prism branch T-1827/bugfix-web-typography-font-and-dynamic-type PR #327 — CONFLICTING / DIRTY commits 1 (ahead 1, behind 2) files 13 touched (2 new) lines +967 / -25 tests macOS green (79) · iOS FAILS (1 test) lint swiftlint 0 · stylelint clean findings 3 blockers · 4 major · 14 minor · 6 nits

Pre-push review: T-1827 / T-1828 web typography

The rendered document finally honours the Body Font setting (T-1827) and iOS Dynamic Type (T-1828) — two T-1542 cutover gaps closed at one seam. The change itself is sound and verified green; the branch is blocked on a merge conflict with newly-landed origin/main (PR #322 / T-1775) in the very file it restructures.

At a glance

  • Blocker — the iOS anti-drift pin fails. WebTypographyBridgeTests.tableMatchesUIFontMetrics fails 8 of 12 categories on iPhone 17 Pro simulator: .xSmall table 14 vs UIKit 14.67, .xxLarge 21 vs 20.0, .xxxLarge 23 vs 22.33, .accessibility1 28 vs 26.33, .accessibility2 33 vs 31.0, .accessibility3 40 vs 37.0, .accessibility4 47 vs 43.33, .accessibility5 53 vs 48.0. Tolerance is 0.51. Because it sits in #if os(iOS), make test-quick never runs it — this branch reports green on macOS while failing the platform the fix is for.
  • Blocker — merge conflict with landed main. git merge-tree origin/main HEAD reports a content conflict in prism/ViewModels/WebDocumentStateSynchronizer.swift. Main's f540cda (T-1775, PR #322) added search-scroll baseline seeding inside start(); this branch changed start() to start(dynamicTypeSize:). PR #327 is CONFLICTING / DIRTY.
  • Blocker (follow-on) — post-merge compile break. Main's new prismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swift:113 calls made.synchronizer.start(). It is a new file, so it merges without conflict and then fails to compile.
  • Everything else is green. 79 targeted tests pass on macOS (exit 0, ** TEST SUCCEEDED **), the iOS test target builds, and on iOS every other test in the two new suites passes — including the escaping boundary, the live-page test, the composed push, both live-change directions, and the recovery replay. make lint 0 violations, make lint-css clean, no new warnings in any changed file.
  • Both mappings genuinely satisfy the spec. Req 1.4 ("Typography settings (font family, text size scale) SHALL apply to rendered content, and changing them SHALL update the open document without reloading it") and Req 10.3 ("Dynamic Type (iOS) and the app's text-scale setting SHALL scale rendered content") — the compounding calc() is exactly what 10.3 asks for. Every font-size in document.css below the body rule is em-relative, so headings, lists, tables, blockquotes and chrome all scale.
  • No un-migrated call sites, no unfed production path. WebDocumentStateSynchronizer.makeAssembly has exactly one production caller (DocumentScrollContent.swift:112), which both layouts mount and which feeds Dynamic Type. No macOS window, share extension, or raw-source path builds a synchronizer.
  • Major — the new suite destabilises a neighbour. Co-scheduled without -parallel-testing-worker-count 1, WebStateSynchronizerAssemblyTests failed 1 of 4 runs (initialStatePushedOnStart, poll budget exhausted); alone under identical flags it passed 5 of 5. AppSettings.makeForTesting writes to UserDefaults.standard and this is the first suite to churn bodyFontFamily while the neighbour polls for exact whole-dict equality — a dict this branch widened from one key to three. make test-quick and make test pass the worker-count flag and are unaffected; make test-locales does not.
  • Major — baseFontSizePx = 17 contradicts an accepted decision. specs/font-settings/decision_log.md Decision 13 lists "Unified 17pt body on all platforms" under Alternatives Considered, "Rejected because it was too large on macOS". The 17px predates this branch (it has been in document.css since T-1542), but this is where it becomes a blessed Swift constant pinned by a new anti-drift test, justified only in an agent note.
  • Major — no decision-log entries, unlike the sibling fix. Four choices here have genuine rejected alternatives and are argued only in code comments. T-1829 — the same class of cutover gap, merged days ago — added a 49-line decision log "per the webview-rendering Decision 15 precedent". design.md:151 also still says Dynamic Type pushes "the resolved scale", now stale.
  • Major — "without losing your reading position" is asserted but unverified. A 17px→53px change reflows the document; nothing re-anchors (no resize observer, no overflow-anchor, and prism-scroll.js only re-anchors programmatic scrolls), so the debounced visible-block report can persist a post-reflow block. The supporting test proves "no reload", a different claim.
  • Agreed: the missing family-name length cap is a nit. Independently confirmed unreachable — AppSettings.bodyFontFamily gates on FontUtilities.fontFamilyExists, so cssQuotedFamily can only ever see a string that exactly equals an installed family name. A measured 100k-char call costs 12 ms, but production cannot reach it.
  • The two visual checks are already tracked. specs/webview-rendering/verification.md lines 67 and 114 are unticked manual entries for exactly Req 1.4 and Req 10.3. No new checklist entry is needed — treat as an outstanding manual step, not a defect.
  • No bugfix-report convention gap. Of the last 24 Fix T-… commits on main, the overwhelming majority ship no specs/bugfixes/ folder — including the closest sibling, T-1829 (#321). Absence here is consistent.

Verdict

Needs fixes

The production fix looks right. Both bugs are real T-1542 cutover gaps against stated requirements (Req 1.4, Req 10.3), the single-composition-point design is the correct answer to applyTypography's whole-dict overwrite, the CSS-string quoting of the untrusted family name holds under everything thrown at it, and the assembly tests are behavioural and de-tautologised. swiftlint reports 0 violations, stylelint is clean, and there are no new compiler warnings on either platform.

Two hard blockers, four majors.

  1. The branch does not pass its own test suite on iOS. WebTypographyBridgeTests.tableMatchesUIFontMetrics — the anti-drift pin the commit message leans on ("pinned against UIFontMetrics on iOS") — fails on 8 of 12 Dynamic Type categories, by up to 5pt at .accessibility5. It is inside #if os(iOS), so make test-quick (macOS) never executes it and reports green; make test (iOS Simulator), which the project's own pre-push gate requires, fails. My reading is that production is right and the test's oracle is wrong — but that is the author's call to make, not mine.
  2. Merge conflict with landed main. origin/main moved two commits during review and PR #322 (T-1775) rewrote WebDocumentStateSynchronizer.start() — the exact function this branch re-signatures. GitHub now reports CONFLICTING / DIRTY, which also means no checks are queued. The resolution is semantic, and main's new WebFragmentNavigationPrecedenceTests.swift calls the old start() and will not compile after the merge.

The four majors are all documentation-or-decision debt rather than broken code, but two of them are load-bearing: the new suite reproducibly destabilises a neighbouring suite through shared UserDefaults (attributed by baseline: neighbour alone 5/5 clean, co-scheduled 1 of 4 failing), and baseFontSizePx = 17 on both platforms is the exact alternative specs/font-settings/decision_log.md Decision 13 explicitly rejected as "too large on macOS" — now pinned in place by a new anti-drift test, with no superseding entry.

Nothing in the design needs reverting. The composition-point architecture, the escaping, and the compounding calc() are all right. Fix the oracle, merge main, settle the Decision 13 question, and re-verify on iOS.

Review findings

27 raised · 0 fixed · 27 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism draws your markdown document inside a web view. Two settings were being ignored by that drawing:

  • Body Font — you could pick a font in Settings, but only the little preview in Settings changed. The document kept using the system font.
  • Larger Text (iOS Dynamic Type) — turning it up made the app's toolbars and sidebars bigger, but every paragraph, heading, list and table in the document stayed exactly the same size.

Both are now wired through. Your chosen font applies to body text, headings, lists and tables — code blocks stay monospace on purpose, because code in a handwriting font is unreadable. The document's base text size follows the system setting, and the app's own Text Size slider still multiplies on top of it rather than replacing it, so 150% of a large system size really is large.

Why it matters

These are accessibility features. Somebody who has turned Larger Text up is doing it because they need it, and the one screen where reading actually happens was the one screen ignoring them.

Key concepts

  • CSS custom properties — the app sends the web view three named values (a font family, a base size, a scale multiplier), and the stylesheet reads them. Nothing is re-rendered; the page just restyles, so your place on the page is kept.
  • Escaping — a font name is stored text, and text going into a stylesheet could in principle be crafted to smuggle in styling rules. It is wrapped in quotes and the dangerous characters neutralised, so a font name is only ever treated as a name.

Architecture

New prism/Services/WebRendering/WebTypography.swift holds three pure pieces: the fallback system font stack (mirroring document.css), CSS-<string> quoting for a family name, and a Dynamic Type → body point size table. WebDocumentControllerFactory.typographyVariables(settings:dynamicTypeSize:) composes three CSS custom properties — --prism-scale, --prism-font-size, --prism-font-family — into the single applyTypography bridge command. prism-theme.js only applies them.

The load-bearing pattern

applyTypography's dict wholly replaces WebDocumentStateSnapshot.typography. So two pushers would not race — they would silently delete each other's variables from the coalesced WebContent-recovery replay. That forces a single composition point. Which is why Dynamic Type is fed into WebDocumentStateSynchronizer (start(dynamicTypeSize:) / applyDynamicTypeSize) rather than pushed from the view the way T-1829 pushes the theme — the WebPaletteFeed grouping pattern deliberately did not transfer, because grouping is only needed when there are two commands to coalesce.

Trade-offs

  • Table, not UIFontMetrics. The mapping is a pure switch so it is testable off-device and exists on macOS. An #if os(iOS) test pins it against UIFontMetrics(forTextStyle: .body) so it cannot drift from the OS.
  • Compounding, not overriding. Dynamic Type resolves --prism-font-size; the in-app slider stays --prism-scale; the stylesheet's existing calc(var(--prism-font-size) * var(--prism-scale)) multiplies them. That is literally what Req 10.3 asks for.
  • No defaulted parameter at any push seam. A silently-.large default is the exact shape of the bug being fixed, so start requires the size — matching the rule T-1829 established for contrast.
  • Emitted even when empty. applyVariables only ever sets properties, so omitting --prism-font-family on "System" would strand a previous family as an inline style forever.

Escaping boundary

cssQuotedFamily emits a CSS <string> token. Inside one, ; { } : and < are ordinary characters; only \ and the delimiter can terminate it, and both are escaped. Control characters (Cc and Cf, since CharacterSet.controlCharacters covers both) are rejected to the system stack rather than escaped — which incidentally also rejects U+202E RTL override and ZWJ. The CSSOM is a second boundary but is deliberately not relied on; liveTypographyVariablesApply proves the point through real WebKit, asserting the hostile fragment survives inside the value, body is not display:none, and documentElement.style.length == 3 so no fourth declaration was smuggled in. Mutation-tested: naive interpolation fails three tests.

Observation semantics

dynamicTypeSize is a plain stored property on a non-@Observable class, so reading it inside computePass registers no dependency and writing it in applyDynamicTypeSize fires no pass — the same discipline the file already documents for cachedMapping. Interleaving is safe because the size is stored before the push, so any already-scheduled pass recomposes an identical dict and pushTypography's dirty-diff swallows it. Both halves are always read live from settings, so no ordering can push a half-stale dict. Exactly one push per change: start seeds then runs the pass (no separate push), and .onChange has no initial: true so there is no mount-time double-push or default-then-correct flicker.

Edge cases

  • macOS. dynamicTypeSize is fixed at .large there, so --prism-font-size is 17.00px and macOS rendering is byte-identical to before. The 17px base on macOS (versus TypographyResolver's 15pt) is deliberate and documented.
  • Unavailable font. AppSettings.bodyFontFamily filters through FontUtilities.fontFamilyExists, so a preference synced from a device that had the font resolves to nil and falls back — a cached Set hit, so free on the pass.
  • rem does not follow. font-size is set on body, not html, so document.css's one rem user (.prism-section-toggle, 1.5rem) stays pinned to the UA 16px while its gutter (main { padding-left: 1.7em }) scales. Pre-existing for the 80–200% slider; Dynamic Type widens the reachable range.
  • Formatting. String(format:) without a locale: is the non-localized overload, so %.2f/%.3f always emit .; calc(17.00px * 1.000) is valid CSS.

Completeness assessment

Fully implemented: Req 1.4 (family + scale, live, no reload) and Req 10.3 (Dynamic Type compounding with the app scale) for the document body and everything em-relative below it; recovery replay; live-change in both directions through the production assembly; the escaping boundary.

Partially implemented: footnote-popover content, which serves the same document.css but has no bridge, so it sees only the :root fallbacks — it ignores the Body Font, Dynamic Type and (pre-existing) the in-app slider.

Not covered by automation: the two visual confirmations, because the live harness deliberately does not apply document.css. Already tracked as unticked manual lines 1.4 and 10.3 in specs/webview-rendering/verification.md.

Important changes — detailed

WebTypography: the whole native→CSS typography mapping, pure and nonisolated

prism/Services/WebRendering/WebTypography.swift

Why it matters. Security boundary and OS-drift boundary in one small file. cssQuotedFamily is the first CSS-value escaper in the codebase — the existing HTMLEscaping/HTMLSanitizer helpers are HTML-context and would emit a literal &quot; into the font name, so reuse would have been a bug, not a saving.

What to look at. WebTypography.swift:34 (systemFontStack), :65-80 (cssQuotedFamily), :104-118 (bodyPointSize)

Takeaway. When escaping, name the grammar you are escaping into. Quoting as a CSS <string> is what makes ';' '{' '}' ':' and '<' inert — so only backslash and the delimiter need handling, and control characters can be rejected outright rather than escaped because no real family name has one.
Rationale. Kept as a pure table rather than a live UIFontMetrics call so the mapping is testable off-device and exists on macOS at all, with an iOS-only test pinning it against UIFontMetrics so it cannot drift from the OS. Stated in the file header and the commit message.

typographyVariables: one composition point, enforced by the command's overwrite semantics

prism/ViewModels/WebDocumentControllerFactory.swift

Why it matters. This is the design decision the whole change turns on, and it is a correctness constraint rather than tidiness. Get it wrong and the failure is invisible until a WebContent process recovery replays a snapshot missing half its variables.

What to look at. WebDocumentControllerFactory.swift:374-390; snapshot semantics at WebDocumentController.swift:532-533 and :558-575

Takeaway. Whether state can be pushed from two places is decided by the command's merge semantics, not by taste. `applyTypography` assigns rather than merges, so two pushers silently delete each other — the fix is one composition site, not locking or ordering.
Rationale. Spelled out in the doc comment and the commit body, and independently verified against WebDocumentStateSnapshot.apply / coalescedCommands.

Dynamic Type fed INTO the synchronizer, not pushed from the view

prism/ViewModels/WebDocumentStateSynchronizer.swift

Why it matters. The obvious move — copy T-1829's WebPaletteFeed pattern and push from the view — would have been wrong here, for the reason above. Worth reading precisely because it deliberately diverges from the sibling fix.

What to look at. WebDocumentStateSynchronizer.swift:67 (stored size), :125-130 (start), :161-167 (applyDynamicTypeSize), :329-333 (shared pushTypography)

Takeaway. A grouping/feed pattern exists to coalesce multiple commands. When there is only one command and it overwrites, the answer is the opposite shape: fold the extra input into the owner rather than adding a second pusher.
Rationale. Stated in both the header comment block and the commit message. Also refactors `dispatch`'s inline typography diff into the shared `pushTypography`, so there is one diff site instead of two.

start(dynamicTypeSize:) takes the size as a required parameter

prism/Views/DocumentScrollContent.swift

Why it matters. This is also the source of the merge conflict — main's PR #322 rewrote the same function. Reviewers should look at both versions of start() together.

What to look at. DocumentScrollContent.swift:62 (@Environment), :125 (seeded start), :169-171 (.onChange)

Takeaway. A defaulted parameter at a push seam is a bug generator when the default is a plausible-looking value: `.large` and `.standard` both compile, both look fine, and both silently drop an accessibility setting. Making it required turns a silent regression into a compile error — and it is why the four test call sites appear in this diff at all.
Rationale. Follows the rule T-1829 established for `contrast` on `pushInitialState`; stated in the doc comments on both functions.

Two pre-existing assembly assertions de-tautologised

prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift

Why it matters. These assertions compared snapshot.typography against typographyVariables — the function under test on both sides — so they passed for the entire life of both bugs. This is the most transferable lesson in the diff.

What to look at. WebStateSynchronizerAssemblyTests.swift:159-166 (names each variable), :194-198 (literal scale value)

Takeaway. An assertion of the form `actual == functionUnderTest(input)` proves only that the plumbing is connected, never that the value is right. Name the expected content literally, at least once.
Rationale. Called out explicitly in the commit message's closing paragraph.

Key decisions

Dynamic Type fed into the synchronizer rather than pushed from the view.

applyTypography carries one dict that wholly replaces WebDocumentStateSnapshot.typography, so a second pusher would delete the first's variables from the coalesced recovery replay. T-1829's WebPaletteFeed grouping pattern therefore deliberately did not transfer — grouping solves coalescing two commands, which is not the problem here.

A pure table instead of a live UIFontMetrics call.

Keeps the mapping pure, testable off-device, and available on macOS where SwiftUI still reports a dynamicTypeSize but UIKit metrics do not exist. Drift risk is answered by an #if os(iOS) test pinning the table against UIFontMetrics(forTextStyle: .body), tolerance 0.51pt.

17px base on macOS, despite TypographyResolver's 15pt macOS body baseline.

17px is what the web renderer has always used on macOS; changing it would resize every macOS document, which is not this fix's business. Sharing the constant with TypographyResolver is not possible without making one path wrong.

--prism-font-family emitted even when the user picks "System".

applyVariables in prism-theme.js only ever sets custom properties. Omitting the variable would leave a previously-pushed family as an inline style, so "System" would never come back without a reload.

--prism-font-mono deliberately never pushed.

That is the whole mechanism keeping code blocks and inline code monospace under a custom body font. The code font is T-428's scope. Pinned by DocumentCSSTypographyRulesTests.codeKeepsMonospaceStack.

No defaulted parameter on start / pushInitialState.

A silently-.large default is the exact shape of T-1828 and a silently-.standard one was T-1829: a push path that keeps compiling while dropping a system accessibility state. Making it required is what surfaced the four test call sites in this diff.

Control characters rejected rather than escaped.

A newline is outright illegal inside a CSS string, and no real family name contains a control character — they can only arrive from a tampered preference. Rejecting to the system stack is a safer failure than escaping something meaningless. As a side effect this also rejects U+202E and ZWJ, since CharacterSet.controlCharacters covers Cf as well as Cc.

Coverage split between bridge assertions and CSS rule pins, with the visual check manual.

The live WebPage harness deliberately does not apply document.css (specs/readable-tables-restoration/decision_log.md Decision 2), so computed font sizes cannot be asserted. Hence WebTypographyBridgeTests for the variables and DocumentCSSTypographyRulesTests for the stylesheet's fallbacks and its consumers. The two visual confirmations remain manual, already tracked as unticked lines 1.4 and 10.3 in specs/webview-rendering/verification.md.

CSS variable names left as bare string literals.

No constants convention exists for these anywhere in the repo — --prism-scale was already a bare literal at this exact site, and the T-1829 contrast work made the same choice. Drift is covered by the DocumentCSS…RulesTests guards. Introducing an enum here would be a repo-wide convention change, not a fix to this diff.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
blockerprismTests/WebRendering/WebTypographyBridgeTests.swift:167-186 (tableMatchesUIFontMetrics)The OS anti-drift pin FAILS on iOS. Run on iPhone 17 Pro simulator, it fails 8 of 12 Dynamic Type categories against its 0.51pt tolerance: .xSmall table 14 vs UIKit 14.667 (delta 0.67), .xxLarge 21 vs 20.0 (1.0), .xxxLarge 23 vs 22.333 (0.67), .accessibility1 28 vs 26.333 (1.67), .accessibility2 33 vs 31.0 (2.0), .accessibility3 40 vs 37.0 (3.0), .accessibility4 47 vs 43.333 (3.67), .accessibility5 53 vs 48.0 (5.0). Only .small/.medium/.large/.xLarge pass — the two curves agree in the middle and diverge at both extremes. Because the test is inside #if os(iOS), `make test-quick` (macOS) never executes it, which is why it reports green there; `make test` (iOS Simulator), which this project's own pre-push gate requires, fails. Diagnosis: the TABLE is right and the ORACLE is wrong. WebTypography's values (14/15/16/17/19/21/23/28/33/40/47/53) are exactly Apple's published Dynamic Type sizes for the Body text style. `UIFontMetrics(forTextStyle: .body).scaledValue(for: 17)` is a different thing — it applies the body style's generic scaling curve to an arbitrary 17pt value, and it does not reproduce the published body table at the extremes. The oracle that does is `UIFont.preferredFont(forTextStyle: .body, compatibleWith: traits).pointSize`. So the commit message's claim that the table is 'pinned against UIFontMetrics on iOS' is not currently true — the pin was almost certainly never executed, since only the macOS suite was run.Author's call, and it must be made deliberately: either switch the oracle to UIFont.preferredFont(forTextStyle: .body, compatibleWith:).pointSize (my reading — keeps the correct published body sizes and makes the pin actually pin), or accept UIFontMetrics' curve and change the production table (which would reduce the accessibility ceiling from 3.1x to 2.8x). NOT fixed here: this needs either a test-logic or a production change, and pinning a wrong value as intended behaviour is exactly the failure mode this review is scoped to avoid. Whichever way it goes, re-run on the iOS Simulator, not just make test-quick.
blockergit / prism/ViewModels/WebDocumentStateSynchronizer.swiftorigin/main moved two commits during review and now conflicts. f540cda (T-1775, PR #322) rewrote WebDocumentStateSynchronizer.start() to seed a search-scroll baseline; this branch changed the same function's signature to start(dynamicTypeSize:). `git merge-tree --write-tree --messages origin/main HEAD` reports CONFLICT (content) in that file, and `gh pr view 327` now reports mergeable=CONFLICTING, mergeStateStatus=DIRTY. Because GitHub queues no checks while a PR is CONFLICTING, there is nothing green on the PR to trust either. The resolution is semantic, not textual: the merged start(dynamicTypeSize:) must perform BOTH the Dynamic Type seed and main's seedSearchScrollBaseline call, and main also changed the SyncPass struct (new parseRevision field) and the search-scroll branch of dispatch that this branch refactored.Merge origin/main into the branch and resolve by hand, then re-run the targeted suites and both builds. NOT fixed here: resolving this is a production-code change, which this review is scoped out of making.
blockerprismTests/WebRendering/WebFragmentNavigationPrecedenceTests.swiftMain's f540cda added this file (624 lines) and it calls `made.synchronizer.start()` at line 113. It is a new file on main, so it merges without a textual conflict and then fails to compile against start(dynamicTypeSize:). Main's DocumentScrollContent.swift:117 also still calls start(), but that line merges to this branch's version cleanly since main did not touch it.Pass an explicit dynamicTypeSize at that call site as part of the merge resolution — .large matches every other test call site. NOT fixed here (test-logic change, and only reachable after the merge).
majorprismTests/WebRendering/WebTypographyBridgeTests.swift (shared UserDefaults) vs WebStateSynchronizerAssemblyTestsThe new suite destabilises a pre-existing neighbour. Reproduced on macOS: WebTypographyBridgeTests + WebStateSynchronizerAssemblyTests co-scheduled WITHOUT -parallel-testing-worker-count 1 failed 1 of 4 runs (WebStateSynchronizerAssemblyTests/initialStatePushedOnStart, 2.131 s — the waitUntil budget exhausted, not an assertion mismatch). Baseline: the neighbour suite ALONE, same flags, passed 5 of 5. So the flake is attributable to co-scheduling with the new suite. Mechanism: AppSettings.makeForTesting writes straight to UserDefaults.standard (AppSettings.swift:488-490 says so in a comment), prism.xctestplan marks the prismTests target parallelizable: true, and the new suite is the first to churn bodyFontFamily (WebTypographyBridgeTests.swift:1024, :1054, :1141) while the neighbour polls for EXACT whole-dict equality of snapshot.typography. This branch also widened that dict from one key to three, so there is now more shared state to race on. Scope check: make test-quick and make test both pass -parallel-testing-worker-count 1, and I could not reproduce under those flags (3 of 3 and 4 of 4 clean, all four localisation configurations running) — but make test-locales does NOT pass it, so the flake is reachable from a documented Makefile target, and from any plain Xcode IDE test run.Isolate the new suite's settings behind a suite-scoped UserDefaults (the hint AppSettings.swift:490 already gives), or stop the neighbour asserting whole-dict equality against globally mutable state. NOT fixed here — test-logic change. Worth doing, because a flake in a suite the branch merely touched will read as an unrelated pre-existing failure.
majorprism/Services/WebRendering/WebTypography.swift:83 vs specs/font-settings/decision_log.md Decision 13baseFontSizePx = 17 on both platforms is the alternative an accepted decision explicitly rejected. Decision 13 'Platform-Specific Reading Baselines' (dated 2026-03-11, status accepted) lists under Alternatives Considered: 'Unified 17pt body on all platforms: Original implementation - Rejected because it was too large on macOS', with the rationale that at 17pt macOS users 'had to set scale to 80% (the minimum), leaving no room for those who prefer smaller text'. The 17px is pre-existing in document.css since the T-1542 cutover, so this is not a regression introduced here — but this branch is where it becomes a blessed Swift constant WITH an anti-drift test pinning it in place (DocumentCSSTypographyRulesTests.baseSizeMatchesSwift), and the only justification is a line in docs/agent-notes/typography-font-settings.md. An accepted decision is now silently contradicted by pinned code.Either add a superseding decision-log entry explaining why the web path departs from Decision 13, or make the constant platform-specific to match TypographyResolver's 15pt macOS baseline. NOT fixed here — either option is a production or spec change, and the choice has real user-visible consequences on macOS.
majorspecs/ (no decision-log entries) — precedent: specs/a11y-increase-contrast/decision_log.mdNo decision-log entry anywhere for a change with at least four choices that have genuine rejected alternatives: the static Dynamic Type table versus a live UIFontMetrics call; feeding Dynamic Type into the synchronizer versus a second view-side push; emitting --prism-font-family unconditionally; and 17px on both platforms. All are argued only in code comments. The immediately preceding sibling fix — 024f354, T-1829, the same class of T-1542 cutover gap — added specs/a11y-increase-contrast/decision_log.md (+49 lines) and its commit message states it was doing so 'per the webview-rendering Decision 15 precedent'. Additionally specs/webview-rendering/design.md:151 still says content-size-category changes trigger applyTypography 'with the resolved scale', which is now stale: the implementation carries a resolved SIZE on a second variable so the two compound.Add the decision-log entries and refresh design.md:151. NOT fixed here — this is authored spec content, and per the project's decision-log format the entries need real Alternatives Considered and Consequences sections that only the author can write honestly.
majorCHANGELOG.md:21 — 'without losing your reading position'Asserted but not verified, and the mechanism looks absent. Unlike T-1829 (a colour change, no reflow), a Dynamic Type change from 17px to 53px reflows the entire document. Nothing re-anchors: prism-scroll.js only re-anchors around PROGRAMMATIC scrolls (beginProgrammaticScroll), nothing observes a resize or layout change, and no overflow-anchor is set anywhere in prism/Resources/WebRenderer/. scrollY is unchanged across the reflow, so the debounced reportVisibleBlock then posts the POST-reflow visible block and the persisted reading position can be overwritten with the wrong one. The only supporting test evidence is #expect(harness.page.url == urlBefore), which proves 'no reload' — a different claim.Either soften the CHANGELOG claim to what is actually verified (no reload), or add the reading-position observation as an explicit manual-verification line. Do not leave it asserted and untested. NOT fixed here: although the CHANGELOG is editorially in scope for this review, choosing between softening the claim and keeping it (which implies a production re-anchor) is a behavioural decision for the author.
minorprismTests/WebRendering/WebTypographyBridgeTests.swift:410liveTypographyVariablesApply uses an unconditional `try await Task.sleep(for: .milliseconds(100))` after harness.send, then asserts the pushed value. That is a fixed delay, not a poll: on a loaded machine the bridge round-trip can exceed 100 ms and the #expect at :414 fails spuriously. This is the only genuine flake risk in the new tests, and the file already has a polling helper (waitUntil, :322-328) plus the harness has its own.Poll evalString until it returns "28.00px" instead of sleeping. NOT fixed here — test-logic change, reported for the author.
minorprismTests/WebRendering/WebTypographyBridgeTests.swift:443-462The private `extension UIContentSizeCategory { init(_ size: DynamicTypeSize) }` reimplements an SDK API. `public init(_ dynamicTypeSize: SwiftUICore.DynamicTypeSize?)` exists, iOS 15+, verified in iPhoneOS26.5.sdk SwiftUI.swiftinterface line 17989. This matters more than a routine duplicate because the test it feeds (tableMatchesUIFontMetrics) is billed as anti-drift against the OS, yet routes UIKit's ground truth through a second hand-written DynamicTypeSize mapping — correlated mistakes in both could mask each other. Because the private overload takes a non-optional parameter it also wins overload resolution, so nothing at the call site hints the SDK version exists.Delete the extension; `UIContentSizeCategory(size)` then resolves to the SDK init with no other change. NOT fixed here — test-logic change, reported for the author.
minorprism/ViewModels/FootnotePopoverWebPage.swift:58-63The footnote popover serves the same bundled document.css but has no bridge and no user scripts, so it only ever sees the :root fallbacks — 17px, system stack. Footnote content therefore ignores the Body Font, Dynamic Type, AND the in-app text-size slider. The slider case is pre-existing (independently confirmed: nothing in FootnotePopoverWebPage or FootnotePopoverView references typography at all), so this is a pre-existing gap in kind rather than a regression — but Dynamic Type widens it from a ~2x mismatch to a ~3x one, and on iOS with Larger Text it is user-visible: the document scales, the popover does not. The popover already solves the analogous theme problem by baking the theme into the emitted <html data-prism-theme=…>, so the same trick would close it.Out of this fix's scope, but the CHANGELOG explicitly scopes the T-1829 popover gap ("Footnote popovers keep the standard palette") and says nothing about typography. Record it — a CHANGELOG clause or an agent-note line — or raise a follow-up ticket. NOT fixed here.
minorprism/Resources/WebRenderer/document.css:645-649font-size is set on `body` (:321), not `html`, and nothing in the stylesheet sets `html { font-size }`. So the one rem user in the file — .prism-section-toggle's `width: 1.5rem; margin-left: -1.5rem` — stays pinned to the UA default 16px while the gutter it sits in (`main { padding-left: 1.7em }`, :336) scales with the body. The collapse chevron drifts out of the note-affordance column as text grows. Verified by reading both rules; every other font-size in the file is em-relative and does scale correctly (h1-h6 :349-354, ul/ol :378, li :382, blockquote :365-370, code 0.92em :391, footnote badge :733, inline notes :757). This mismatch is already reachable today via the 80-200% slider (1.7em vs 24px at 200% is already a ~34px gap), so it is pre-existing rather than introduced — Dynamic Type roughly triples the reachable range.Not a regression from this change, so not a blocker. A follow-up could move the font-size declaration from `body` to `html` and let body inherit — but note DocumentCSSTypographyRulesTests.bodyRuleCombinesBothScales pins it on the body rule, so that is a coupled production+test change. NOT fixed here.
minorprism/Resources/WebRenderer/document.css:618The readable-table column cap stays a fixed 300px. At accessibility sizes that holds only a few words per column, so "readable" mode becomes less readable. The 300pt figure is the readable-tables spec's rule and predates this change; Dynamic Type is what makes the small end reachable.Pre-existing spec value, out of scope. Worth a follow-up (em-based, or max(300px, Nem)) if it looks wrong during the manual Req 10.3 check. NOT fixed here.
minorprism/ViewModels/WebDocumentStateSynchronizer.swift:125-130, :161-167applyDynamicTypeSize does not check isStarted. Called before start(), it stores the size and pushes typography (setting lastTypography) with the observation pass unarmed; a subsequent start(dynamicTypeSize:) then overwrites the stored size at :128, silently discarding the newer view-fed value and pushing the smaller dict. Unreachable in production — DocumentScrollContent assigns @State synchronizer only after start returns, with no await between, and .onChange goes through synchronizer?, so it is nil until then — so this is latent, not a live defect. But the asymmetry is a trap, and it is precisely the "keeps compiling while dropping the accessibility state" class the surrounding comments guard against. Separately, a second start(…) with a different size is silently ignored by the !isStarted guard without re-seeding (harmless today, since a remount builds a fresh synchronizer, but undocumented).Optional hardening: `guard isStarted else { return }` at the top of applyDynamicTypeSize. NOT fixed here — production-code change; worth folding into the merge resolution since it touches the same region.
minorprism/ViewModels/WebDocumentControllerFactory.swift:247-264pushInitialState has no production caller — its only caller anywhere is prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift:140. Production pushes initial state through WebDocumentStateSynchronizer.start → dispatch, a different and larger set (typography + comments + notes + sections + details + tables), so that test's "Mirror the app" comment at :87 is already only approximately true. This diff correctly updated the signature, but its new doc comment about "every caller has to state its intent" is aspirational — there are no production callers left to protect.Pre-existing dead code, not introduced here. Worth a follow-up to delete the helper and have the test drive synchronizer.start(dynamicTypeSize:) so there is one initial-push path rather than two. NOT fixed here.
minorprismTests/WebRendering/DocumentCSSTypographyRulesTests.swift:57-59The new file adds a `withoutComments` regex pass that fixes a real latent defect its three siblings still carry: DocumentCSSContrastRulesTests.ruleBody's docstring claims a fragment matched inside a preceding comment is skipped, but that file never strips comments and the }-between-{ check does not protect against a comment match. It happens not to bite there because [data-prism-contrast=…] never appears in prose. So the fix landed in only one of four copies of the helper (also DocumentCSSTableRulesTests, DocumentCSSCodeBlockRulesTests), all of which predate this branch.The 4x duplication is pre-existing convention, not introduced here — this branch follows it. A separate chore extracting one DocumentCSSFixture helper would propagate the comment-stripping fix and stop a fifth copy appearing. NOT fixed here.
minorCLAUDE.md:53Now factually wrong. It states that DocumentScrollContent mounts the assembly '(the palette ALONE is view-fed, via applyTheme(themeKey:contrast:), because it depends on view-world environment values…)'. After this branch dynamicTypeSize is a second view-world environment value fed from the view, through start(dynamicTypeSize:) / applyDynamicTypeSize. T-1829 rewrote this exact sentence for exactly this reason, and its own pre-push review fixed a stale applyTheme signature in CLAUDE.md — so keeping it current is established practice here. No Source Structure tree change is needed; Services/WebRendering/ files are not enumerated there.One-sentence correction. NOT applied here on purpose: this branch needs an origin/main merge and a decision on the failing test first, and an unrelated edit from the reviewer would have to survive that merge. Flagging it for the author to fold in.
minorspecs/webview-rendering/verification.md:67, :114The two manual lines exist but are too weak to have caught these bugs. Line 67 reads 'Changing font family and text-size scale updates the open document without reloading it' — that can be ticked by observing 'no reload' while the family never applies, which is precisely the state T-1827 was in for the whole life of the bug. Line 114 ('Dynamic Type [iOS] and the app's text-scale setting scale rendered content') does not name the reading-position observation from the CHANGELOG finding above.Sharpen both to name the observable: 1.4 should say the family reaches body/headings/lists/tables/blockquotes and NOT code; 10.3 should include 'and the reading position is kept across the reflow'. NOT fixed here — spec content, and it depends on how the reading-position claim is resolved.
minormacOS behaviour — WebTypography.bodyPointSize / CHANGELOG.md:21The macOS half of the mapping is unverified and unannounced. The UIFontMetrics pin is #if os(iOS), so nothing checks macOS at all, yet typographyVariables is not platform-gated: on macOS the document base size now tracks System Settings > Appearance > Text Size through SwiftUI's dynamicTypeSize, while the app chrome scales through @ScaledMetric/NSFont. The two can diverge by setting. The CHANGELOG mentions only iOS 'Larger Text', so if macOS behaviour does change it ships unannounced. The counter-argument — that macOS reports a fixed .large, making rendering byte-identical — is Apple's documented behaviour and probably correct, but it is reasoning, not evidence, and no test pins it.Confirm on a Mac during the manual pass (open a document, check nothing resized, then move the system Text Size setting), and either add a macOS assertion or state the platform assumption in the agent note. NOT fixed here.
minorprism/Resources/WebRenderer/document.css:464.prism-copy / .prism-chrome-button carry font-family: var(--prism-font-family), so the in-page copy, view-source and full-screen pills now adopt the user's reading font. specs/font-settings/requirements.md:41 (Req 1.4) scopes typography to document content and says 'Application UI elements (toolbar labels, settings controls, note editor text, metadata) are not affected'. In-document chrome is genuinely a grey area — these pills sit inside the document, not the app toolbar — but the rule pre-dates the variable ever being pushed, so nobody has decided this.Decide deliberately: either accept it (one sentence in the agent note or a quick decision) or key that rule off a chrome-specific family. NOT fixed here.
minordocs/agent-notes/typography-font-settings.mdThe new web-path section is accurate and well aimed, but the update leaves the file internally contradictory. The retained native list is headed 'Treat this list as history unless the file still exists' — 8 of its 11 entries name files that are gone (HighlightedInlineText.swift, TextualBlockView.swift, CodeBlockView.swift, TruncatedCodeBlockView.swift, ListBlockView.swift, DetailsBlockView.swift, TextualThemeAdapter.swift, AccessibleTableView.swift), so the hedge pushes verification cost onto every future reader instead of deleting dead entries. The first entry, TypographyModifier.swift, does still exist but View.applyTypography(_:) has zero call sites, so the hedge actively misleads there. Sections further down were not touched: 'Markdown Rendering Paths' still describes HighlightedInlineText/InlineText/Textual as the live path and 'Key Design Decisions'/'Known Issues' still cite HighlightSwift's CodeText — both dependencies were dropped in T-1542. Per CLAUDE.md, a stale note is worse than no note.Delete the dead entries rather than hedging them, and reconcile the later sections with the web path. NOT applied here for the same reason as the CLAUDE.md item — it would have to survive the pending merge.
minorCHANGELOG.md:21 (wording)Two small inaccuracies. (a) 'Body text, headings, lists, and tables now use the selected family' omits blockquotes, which specs/font-settings/requirements.md Req 2.3 lists explicitly and which the fix does cover (blockquotes inherit; no competing font-family rule). (b) 'a font name is applied as text only, so it cannot alter the document's styling' is literally false — it alters font-family, which is the entire point. The intended meaning is that it cannot inject styling BEYOND the family.Purely editorial. NOT applied here only because the same paragraph is the subject of the unresolved reading-position finding above, so it should be edited once, together. Both are one-word fixes.
nitprismTests/WebRendering/WebTypographyBridgeTests.swift:1180-1189, :947-959, :975Three small test-quality items. (a) unchangedDynamicTypeDoesNotPush measures controller.pendingCommands.count, which only works because isReady is false in the assembly; it would silently become vacuous (0 == 0) if the harness ever became ready — an #expect(before > 0) would guard that. (b) multiplierIsMonotonic iterates DynamicTypeSize.allCases and assumes ascending order; true today (synthesised allCases follows declaration order) but not contractual, and .allCases.sorted() is free since DynamicTypeSize is Comparable. A wrong order would produce a false failure rather than a false pass, hence nit. (c) The 0.51 tolerance is the right choice — integer point sizes, so it catches every 1pt drift while tolerating float noise — but is unexplained in a test whose whole purpose is anti-drift.All cosmetic. NOT fixed here — test-logic changes.
nitprism/Services/WebRendering/WebTypography.swift:118 (dynamicTypeMultiplier)dynamicTypeMultiplier has no production caller — it is referenced only from WebTypographyBridgeTests (multiplierIsMonotonic, defaultSizeIsNeutral). Production API existing solely for tests; the same assertions could compare bodyPointSize directly.Cosmetic. Either inline it into the tests or leave it as a readable expression of intent.
nitprism/Services/WebRendering/WebTypography.swift:128-130 vs prismTests/.../DocumentCSSTypographyRulesTests.swift:151fontSizeValue formats an integral table with %.2f, producing "17.00px", while the anti-drift test pins the CSS fallback as "\(Int(baseFontSizePx))px" = "17px". Both are valid CSS and render identically, so no defect — but two representations of the same value are formatted differently, which is what those anti-drift tests exist to prevent.Cosmetic. "%.0fpx" would make them symmetric. Not worth a round trip on its own.
nitprism/Services/WebRendering/WebTypography.swift:65-80The known, already-raised item: no length cap on the family name before quoting. Assessed and AGREED with the previous reviewer that it is a nit. Independently confirmed unreachable in production — the only production caller passes resolver.bodyFontFamily, and AppSettings.bodyFontFamily:356-363 gates on FontUtilities.fontFamilyExists (an O(1) Set hit against cached NSFontManager/UIFont family names), so cssQuotedFamily can only ever see a string that exactly equals an installed family name. Measured cost of a hypothetical 100k-char call is 12.1 ms; measured cost of the actual production call is 3.27 us per observation pass, of which 1.87 us is String(format:) rather than the escaping. The dirty-diff in pushTypography means an unchanged recomposition costs three short string compares.No change needed. A one-line `guard trimmed.utf8.count <= 256` would make it O(1)-bounded against future callers that skip the FontUtilities filter, if the author wants belt-and-braces.
nitprism/ViewModels/WebDocumentControllerFactory.swift:378typographyVariables builds a full TypographyResolver whose init also reads settings.monoFontFamily, which this path does not use. Because that read happens inside the tracked block, changing the Mono Font in Settings fires a whole observation pass (block-mapping walk + noteStatePayloads JSON encode) that pushes nothing. Pre-existing — the old one-line version constructed the resolver too — so this diff adds no new invalidation surface.Reading settings.textSizeScale and settings.bodyFontFamily directly would be cheaper and narrower, but it is one wasted pass per user action. Not worth blocking on.
nitprism/Resources/WebRenderer/document.css:831, :1010Two comments justify `font-size: 1em` on buttons by reference to an absolute content size ("~13.3px is smaller than the content (17px)"). With Dynamic Type the 17px is no longer the content size. The rule itself is correct and scale-independent; only the comment's parenthetical is now approximate. Pre-existing text, not in this diff.Cosmetic comment drift. Ignore, or reword opportunistically.

Per-file diffs

Click to expand.

prism/Services/WebRendering/WebTypography.swift New +131 / -0
diff --git a/prism/Services/WebRendering/WebTypography.swift b/prism/Services/WebRendering/WebTypography.swiftnew file mode 100644index 0000000..57ce73a--- /dev/null+++ b/prism/Services/WebRendering/WebTypography.swift@@ -0,0 +1,131 @@+//+//  WebTypography.swift+//  prism+//+//  The native side of the rendered document's typography variables (Req 1.4 /+//  10.3): the body font family the user picked in Settings (T-1827) and the+//  Dynamic Type base size (T-1828).+//+//  Pure and nonisolated by design. `WebDocumentControllerFactory.typographyVariables`+//  composes these into the single `applyTypography` variables dict, which is the ONLY+//  place typography state is assembled — see the note there on why a split push would+//  lose state rather than merely race.+//++import SwiftUI++/// Native → CSS typography mapping for the web-rendered document.+///+/// The values here mirror `document.css`'s `:root` fallbacks, which apply for the+/// frames before the first `applyTypography` lands (and if the bridge never comes+/// up at all). `DocumentCSSTypographyRulesTests` pins the two representations+/// together, the way `DocumentCSSContrastRulesTests` pins the contrast palette.+enum WebTypography {++    // MARK: - Body font family (T-1827)++    /// The fallback family stack, mirroring `document.css`'s `--prism-font-family`.+    ///+    /// Always emitted, even when the user has no font selected: `applyVariables` in+    /// prism-theme.js only ever *sets* custom properties, so omitting the variable+    /// after a family had been pushed would leave the previous inline value in place+    /// and "System" would never come back without a reload.+    static let systemFontStack =+        "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Helvetica Neue\", sans-serif"++    /// The `--prism-font-family` value for a user-selected family, falling back to+    /// the system stack when there is no selection (or the name is unusable).+    ///+    /// The user family is always FIRST and the system stack always follows, so a font+    /// that resolves on the picker's device but not in WebKit still renders readable+    /// text rather than the browser default serif.+    ///+    /// Only the proportional stack is affected — `--prism-font-mono` is untouched, so+    /// code blocks and inline code stay monospace (Req 1.4).+    static func fontFamilyValue(userFamily: String?) -> String {+        guard let userFamily, let quoted = cssQuotedFamily(userFamily) else {+            return systemFontStack+        }+        return "\(quoted), \(systemFontStack)"+    }++    /// Quotes a font family name as a CSS `<string>` token, or nil when the name+    /// cannot be represented safely.+    ///+    /// The family name is untrusted: `AppSettings.bodyFontFamily` is backed by a+    /// `UserDefaults` string that a user (or a restored/synced preference) can set to+    /// anything, so it is escaped rather than interpolated. Quoting is the boundary —+    /// inside a CSS string `;`, `{`, `}`, `:` and `<` are ordinary characters and+    /// cannot terminate the declaration or open a new rule. Backslash and the quote+    /// character itself are the only two that can, and both are escaped.+    ///+    /// Control characters (including newlines, which are outright illegal inside a CSS+    /// string) are rejected instead of escaped: they can only arrive from a tampered+    /// preference and no real family name contains one.+    static func cssQuotedFamily(_ family: String) -> String? {+        let trimmed = family.trimmingCharacters(in: .whitespacesAndNewlines)+        guard !trimmed.isEmpty else { return nil }+        guard !trimmed.unicodeScalars.contains(where: {+            CharacterSet.controlCharacters.contains($0)+        }) else { return nil }++        var escaped = String.UnicodeScalarView()+        for scalar in trimmed.unicodeScalars {+            if scalar == "\\" || scalar == "\"" {+                escaped.append("\\")+            }+            escaped.append(scalar)+        }+        return "\"\(String(escaped))\""+    }++    // MARK: - Dynamic Type (T-1828)++    /// The document body's base font size in CSS pixels, mirroring `document.css`'s+    /// `--prism-font-size`. This is the iOS Dynamic Type "Large" body size, and is+    /// deliberately used on macOS too — it is the size the web renderer has always+    /// used there, and changing it is not this fix's business.+    static let baseFontSizePx: Double = 17++    /// Apple's body point size for each Dynamic Type size (the values+    /// `UIFontMetrics(forTextStyle: .body)` resolves to). `.large` is the 17pt+    /// default, so its multiplier is exactly 1 and the pre-fix rendering is+    /// unchanged for users who never touched Larger Text.+    ///+    /// A table rather than a live `UIFontMetrics` call so the mapping is pure,+    /// testable off-device, and available on macOS — where SwiftUI still reports a+    /// `dynamicTypeSize` but UIKit's metrics do not exist. `WebTypographyBridgeTests`+    /// pins the table against `UIFontMetrics` on iOS, so it cannot drift from the OS.+    static func bodyPointSize(for size: DynamicTypeSize) -> Double {+        switch size {+        case .xSmall: return 14+        case .small: return 15+        case .medium: return 16+        case .large: return 17+        case .xLarge: return 19+        case .xxLarge: return 21+        case .xxxLarge: return 23+        case .accessibility1: return 28+        case .accessibility2: return 33+        case .accessibility3: return 40+        case .accessibility4: return 47+        case .accessibility5: return 53+        @unknown default: return baseFontSizePx+        }+    }++    /// The Dynamic Type multiplier relative to the default body size.+    static func dynamicTypeMultiplier(for size: DynamicTypeSize) -> Double {+        bodyPointSize(for: size) / baseFontSizePx+    }++    /// The `--prism-font-size` value: the base size resolved for the current Dynamic+    /// Type size. The app's own text-scale slider stays a separate multiplier+    /// (`--prism-scale`), so the stylesheet's+    /// `calc(var(--prism-font-size) * var(--prism-scale))` combines the two — system+    /// accessibility scaling and the in-app setting compound rather than override+    /// each other (Req 10.3).+    static func fontSizeValue(dynamicTypeSize: DynamicTypeSize) -> String {+        String(format: "%.2fpx", bodyPointSize(for: dynamicTypeSize))+    }+}
prism/ViewModels/WebDocumentControllerFactory.swift Modified +36 / -9
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex e84f4b2..c1457e8 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -16,6 +16,7 @@  import Foundation import OSLog+import SwiftUI import WebKit  @MainActor@@ -239,14 +240,16 @@ enum WebDocumentControllerFactory {     /// `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.+    /// `contrast` and `dynamicTypeSize` carry no default on purpose. A silently-+    /// `.standard`/`.large` default is the exact shape of the T-1829 and T-1828+    /// regressions — a push path that keeps compiling while dropping a system+    /// accessibility state — so every caller has to state its intent.     static func pushInitialState(         to controller: WebDocumentController,         settings: AppSettings,         themeKey: String,-        contrast: WebContrastMode+        contrast: WebContrastMode,+        dynamicTypeSize: DynamicTypeSize     ) {         controller.applyTheme(             theme: themeKey,@@ -254,7 +257,9 @@ enum WebDocumentControllerFactory {             variables: [:],             mermaidConfig: mermaidThemeConfigJSON(for: themeKey)         )-        controller.applyTypography(variables: typographyVariables(settings: settings))+        controller.applyTypography(variables: typographyVariables(+            settings: settings, dynamicTypeSize: dynamicTypeSize+        ))         controller.setCommentVisibility(settings.showHTMLComments)     } @@ -351,13 +356,35 @@ enum WebDocumentControllerFactory {         )     } -    /// The CSS typography variables for the current text-scale setting (Req 1.4).-    /// Mirrors the SwiftUI text-scale path; iOS Dynamic Type changes re-push these-    /// (Req 10.3).-    static func typographyVariables(settings: AppSettings) -> [String: String] {+    /// The CSS typography variables for the current typography state (Req 1.4/10.3):+    /// the in-app text-scale setting, the Dynamic Type resolved base size (T-1828),+    /// and the selected body font family (T-1827).+    ///+    /// This is the ONLY place the typography variables are assembled, and that is a+    /// correctness requirement rather than tidiness. `applyTypography` carries one+    /// dict that WHOLLY REPLACES `WebDocumentStateSnapshot.typography`, so two+    /// pushers — say the settings-observing synchronizer and a view feeding Dynamic+    /// Type — would not race, they would silently drop each other's variables from+    /// the recovery replay. Every input is composed here and pushed once.+    ///+    /// `dynamicTypeSize` carries no default, for the reason `contrast` carries none+    /// on `pushInitialState`: a defaulted `.large` is invisible at the call site and+    /// is exactly the T-1828 bug — a push path that keeps compiling while dropping+    /// the system accessibility state.+    static func typographyVariables(+        settings: AppSettings,+        dynamicTypeSize: DynamicTypeSize+    ) -> [String: String] {         let resolver = TypographyResolver(from: settings)         return [             "--prism-scale": String(format: "%.3f", resolver.scaleFactor),+            "--prism-font-size": WebTypography.fontSizeValue(dynamicTypeSize: dynamicTypeSize),+            // `resolver.bodyFontFamily` is nil both for "System" and for a stored+            // family that no longer exists on this device (AppSettings filters through+            // FontUtilities), so an unavailable font falls back to the system stack.+            "--prism-font-family": WebTypography.fontFamilyValue(+                userFamily: resolver.bodyFontFamily+            ),         ]     } 
prism/ViewModels/WebDocumentStateSynchronizer.swift Modified — CONFLICTS with origin/main +56 / -7
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex 9944aaf..f02dddf 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -23,6 +23,10 @@ //    - 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).+//    - Dynamic Type: `dynamicTypeSize` is also a view-world environment value, but+//      unlike theme it does NOT get its own push — it is stored here and folded into+//      the typography domain above, because `applyTypography` carries one variables+//      dict that wholly replaces the snapshot's typography (T-1828). // //  Change reaction runs through the Observation framework, NOT SwiftUI //  `.onChange`, so the wiring exists independent of any view being mounted —@@ -39,6 +43,7 @@  import Foundation import Observation+import SwiftUI  @MainActor final class WebDocumentStateSynchronizer {@@ -49,9 +54,18 @@ final class WebDocumentStateSynchronizer {     private let settings: AppSettings     private let notesManager: NotesManager -    /// Guards `start()` against arming a second observation pass.+    /// Guards `start(dynamicTypeSize:)` against arming a second observation pass.     private var isStarted = false +    /// The system Dynamic Type size the document is rendering at (T-1828). Seeded by+    /// `start(dynamicTypeSize:)` and updated by `applyDynamicTypeSize`, both fed by the+    /// hosting view — it is a view-world environment value, like `colorScheme`.+    ///+    /// Plain stored state, deliberately NOT a separate push: the typography domain+    /// below composes it with the settings-derived scale and family into the single+    /// `applyTypography` dict.+    private var dynamicTypeSize: DynamicTypeSize = .large+     // MARK: - Last-pushed values (the dirty flags)     //     // Each domain re-pushes only when its freshly computed value differs from@@ -101,9 +115,17 @@ final class WebDocumentStateSynchronizer {     /// intentionally NOT part of the pass: T-1680 owns the highlight pipeline     /// and adds its domain at this seam. Search NAVIGATION (scroll-to-match)     /// is wired below.-    func start() {+    ///+    /// `dynamicTypeSize` is a required parameter rather than a stored default the+    /// caller may or may not overwrite afterwards (T-1828): the first pass pushes+    /// typography, so a mount that forgot to feed Dynamic Type would render the+    /// document at the unscaled base size — the bug this fixes — while still+    /// compiling. Passing it here makes the first push already correct, so there is+    /// no default-then-correct flicker either.+    func start(dynamicTypeSize: DynamicTypeSize) {         guard !isStarted else { return }         isStarted = true+        self.dynamicTypeSize = dynamicTypeSize         synchronize()     } @@ -127,6 +149,23 @@ final class WebDocumentStateSynchronizer {         )     } +    /// Updates the Dynamic Type size and re-pushes typography if that changed the+    /// variables (T-1828). Called by the hosting view when the system Larger Text+    /// setting changes, so the rendered document rescales live — no reload, so the+    /// reading position is kept.+    ///+    /// This deliberately does NOT re-arm the observation pass: it re-composes the+    /// same typography dict `computePass` would, through the same dirty-diffed push+    /// helper, so a Dynamic Type change costs one push and cannot desynchronise from+    /// the settings-derived half.+    func applyDynamicTypeSize(_ size: DynamicTypeSize) {+        guard size != dynamicTypeSize else { return }+        dynamicTypeSize = size+        pushTypography(WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: size+        ))+    }+     // MARK: - The coalescing observation pass      /// Everything one tracked read computed: the desired value of each state@@ -190,7 +229,9 @@ final class WebDocumentStateSynchronizer {             mapped: mapped         )         return SyncPass(-            typography: WebDocumentControllerFactory.typographyVariables(settings: settings),+            typography: WebDocumentControllerFactory.typographyVariables(+                settings: settings, dynamicTypeSize: dynamicTypeSize+            ),             commentVisibility: settings.showHTMLComments,             noteIndicatorsJSON: notePayloads.indicatorsJSON,             inlineNotesJSON: notePayloads.inlineNotesJSON,@@ -225,10 +266,7 @@ final class WebDocumentStateSynchronizer {     /// the last push, then consumes the navigation targets. Runs outside the     /// tracked read, so pushes never register spurious dependencies.     private func dispatch(_ pass: SyncPass) {-        if pass.typography != lastTypography {-            lastTypography = pass.typography-            controller.applyTypography(variables: pass.typography)-        }+        pushTypography(pass.typography)         if pass.commentVisibility != lastCommentVisibility {             lastCommentVisibility = pass.commentVisibility             controller.setCommentVisibility(pass.commentVisibility)@@ -283,6 +321,17 @@ final class WebDocumentStateSynchronizer {         }     } +    /// The typography domain's single push site: dirty-diffed against the last+    /// pushed dict, so an unchanged recomposition costs nothing. Shared by the+    /// observation pass (settings-derived scale and body font) and+    /// `applyDynamicTypeSize` (the view-fed half), which is what keeps the two+    /// halves in one dict — see `typographyVariables` on why that matters (T-1828).+    private func pushTypography(_ variables: [String: String]) {+        guard variables != lastTypography else { return }+        lastTypography = variables+        controller.applyTypography(variables: variables)+    }+     // MARK: - Domain computations      /// Table display modes keyed by DOM id (Req 1.5): native keys are either
prism/Views/DocumentScrollContent.swift Modified +17 / -1
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 92c01c6..007864b 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -55,6 +55,12 @@ struct DocumentScrollContent: View {     /// `colorScheme` — the two together form the palette the document renders in.     @Environment(\.colorSchemeContrast) private var colorSchemeContrast +    /// The system Dynamic Type size (T-1828). Another view-world environment value the+    /// rendered document needs, fed into the synchronizer's typography domain rather+    /// than pushed from here — `applyTypography` carries one variables dict, so the+    /// Dynamic Type half and the settings-derived half must be composed in one place.+    @Environment(\.dynamicTypeSize) private var dynamicTypeSize+     /// Image services, for the iOS sibling-image folder-access grant flow (Req 3.x).     @Environment(\.imageServices) private var imageServices @@ -114,7 +120,9 @@ struct DocumentScrollContent: View {                 selectionAffordance: selectionAffordance             )             webRouter = made.router-            made.synchronizer.start()+            // Dynamic Type is seeded into `start` rather than pushed after it, so the+            // very first typography push already carries the system size (T-1828).+            made.synchronizer.start(dynamicTypeSize: dynamicTypeSize)             // 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(@@ -153,6 +161,14 @@ struct DocumentScrollContent: View {         .onChange(of: paletteFeed) { _, feed in             synchronizer?.applyTheme(themeKey: feed.themeKey, contrast: feed.contrast)         }+        // Live Dynamic Type rescaling without a reload, so the reading position+        // survives an OS Larger Text change (Req 10.3, T-1828). Not folded into+        // `paletteFeed`: that feed exists so theme and contrast reach the page in ONE+        // applyTheme, and Dynamic Type is not part of the palette — it belongs to the+        // typography dict the synchronizer composes.+        .onChange(of: dynamicTypeSize) { _, size in+            synchronizer?.applyDynamicTypeSize(size)+        }         // 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         // comment-visibility changes), or current-match selection change. Both layouts
prism/Resources/WebRenderer/document.css Modified +18 / -2
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex 2a75fdd..4598e01 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -13,12 +13,28 @@  * and inert under the document CSP (Req 8.6).  */ -/* ---- Typography scale (overridden per document via applyTypography) ---- */+/* ---- Typography scale (overridden per document via applyTypography) ----+ *+ * These three are FALLBACKS, not the effective values: applyTypography overwrites+ * --prism-font-family, --prism-font-size and --prism-scale as inline custom properties+ * on <html> on every push, so what is declared here only paints the frames before the+ * bridge's first push (and if the bridge never comes up).+ *+ * --prism-font-family carries the user's Body Font choice ahead of this stack (T-1827),+ * safely quoted natively; --prism-font-size carries the Dynamic Type resolved base size+ * (T-1828). Both mirror WebTypography's Swift constants, pinned by+ * DocumentCSSTypographyRulesTests. --prism-font-mono is NOT pushed: code stays monospace+ * whatever body font is chosen.+ */ :root {     --prism-font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;     --prism-font-mono: "SF Mono", ui-monospace, Menlo, Monaco, "Courier New", monospace; -    /* Base size scaled by the user's text-size setting / Dynamic Type (Req 1.4/10.3). */+    /*+     * Base size scaled by the user's text-size setting / Dynamic Type (Req 1.4/10.3).+     * Dynamic Type resolves --prism-font-size; the in-app slider is --prism-scale, and+     * the body rule multiplies them so the two compound.+     */     --prism-font-size: 17px;     --prism-line-height: 1.6;     --prism-scale: 1;
prism/Resources/WebRenderer/prism-theme.js Modified +6 / -0
diff --git a/prism/Resources/WebRenderer/prism-theme.js b/prism/Resources/WebRenderer/prism-theme.jsindex 6e8c5ce..d2b5e1b 100644--- a/prism/Resources/WebRenderer/prism-theme.js+++ b/prism/Resources/WebRenderer/prism-theme.js@@ -44,6 +44,12 @@     });      // ---- Typography (Req 1.4 / Dynamic Type Req 10.3) --------------------+    // Native composes the WHOLE variables dict — the in-app text scale, the Dynamic+    // Type resolved base size (T-1828), and the user's Body Font family already+    // quoted as a CSS string (T-1827) — and pushes it in one command, so this only+    // applies it. The family is never assembled here: escaping an untrusted family+    // name is native's job (WebTypography), and splitting the dict across pushes+    // would drop variables from the coalesced recovery replay.     bridge.registerCommand("applyTypography", function (payload) {         if (!payload) { return null; }         applyVariables(payload.variables);
prismTests/WebRendering/WebTypographyBridgeTests.swift New +464 / -0
diff --git a/prismTests/WebRendering/WebTypographyBridgeTests.swift b/prismTests/WebRendering/WebTypographyBridgeTests.swiftnew file mode 100644index 0000000..3a5a7ab--- /dev/null+++ b/prismTests/WebRendering/WebTypographyBridgeTests.swift@@ -0,0 +1,464 @@+//+//  WebTypographyBridgeTests.swift+//  prismTests+//+//  Regression tests for T-1827 (the Body Font setting was ignored by web-rendered+//  documents) and T-1828 (iOS Dynamic Type did not scale them).+//+//  Both were T-1542 cutover gaps of the same shape: the spec stated the mapping+//  (webview-rendering Req 1.4 / 10.3, design "content-size-category changes trigger+//  applyTypography"), but `typographyVariables` only ever emitted `--prism-scale`.+//  The document kept the stylesheet's fixed system stack and its fixed 17px base, so+//  the Settings picker moved only the native preview and OS Larger Text moved only+//  the app chrome.+//+//  The fix composes ALL typography inputs — in-app scale, Dynamic Type resolved base+//  size, user body family — into the single `applyTypography` variables dict. That+//  single-composition-site property is itself load-bearing: the command's dict wholly+//  replaces `WebDocumentStateSnapshot.typography`, so two pushers would drop each+//  other's variables from the recovery replay rather than merely race. The tests+//  below cover the escaping boundary, the Dynamic Type mapping, the composed push,+//  live changes through the production assembly, recovery replay, and the live page.+//++import SwiftUI+import Testing+import WebKit+@testable import prism++@Suite("Typography reaches the rendered document (T-1827/T-1828)")+@MainActor+struct WebTypographyBridgeTests {++    /// A font family that genuinely exists on the running platform, so+    /// `AppSettings.bodyFontFamily`'s `FontUtilities` existence filter passes.+    private static func installedFamily() throws -> String {+        try #require(FontUtilities.allFamilies.first, "the test host must have fonts")+    }++    /// Whether the interior of a quoted CSS string contains a quote that is not+    /// escaped — i.e. whether the string terminates before its closing delimiter.+    /// Walks the scalars the way a CSS tokenizer does: a backslash consumes the+    /// scalar after it.+    private static func hasUnescapedQuote(inside quoted: String) -> Bool {+        let interior = Array(quoted.unicodeScalars.dropFirst().dropLast())+        var index = 0+        while index < interior.count {+            if interior[index] == "\\" {+                index += 2+                continue+            }+            if interior[index] == "\"" { return true }+            index += 1+        }+        return false+    }++    private func sampleBlocks() -> [MarkdownBlock] {+        [+            .heading(level: 1, text: "Doc"),+            .paragraph(markdown: "Body text."),+            .codeBlock(language: "swift", code: "let x = 1"),+        ]+    }++    // MARK: - CSS escaping boundary (T-1827 security note)++    @Test("A family name is emitted as a quoted CSS string")+    func familyIsQuoted() {+        #expect(WebTypography.cssQuotedFamily("Helvetica Neue") == "\"Helvetica Neue\"")+    }++    @Test("A family name cannot break out of the declaration")+    func familyCannotBreakOut() throws {+        // Each of these is a breakout attempt through the value: end the declaration,+        // close the rule and open a new one, or smuggle a second property. Inside a+        // quoted CSS string every one of them is an ordinary character, so the whole+        // hostile name must survive INSIDE the quotes and nothing may leak outside.+        let attempts = [+            "Evil; color: red",+            "Evil} body { display: none } .x {",+            // The one a naive `"\(family)"` interpolation lets through: the value's own+            // double quote closes the string early and everything after it becomes live+            // CSS. `liveTypographyVariablesApply` proves WebKit agrees.+            "Evil\"; } body { display: none } .x { color: red",+            "Evil'; background: url(http://evil.example)",+            "Evil:hover",+            "Evil</style><script>alert(1)</script>",+        ]+        for attempt in attempts {+            let quoted = try #require(WebTypography.cssQuotedFamily(attempt))+            #expect(quoted.hasPrefix("\""))+            #expect(quoted.hasSuffix("\""))+            // No UNESCAPED quote inside the delimiters: one would close the string+            // early and everything after it would be live CSS. Counting quotes is not+            // enough — an escaped \" is still a quote character — so the interior is+            // walked the way a CSS tokenizer would.+            #expect(!Self.hasUnescapedQuote(inside: quoted), "unescaped quote in \(quoted)")++            let value = WebTypography.fontFamilyValue(userFamily: attempt)+            // The system fallback stack is appended after the closing quote, and it is+            // the ONLY thing after it — the hostile text stays inside the string.+            #expect(value == "\(quoted), \(WebTypography.systemFontStack)")+        }+    }++    @Test("Embedded quotes and backslashes are escaped, not dropped")+    func quotesAndBackslashesEscaped() throws {+        // A literal double quote must become \" or it would terminate the string.+        let quoted = try #require(WebTypography.cssQuotedFamily("My \"Font\""))+        #expect(quoted == "\"My \\\"Font\\\"\"")++        // A trailing backslash is the classic escape-the-escape trick: unescaped, it+        // would consume the closing quote and let everything after it become CSS.+        let trailing = try #require(WebTypography.cssQuotedFamily("Font\\"))+        #expect(trailing == "\"Font\\\\\"")+    }++    @Test("Unusable family names fall back to the system stack")+    func unusableNamesFallBack() {+        // Control characters (a newline is outright illegal inside a CSS string) are+        // rejected rather than escaped — they can only come from a tampered+        // preference, never from the font picker.+        #expect(WebTypography.cssQuotedFamily("Evil\nBody { }") == nil)+        #expect(WebTypography.cssQuotedFamily("Evil\u{0}") == nil)+        #expect(WebTypography.cssQuotedFamily("") == nil)+        #expect(WebTypography.cssQuotedFamily("   ") == nil)++        #expect(WebTypography.fontFamilyValue(userFamily: nil) == WebTypography.systemFontStack)+        #expect(+            WebTypography.fontFamilyValue(userFamily: "Evil\nBody { }")+                == WebTypography.systemFontStack+        )+    }++    @Test("The user's family is first and the system stack still follows it")+    func userFamilyLeadsTheStack() {+        let value = WebTypography.fontFamilyValue(userFamily: "Palatino")+        #expect(value.hasPrefix("\"Palatino\", "))+        // The fallbacks must survive: a family the picker resolved but WebKit cannot+        // must still render as readable sans-serif, not the browser default serif.+        #expect(value.hasSuffix(WebTypography.systemFontStack))+    }++    // MARK: - Dynamic Type mapping (T-1828)++    @Test("The default Dynamic Type size leaves the base size unchanged")+    func defaultSizeIsNeutral() {+        #expect(WebTypography.dynamicTypeMultiplier(for: .large) == 1.0)+        #expect(WebTypography.bodyPointSize(for: .large) == WebTypography.baseFontSizePx)+    }++    @Test("The Dynamic Type multiplier increases monotonically across every size")+    func multiplierIsMonotonic() {+        var previous = 0.0+        for size in DynamicTypeSize.allCases {+            let multiplier = WebTypography.dynamicTypeMultiplier(for: size)+            #expect(multiplier > previous, "\(size) must scale above the previous size")+            previous = multiplier+        }+        // The accessibility range must actually be large, not a rounding nudge —+        // this is the setting the ticket's reproduction uses.+        #expect(WebTypography.dynamicTypeMultiplier(for: .accessibility5) > 3.0)+        #expect(WebTypography.dynamicTypeMultiplier(for: .xSmall) < 1.0)+    }++    #if os(iOS)+    @Test("The Dynamic Type table matches UIKit's own body metrics (device pin)")+    func tableMatchesUIFontMetrics() {+        // Anti-drift against the OS rather than against a second copy of our own+        // numbers: the table exists so the mapping is pure and works on macOS, but it+        // is only correct while it agrees with what UIKit resolves on device.+        for size in DynamicTypeSize.allCases {+            let traits = UITraitCollection { mutable in+                mutable.preferredContentSizeCategory = UIContentSizeCategory(size)+            }+            let resolved = UIFontMetrics(forTextStyle: .body).scaledValue(+                for: WebTypography.baseFontSizePx, compatibleWith: traits+            )+            #expect(+                abs(resolved - WebTypography.bodyPointSize(for: size)) < 0.51,+                "\(size): UIKit resolves \(resolved)"+            )+        }+    }+    #endif++    // MARK: - The composed push++    @Test("The typography push carries the selected body font family (T-1827)")+    func typographyCarriesBodyFont() throws {+        let family = try Self.installedFamily()+        let settings = AppSettings.makeForTesting(bodyFontFamily: family)+        let variables = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )+        let value = try #require(variables["--prism-font-family"])+        #expect(value == "\"\(family)\", \(WebTypography.systemFontStack)")+    }++    @Test("A body font choice never changes the monospace stack (code stays code)")+    func codeStaysMonospace() throws {+        let family = try Self.installedFamily()+        let settings = AppSettings.makeForTesting(bodyFontFamily: family)+        let variables = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )+        // --prism-font-mono is never pushed, so the stylesheet's monospace stack keeps+        // applying to code blocks and inline code (Req 1.4).+        #expect(variables["--prism-font-mono"] == nil)+    }++    @Test("A stored font family that no longer exists falls back to the system stack")+    func unavailableFontFallsBack() {+        // The reproduction is a preference synced from a device that had the font.+        // AppSettings filters through FontUtilities, so the resolver sees nil.+        let settings = AppSettings.makeForTesting(bodyFontFamily: "Definitely Not Installed 9Z")+        let variables = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )+        #expect(variables["--prism-font-family"] == WebTypography.systemFontStack)+    }++    @Test("Clearing the font back to System re-pushes the plain system stack")+    func clearingFontRestoresSystemStack() throws {+        // The variable is emitted unconditionally on purpose: prism-theme.js only+        // ever SETS custom properties, so omitting it when the user picks "System"+        // would leave the previous family as an inline style forever.+        let family = try Self.installedFamily()+        let settings = AppSettings.makeForTesting(bodyFontFamily: family)+        #expect(WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )["--prism-font-family"] != WebTypography.systemFontStack)++        settings.bodyFontFamily = nil+        #expect(WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )["--prism-font-family"] == WebTypography.systemFontStack)+    }++    @Test("The typography push carries a Dynamic Type resolved base size (T-1828)")+    func typographyCarriesDynamicType() {+        let settings = AppSettings.makeForTesting()+        let atDefault = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .large+        )+        #expect(atDefault["--prism-font-size"] == "17.00px")++        let enlarged = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .accessibility3+        )+        #expect(enlarged["--prism-font-size"] == "40.00px")+    }++    @Test("Dynamic Type and the in-app text scale compound, not override")+    func dynamicTypeAndAppScaleCompound() {+        // Req 10.3: "rendered content combines Dynamic Type with the app scale". They+        // ride separate variables and the stylesheet multiplies them, so raising one+        // must not flatten the other.+        let settings = AppSettings.makeForTesting(textSizeScale: 150)+        let variables = WebDocumentControllerFactory.typographyVariables(+            settings: settings, dynamicTypeSize: .accessibility1+        )+        #expect(variables["--prism-scale"] == "1.500")+        #expect(variables["--prism-font-size"] == "28.00px")+    }++    // MARK: - Recovery replay (Req 9.6)++    @Test("The coalesced snapshot replays the composed typography after a recovery")+    func snapshotReplaysTypography() {+        var snapshot = WebDocumentStateSnapshot()+        let variables = [+            "--prism-scale": "1.200",+            "--prism-font-size": "28.00px",+            "--prism-font-family": "\"Palatino\", \(WebTypography.systemFontStack)",+        ]+        snapshot.apply(.applyTypography(variables: variables))+        #expect(snapshot.typography == variables)+        #expect(snapshot.coalescedCommands().contains(.applyTypography(variables: variables)))+    }++    @Test("A font-only and a Dynamic Type-only change are each a distinct snapshot")+    func typographyChangesSnapshotIdentity() {+        func snapshot(_ variables: [String: String]) -> WebDocumentStateSnapshot {+            var snapshot = WebDocumentStateSnapshot()+            snapshot.apply(.applyTypography(variables: variables))+            return snapshot+        }+        let base = ["--prism-scale": "1.000", "--prism-font-size": "17.00px", "--prism-font-family": "a"]+        var fontChanged = base+        fontChanged["--prism-font-family"] = "b"+        var sizeChanged = base+        sizeChanged["--prism-font-size"] = "23.00px"++        // Both must be distinct values, or the synchronizer's dirty-diff would swallow+        // the push and the change would never reach the page.+        #expect(snapshot(base) != snapshot(fontChanged))+        #expect(snapshot(base) != snapshot(sizeChanged))+    }++    // MARK: - Production assembly (the seam the tickets faulted)++    private func makeAssembly(settings: AppSettings) async -> (+        controller: WebDocumentController,+        synchronizer: WebDocumentStateSynchronizer+    ) {+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/t1827-typography-fixture.md"),+            content: "# Doc\n\nBody text.\n"+        )+        await session.parseContent()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: settings,+            coordinator: DocumentLayoutCoordinator(),+            notesManager: NotesManager()+        )+        return (made.controller, made.synchronizer)+    }++    /// Polls a condition, since the synchronizer's observation pass is scheduled.+    private func waitUntil(_ condition: () -> Bool) async -> Bool {+        for _ in 0..<40 {+            if condition() { return true }+            await Task.yield()+            try? await Task.sleep(for: .milliseconds(25))+        }+        return condition()+    }++    @Test("start seeds Dynamic Type into the very first typography push")+    func startSeedsDynamicType() async {+        let assembly = await makeAssembly(settings: AppSettings.makeForTesting())+        assembly.synchronizer.start(dynamicTypeSize: .accessibility2)+        let pushed = await waitUntil {+            assembly.controller.latestSnapshot.typography?["--prism-font-size"] == "33.00px"+        }+        // Seeded rather than corrected afterwards: the first push a fresh page (or a+        // post-recovery replay) sees must already be the system's size.+        #expect(pushed, "the first typography push must already carry the system Dynamic Type size")+    }++    @Test("A Dynamic Type change re-pushes typography without losing the settings half")+    func dynamicTypeChangeKeepsSettingsHalf() async throws {+        let family = try Self.installedFamily()+        let settings = AppSettings.makeForTesting(textSizeScale: 130, bodyFontFamily: family)+        let assembly = await makeAssembly(settings: settings)+        assembly.synchronizer.start(dynamicTypeSize: .large)+        _ = await waitUntil { assembly.controller.latestSnapshot.typography != nil }++        assembly.synchronizer.applyDynamicTypeSize(.accessibility1)+        let pushed = await waitUntil {+            assembly.controller.latestSnapshot.typography?["--prism-font-size"] == "28.00px"+        }+        #expect(pushed, "an OS Larger Text change must reach the page live")++        // The regression this guards: a separate Dynamic Type push would REPLACE the+        // snapshot's typography dict, silently dropping the scale and family from the+        // recovery replay.+        let typography = try #require(assembly.controller.latestSnapshot.typography)+        #expect(typography["--prism-scale"] == "1.300")+        #expect(typography["--prism-font-family"] == "\"\(family)\", \(WebTypography.systemFontStack)")+    }++    @Test("A Body Font change re-pushes typography without losing the Dynamic Type half")+    func fontChangeKeepsDynamicTypeHalf() async throws {+        let family = try Self.installedFamily()+        let settings = AppSettings.makeForTesting()+        let assembly = await makeAssembly(settings: settings)+        assembly.synchronizer.start(dynamicTypeSize: .accessibility1)+        _ = await waitUntil { assembly.controller.latestSnapshot.typography != nil }++        // The T-1827 reproduction: change the picker and return to the document.+        settings.bodyFontFamily = family+        let pushed = await waitUntil {+            assembly.controller.latestSnapshot.typography?["--prism-font-family"]+                == "\"\(family)\", \(WebTypography.systemFontStack)"+        }+        #expect(pushed, "a Body Font change must reach the page without a reload or reopen")++        let typography = try #require(assembly.controller.latestSnapshot.typography)+        #expect(typography["--prism-font-size"] == "28.00px", "the view-fed half must survive")+    }++    @Test("An unchanged Dynamic Type size does not re-push")+    func unchangedDynamicTypeDoesNotPush() async {+        let assembly = await makeAssembly(settings: AppSettings.makeForTesting())+        assembly.synchronizer.start(dynamicTypeSize: .large)+        _ = await waitUntil { assembly.controller.latestSnapshot.typography != nil }+        let before = assembly.controller.pendingCommands.count++        assembly.synchronizer.applyDynamicTypeSize(.large)+        #expect(assembly.controller.pendingCommands.count == before)+    }++    // MARK: - Live page++    @Test("applyTypography sets the font variables live, without a reload")+    func liveTypographyVariablesApply() async throws {+        let harness = try await WebDocumentLiveHarness.make(blocks: sampleBlocks())+        let urlBefore = harness.page.url++        let hostile = "Evil\"; } body { display: none } .x {"+        let family = WebTypography.fontFamilyValue(userFamily: hostile)+        try await harness.send(.applyTypography(variables: [+            "--prism-scale": "1.200",+            "--prism-font-size": "28.00px",+            "--prism-font-family": family,+        ]))+        try await Task.sleep(for: .milliseconds(100))++        let size = try await harness.evalString(+            "return document.documentElement.style.getPropertyValue('--prism-font-size');"+        )+        #expect(size?.trimmingCharacters(in: .whitespaces) == "28.00px")++        // The CSSOM accepted the family as ONE value; the hostile fragment did not+        // become a rule of its own. If the quoting had failed, either the property+        // would be empty (rejected as invalid) or `body` would now be display:none.+        let pushed = try await harness.evalString(+            "return document.documentElement.style.getPropertyValue('--prism-font-family');"+        )+        let pushedValue = try #require(pushed).trimmingCharacters(in: .whitespaces)+        #expect(pushedValue.contains("Evil"), "the family must survive as a string, not be dropped")+        #expect(pushedValue.hasSuffix("sans-serif"), "the fallback stack must still be there")+        let bodyHidden = try await harness.evalBool(+            "return getComputedStyle(document.body).display === 'none';"+        )+        #expect(!bodyHidden, "a family name must not be able to inject a rule")++        // Only the three pushed custom properties exist inline — no fourth+        // declaration was smuggled in through the value.+        let declarations = try await harness.evalString(+            "return String(document.documentElement.style.length);"+        )+        #expect(declarations == "3")++        #expect(harness.page.url == urlBefore, "typography changes must not reload the document")+    }+}++#if os(iOS)+private extension UIContentSizeCategory {+    /// Maps a SwiftUI `DynamicTypeSize` onto the UIKit category, for the device pin+    /// above. Test-only: production reads the pure table in `WebTypography`.+    init(_ size: DynamicTypeSize) {+        switch size {+        case .xSmall: self = .extraSmall+        case .small: self = .small+        case .medium: self = .medium+        case .large: self = .large+        case .xLarge: self = .extraLarge+        case .xxLarge: self = .extraExtraLarge+        case .xxxLarge: self = .extraExtraExtraLarge+        case .accessibility1: self = .accessibilityMedium+        case .accessibility2: self = .accessibilityLarge+        case .accessibility3: self = .accessibilityExtraLarge+        case .accessibility4: self = .accessibilityExtraExtraLarge+        case .accessibility5: self = .accessibilityExtraExtraExtraLarge+        @unknown default: self = .large+        }+    }+}+#endif
prismTests/WebRendering/DocumentCSSTypographyRulesTests.swift New +161 / -0
diff --git a/prismTests/WebRendering/DocumentCSSTypographyRulesTests.swift b/prismTests/WebRendering/DocumentCSSTypographyRulesTests.swiftnew file mode 100644index 0000000..326816f--- /dev/null+++ b/prismTests/WebRendering/DocumentCSSTypographyRulesTests.swift@@ -0,0 +1,161 @@+//+//  DocumentCSSTypographyRulesTests.swift+//  prismTests+//+//  Stylesheet guard for the typography regressions T-1827 (Body Font ignored) and+//  T-1828 (Dynamic Type ignored).+//+//  Native now pushes `--prism-font-family` and `--prism-font-size` on every+//  `applyTypography`, and `WebTypography` holds the Swift-side constants those pushes+//  are built from. `document.css` holds the same two values as the pre-bridge+//  fallbacks — the frames before the first push, and the state if the bridge never+//  comes up. Two representations of one value drift, so these tests pin them+//  together, the way `DocumentCSSContrastRulesTests` pins the contrast palette+//  against `HighContrastThemeColors`.+//+//  They also pin the CONSUMERS, which is the other half of each bug: the fix is only+//  effective while the body rule actually multiplies the two variables and the code+//  rules keep using the untouched monospace stack.+//+//  Rule-pin, not a rendered-typography proof: the live WebPage harness never applies+//  the emitted stylesheet (specs/readable-tables-restoration/decision_log.md+//  Decision 2), so computed font sizes cannot be asserted here. The bridge half is+//  covered by WebTypographyBridgeTests; visual verification is manual.+//++import Foundation+import SwiftUI+import Testing+@testable import prism++@Suite("document.css typography variables (T-1827/T-1828)")+@MainActor+struct DocumentCSSTypographyRulesTests {++    /// 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)+    }++    /// Strips `/* … */` comments.+    ///+    /// The sibling guards do not need this because their selector fragments+    /// (`[data-prism-contrast=…]`) never appear in prose. "body" does — the typography+    /// section comment explains what the body rule does — and a fragment matched inside+    /// a comment that precedes a rule passes the `}`-between check, silently returning+    /// the WRONG rule's body. Removing comments first is the fix; it also makes the+    /// declaration splitting below safe.+    private static func withoutComments(_ 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.+    ///+    /// Same implementation as the sibling `document.css` guards+    /// (`DocumentCSSContrastRulesTests`, `DocumentCSSTableRulesTests`): a hit only+    /// counts as a selector if no `}` sits between it and the next `{`, so a fragment+    /// appearing inside a previous rule body is skipped rather than silently yielding+    /// the wrong body.+    private static func ruleBody(containing selectorFragment: String, in css: String) -> String? {+        let flat = normalized(withoutComments(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 `:root` typography block — the first `:root` rule, which is the one the+    /// typography fallbacks live in (the theme palettes follow it).+    private static func rootBody() throws -> String {+        let css = try loadDocumentCSS()+        return try #require(ruleBody(containing: ":root", in: css), "document.css must have a :root block")+    }++    // MARK: - Swift ↔ CSS anti-drift++    @Test("The CSS font-family fallback matches WebTypography.systemFontStack (T-1827)")+    func fontStackMatchesSwift() throws {+        let declared = try #require(Self.value(of: "--prism-font-family", in: try Self.rootBody()))+        // Native appends this exact stack after the user's family, so a divergence+        // means a document with a custom font falls back differently from one without.+        #expect(+            Self.normalized(declared) == Self.normalized(WebTypography.systemFontStack),+            "document.css declares \(declared)"+        )+    }++    @Test("The CSS base font size matches WebTypography.baseFontSizePx (T-1828)")+    func baseSizeMatchesSwift() throws {+        let declared = try #require(Self.value(of: "--prism-font-size", in: try Self.rootBody()))+        // The Swift constant is the Dynamic Type "Large" neutral point: if the CSS+        // fallback drifted from it, the document would visibly resize the moment the+        // first typography push landed, at the default text size.+        #expect(declared == "\(Int(WebTypography.baseFontSizePx))px", "document.css declares \(declared)")+    }++    // MARK: - The consumers++    @Test("The body rule combines the Dynamic Type base size with the app scale")+    func bodyRuleCombinesBothScales() throws {+        let css = try Self.loadDocumentCSS()+        let body = try #require(Self.ruleBody(containing: "body", in: css))+        let fontSize = try #require(Self.value(of: "font-size", in: body))+        // Both variables, multiplied: Dynamic Type and the in-app slider have to+        // compound (Req 10.3). Dropping either factor is one of the two bugs.+        #expect(fontSize.contains("var(--prism-font-size)"))+        #expect(fontSize.contains("var(--prism-scale)"))+        #expect(fontSize.hasPrefix("calc("))++        #expect(Self.value(of: "font-family", in: body) == "var(--prism-font-family)")+    }++    @Test("Code keeps the monospace stack, which is never pushed")+    func codeKeepsMonospaceStack() throws {+        let css = try Self.loadDocumentCSS()+        // Req 1.4: the body font applies to body/headings/lists/tables, never to code.+        // `--prism-font-mono` is deliberately absent from the typography push, so code+        // stays monospace whatever family the user picks — that only holds while the+        // code rules key off the mono variable rather than the body one.+        #expect(css.contains("var(--prism-font-mono)"))+        #expect(+            Self.value(of: "--prism-font-mono", in: try Self.rootBody()) != nil,+            "--prism-font-mono must stay declared in :root"+        )+        let variables = WebDocumentControllerFactory.typographyVariables(+            settings: AppSettings.makeForTesting(),+            dynamicTypeSize: .large+        )+        #expect(variables["--prism-font-mono"] == nil, "the mono stack must not be pushed")+    }+}
prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift Modified +19 / -3
diff --git a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swiftindex f1cbc2d..d9197d0 100644--- a/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift+++ b/prismTests/WebRendering/WebStateSynchronizerAssemblyTests.swift@@ -20,6 +20,7 @@ //  import Foundation+import SwiftUI import Testing @testable import prism @@ -88,7 +89,7 @@ struct WebStateSynchronizerAssemblyTests {             coordinator: coordinator,             notesManager: notesManager         )-        made.synchronizer.start()+        made.synchronizer.start(dynamicTypeSize: .large)         return Assembly(             session: session,             coordinator: coordinator,@@ -143,7 +144,8 @@ struct WebStateSynchronizerAssemblyTests {     func initialStatePushedOnStart() async throws {         let assembly = await makeAssembly()         let expectedTypography = WebDocumentControllerFactory.typographyVariables(-            settings: assembly.settings+            settings: assembly.settings,+            dynamicTypeSize: .large         )         let pushed = await waitUntil {             assembly.controller.latestSnapshot.typography == expectedTypography@@ -151,6 +153,14 @@ struct WebStateSynchronizerAssemblyTests {                 && assembly.controller.latestSnapshot.noteIndicatorsJSON != nil         }         #expect(pushed, "start() must push initial typography/comment/note state so recovery replay has native truth")++        // Not just "equal to whatever the factory returns" — that comparison holds for+        // any content and passed throughout T-1827/T-1828, when the dict was missing+        // two of its three variables. Name every variable the stylesheet consumes.+        let typography = try #require(assembly.controller.latestSnapshot.typography)+        #expect(typography["--prism-scale"] != nil)+        #expect(typography["--prism-font-size"] != nil)+        #expect(typography["--prism-font-family"] != nil)     }      @Test("applyTheme pushes the theme through the synchronizer")@@ -175,12 +185,18 @@ struct WebStateSynchronizerAssemblyTests {          assembly.settings.textSizeScale = originalScale == 120 ? 140 : 120         let expectedTypography = WebDocumentControllerFactory.typographyVariables(-            settings: assembly.settings+            settings: assembly.settings,+            dynamicTypeSize: .large         )         let typographyPushed = await waitUntil {             assembly.controller.latestSnapshot.typography == expectedTypography         }         #expect(typographyPushed, "text-size change must reach the web controller without a view remount")+        // Literal, for the reason above: the scale the setting was just moved to.+        #expect(+            assembly.controller.latestSnapshot.typography?["--prism-scale"]+                == (assembly.settings.textSizeScale == 140 ? "1.400" : "1.200")+        )          assembly.settings.showHTMLComments = !originalComments         let commentsPushed = await waitUntil {
prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift Modified +3 / -1
diff --git a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swiftindex 37b8124..c1a14ff 100644--- a/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift+++ b/prismTests/WebRendering/HTMLCommentVisibilityLiveTests.swift@@ -13,6 +13,7 @@ //  import Foundation+import SwiftUI import Testing import WebKit @testable import prism@@ -140,7 +141,8 @@ struct HTMLCommentVisibilityLiveTests {             to: controller,             settings: settings,             themeKey: PrismTheme.prismLight.rawValue,-            contrast: .standard+            contrast: .standard,+            dynamicTypeSize: .large         )         let url = WebDocumentControllerFactory.documentURL(             session: session, parseRevision: session.parseRevision
prismTests/WebRendering/WebSearchWiringTests.swift Modified +1 / -1
diff --git a/prismTests/WebRendering/WebSearchWiringTests.swift b/prismTests/WebRendering/WebSearchWiringTests.swiftindex 87be62b..db6ee6c 100644--- a/prismTests/WebRendering/WebSearchWiringTests.swift+++ b/prismTests/WebRendering/WebSearchWiringTests.swift@@ -67,7 +67,7 @@ struct WebSearchWiringTests {             notesManager: notesManager         )         let controller = made.controller-        made.synchronizer.start()+        made.synchronizer.start(dynamicTypeSize: .large)         made.synchronizer.applyTheme(             themeKey: settings.theme(for: .light).rawValue,             contrast: .standard
docs/agent-notes/typography-font-settings.md Modified +54 / -1
diff --git a/docs/agent-notes/typography-font-settings.md b/docs/agent-notes/typography-font-settings.mdindex 69ae851..ee15ce6 100644--- a/docs/agent-notes/typography-font-settings.md+++ b/docs/agent-notes/typography-font-settings.md@@ -21,7 +21,60 @@ - `prismTests/TypographyResolverTests.swift` - Tests for font computation logic. Uses 15pt baseline assertions. - `prismTests/AppSettingsTypographyTests.swift` - Tests for clamping, snapping, validation, reset. -### Rendering Pipeline+### Rendering Pipeline (web path — T-1827/T-1828)++The document body is WebKit-rendered (T-1542), so `TypographyResolver` no longer drives+it. The web path is:++`AppSettings` (`textSizeScale`, `bodyFontFamily`) + `@Environment(\.dynamicTypeSize)`+→ `WebDocumentControllerFactory.typographyVariables(settings:dynamicTypeSize:)`+→ `applyTypography` → `prism-theme.js` `applyVariables` → inline custom properties on+`<html>` → `document.css`'s `body` rule.++Three variables are pushed, and only these three:++| Variable | Source | Notes |+|----------|--------|-------|+| `--prism-scale` | `textSizeScale` / 100 | the in-app 80–200% slider |+| `--prism-font-size` | `WebTypography.bodyPointSize(for:)` | Dynamic Type resolved base size, px |+| `--prism-font-family` | `WebTypography.fontFamilyValue(userFamily:)` | quoted user family + system stack |++Gotchas that cost time here:++- **`--prism-font-mono` is deliberately NOT pushed.** That is what keeps code monospace+  under a custom body font. Pushing it is T-428's business, not the body font's.+- **All three must be composed in `typographyVariables` and pushed together.**+  `applyTypography` carries one dict that *wholly replaces*+  `WebDocumentStateSnapshot.typography`, so a second pusher does not race — it deletes+  the other's variables from the post-recovery replay. This is why Dynamic Type is fed+  *into* `WebDocumentStateSynchronizer` (`start(dynamicTypeSize:)` /+  `applyDynamicTypeSize`) rather than pushed from the view like the theme is.+- **`--prism-font-family` is emitted even when no family is selected.** `applyVariables`+  only ever *sets* properties, so omitting it would leave a previously-pushed family as+  an inline style and "System" would never come back without a reload.+- **The family name is untrusted** (`UserDefaults`-backed, reachable via `defaults write`+  or a synced preference). `WebTypography.cssQuotedFamily` quotes it as a CSS string and+  escapes `\` and `"`; control characters are rejected to the system stack.+- **`document.css`'s `:root` values are fallbacks, not the effective values**, and they+  mirror `WebTypography`'s Swift constants. `DocumentCSSTypographyRulesTests` pins the+  two so they cannot drift (same pattern as `DocumentCSSContrastRulesTests`).+- **`WebTypography.baseFontSizePx` is 17 on both platforms**, unlike+  `TypographyResolver`'s 15pt macOS body baseline. Deliberate: 17px is what the web+  renderer has always used on macOS and changing it would resize every macOS document.+- **The Dynamic Type mapping is a table, not a live `UIFontMetrics` call**, so it is pure,+  testable off-device, and works on macOS. An iOS-only test pins the table against+  `UIFontMetrics` so it cannot drift from the OS.+- The live `WebPage` test harness does **not** apply `document.css`+  (`specs/readable-tables-restoration/decision_log.md` Decision 2), so computed font+  sizes cannot be asserted in a test. Coverage is split: bridge/variable assertions plus+  CSS rule pins; the visual check is manual.++### Rendering Pipeline (native path — LARGELY RETIRED by T-1542)++The views below no longer render the document body; `TypographyResolver` now serves the+raw-source view, Settings' preview card, metadata, and chrome only. Treat this list as+history unless the file still exists.+ - `prism/Views/TypographyModifier.swift` - ViewModifier applying `resolver.scaledBodyFont` (always explicit Font, never nil) as environment fallback. - `prism/Views/HighlightedInlineText.swift` - Fonts applied directly via `.font()` on `InlineText`. `scaledFont` is `Font` (non-optional) from `TypographyResolver.scaledBodyFont`. No `.textual.fontScale()`. - `prism/Views/TextualBlockView.swift` - `headingFont(level:)` uses `TypographyResolver.scaledHeadingFont(level:)` which bakes scale into point size.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c740e3a..f62c623 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 **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 a reload and without losing your reading position, and both survive a WebKit process recovery. 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. - 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. - 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.

Things to double-check

Run make test (iOS), not just make test-quick.

This is the whole lesson of the failing pin. A #if os(iOS) test is invisible to make test-quick, so a macOS-only run reports green on a suite that fails on the target platform. Any suite added under a platform guard needs a run on that platform before the branch is called verified — and this branch's headline feature (Dynamic Type) is iOS-only, so macOS-only verification was never going to be sufficient.

Merge origin/main before anything else.

PR #327 is CONFLICTING / DIRTY. GitHub queues no checks in that state, so an absence of red on the PR means nothing. After resolving, re-run the targeted suites and both builds — the conflict is in the file this branch restructures, and the resolution has to merge two independent changes to start() and two independent changes to dispatch.

The two visual checks are still owed.

Not automatable, because the live WebPage harness deliberately does not apply document.css. Already tracked as unticked manual lines 1.4 (font family / text scale updates without reload) and 10.3 (Dynamic Type + app scale) in specs/webview-rendering/verification.md. Worth checking the collapse-chevron alignment and readable-table columns at .accessibility3+ while there, per the two minor CSS findings.

Concurrent-build flake, for the record.

The first iOS build-for-testing failed with disk I/O error on XCBuildData/build.db and ~248 cascading "No such file or directory" object-write errors — zero source errors. With five concurrent xcodebuild processes on the machine this is infrastructure, not code. Deleting XCBuildData and re-running gave ** TEST BUILD SUCCEEDED **, exit 0. If a sibling agent reports a mystery build failure, check for this shape before believing it.

macOS is asserted unchanged, but only by reasoning.

The argument is that dynamicTypeSize is fixed at .large on macOS, so --prism-font-size is 17.00px and rendering is byte-identical. That is Apple's documented behaviour and it matches the pre-change hard-coded 17px, but no test pins it — DynamicTypeSize is not platform-gated in typographyVariables. Cheap to confirm during the manual pass: open a document on Mac and check nothing resized.