feat(html): expose the zoom every view opens at to the host - #724
Merged
Conversation
The fit landed a scale on the body and nothing could move it afterwards. An app wanting a zoom control had to reach for the platform's own — `WKWebView.pageZoom`, `WebView.zoomBy` — which differ per platform, are inert for an embedder rendering us in a frame, and know nothing of the fit, so each app would again reimplement the same judgement with its own bugs. That is the thing #719 just climbed out of. The `odr` object every view already carries now answers for the zoom: odr.getZoom() // effective, 1 is actual size odr.setZoom(value, focus) // pins it odr.adjustZoom(factor, focus) // multiplicative, pins it odr.resetZoom(focus) // back to following the fit odr.isZoomFitted() odr.onZoomChange = function (zoom, fitted) {} The state behind it is one nullable number: a pinned zoom, or `null` while the view follows the fit. Not a multiplier over the fit — a phone rotating with a 2x multiplier compounds it against the wider fit and overshoots, where a pinned zoom keeps the text the size the reader set. Which one is live is what `isZoomFitted()` answers, and the only bit a toolbar cannot derive from the number alone. `focus` is the point the zoom is centred on, a pinch's midpoint, given as `{x, y}` or any object carrying `clientX`/`clientY`. It stays put across the change; the anchor that holds the reading position now pins the horizontal too, but only when a point asked for it — otherwise the page column centres itself and holding x would fight that. The css says what the view opens at, so a page under a strict Content-Security-Policy opens right with no script at all: `--odr-fit` is the factor, or `auto` where only the view can measure it, and `--odr-zoom` plus `body{zoom}` carry what is applied. `HtmlConfig::initial_zoom` writes that, and reaches through all four bindings. Printing resets both: paper has its own geometry. Every view that renders something to read gets it — documents, pdf, text, xml, the archive listing, images. An image is fitted by css rather than by a body zoom, so its `max-width` puts the factor back to grow with it. The media view opts out, and the font preview ships no resources to hang a script on. Verified in Chrome, framed and top-level: the fit tracks resizes while fitted and holds while pinned, clamping and a NaN guard hold, the reading position drifts by ~0 across zoom steps, and a focal point stays within half a pixel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 866e7ddffa
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
`--odr-zoom` was written for what it was worth, so `initial_zoom = 1`
wrote nothing at all and the view read it back as unpinned: it went on
to measure the fit and apply that, and where the fit was known it
reported that factor while the page stood at actual size.
A pin is stated because it was set, whatever its value, and it states
only itself — the fit already has `--odr-fit`, and `body{zoom}` applies
whichever of the two wins. That also removes the guess the script made
of a pin that happened to equal the fit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k
andiwand
added a commit
that referenced
this pull request
Aug 20, 2026
Both changed the head of every page — a declared fit, a print-time zoom pin and `viewport.js` — without regenerating the reference set, so the comparison rendered new output against a tree missing the script. 265 public and 1175 private files, and the script itself; nothing else moved. Claude-Session: https://claude.ai/code/session_015d5RcmsA777vwXiuafjx6k Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
The fit from #719 landed a scale on the body and nothing could move it afterwards. An app wanting a zoom control had to reach for the platform's own —
WKWebView.pageZoom,WebView.zoomBy— which differ per platform, are inert for an embedder rendering us in a frame, and know nothing of the fit. That is the thing #719 just climbed out of.The api
Written into the
odrobject every view already carries:Clamped to
[0.1, 10]; a value that is not a number leaves the zoom alone.onZoomChangefires on a resize too, since the effective zoom moves while fitted without anyone calling a setter.The state behind it
One nullable number: a pinned zoom, or
nullwhile the view follows the fit — not a multiplier over the fit. A phone rotating with a 2× multiplier compounds it against the wider fit and overshoots, where a pinned zoom keeps the text the size the reader set. While pinned, the fit is not measured at all, so a resize costs nothing untilresetZoom().isZoomFitted()is the one bit a toolbar cannot derive from the number: "fitted at 0.72" and "pinned at 0.72" read the same otherwise.Focal point
focusis the point the zoom is centred on — a pinch's midpoint — given as{x, y}or any object carryingclientX/clientY, so a touch or mouse event can be handed straight over. The anchor that holds the reading position now pins the horizontal too, but only when a point asked for it: otherwise the page column centres itself and holding x would fight that.Css says what the view opens at
So a page under a strict Content-Security-Policy opens right with no script:
--odr-fitis the factor, orautowhere only the view can measure it, and--odr-zoomplusbody{zoom}carry what is applied.HtmlConfig::initial_zoomwrites that and reaches through all four bindings (python, jni, apple, wasm). Printing resets both — paper has its own geometry, and!importantbeats the inline zoom the script writes.Coverage
Documents, pdf, text, xml, the archive listing and images. An image is fitted by css rather than by a body zoom (
100%of a zoomed body is the viewport again), so itsmax-widthputs the factor back to grow with it. The media view opts out — zooming a<video>is not meaningful — and the font preview ships no resources to hang a script on.Verified
html_commonandhtmlsuites pass, plus the 306 html-output tests. In Chrome, framed and top-level:resetZoom()re-measures against the current widthNot in here
touch-actionplustouchmove, and WebKit's non-standardgesturechange) needs real-device testing on both platforms and can break panning if it is wrong.<style>in its head, so the pinned output drifts — same as feat(html): fit paged output to the viewport, and hold the reading position #719, which did not advance the pointer either.