11 KiB
name, description, metadata
| name | description | metadata | ||||||
|---|---|---|---|---|---|---|---|---|
| page-turn-styles-viewtransitions-555 | #555 slide/curl page-turn styles via View Transitions — snapshot layering, shadow-DOM name scoping, margin clip, scrubbed drag tracking |
|
Issue #555 (Apple Books/Kindle turn animations): implemented pageTurnStyle view setting (Push/Slide/Curl). MERGED 2026-07-05: app PR readest#4940 → main 75f1fafe9 (11 commits, includes the Xcode-26.2 swift-rs build fix) + foliate fork readest/foliate-js#48. Mesh curl verified live on macOS/iOS/Android; Windows/Linux capture backends still open (CSS-curl fallback). Worktree removed post-merge (pnpm worktree:rm feat/page-turn-styles-555 — takes BRANCH name not path; plain git worktree remove refuses trees with submodules); phase-2 plan preserved at main checkout apps/readest-app/.claude/plans/page-curl-mesh-tauri-555.md.
Phase 2 (true mesh curl on Tauri, 2026-07-04): plan at worktree apps/readest-app/.claude/plans/page-curl-mesh-tauri-555.md (plans dir is gitignored). Done + committed: WebGL curl renderer src/utils/pageCurl.ts (000814a51) and native-bridge capture_webview_region for macOS/iOS (85e592fd1). Renderer gotchas: WebGL canvas needs preserveDrawingBuffer:true or readPixels silently returns zeros after any await (browser composited); the clip-space Y flip mirrors triangle winding so frontFace(CW) or gl_FrontFacing (front vs whitened back) inverts; corner-grab fold tilt must decay (1-p); travel = w + PI*r_end; WebKit ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap uploads — curl was upside down on iOS (back read as 180°-rotated instead of Apple-Books horizontal mirror); fix = upload unflipped + vUv = aPos (f9a49fd4c); test textures MUST be vertically asymmetric to catch flips, and running the vitest browser suite on Playwright WebKit (instances: [{ browser: 'webkit' }] temporarily in vitest.browser.config.mts) reproduces iOS WebGL behavior. Capture: binary ipc::Response PNG; macOS legacy objc msg_send WKWebView takeSnapshot in plugin src/platform/macos.rs, iOS Swift + base64 across JSON plugin boundary. Orchestration committed: CapturedPageTurn (src/app/reader/utils/capturedTurn.ts, renamed from MeshCurlTurn/meshCurl.ts in 5ba62ef07, host-callback DI, browser-tested) runs capture→overlay→instant-nav→animate with a per-turn style ('curl' WebGL mesh | 'slide' flat canvas); turns the FULL gridcell (header/footer/margins ride the sheet, per Apple Books video + user request 7e300a7bd) — NOTE this diverges from the VT slide/curl which clips furniture static; instant nav = drop animated attr (ALL paginator animated paths incl. VT gate on it); backward = mirrored old-page-recedes (rendererRtl = forward?rtl:!rtl); useCapturedTurn (renamed from useMeshPageCurl) wraps view.prev/next + touch interceptor (priority 5, ruler=10 swipe-flip=0), drag progress from deltaX/gridWidth, cancel un-curls then navs BACK under the flat overlay; applyPageTurnAttributes = single source for turn-style/no-swipe (FoliateViewer open + ControlPanel pageTurnStyle/animated/disableSwipe effects); session captureBroken flag → paginator turn-style where VT is fully supported, push elsewhere. Android capture done (PixelCopy in NativeBridgePlugin.kt: CSS px × density + getLocationInWindow; kotlin compile check = gen/android ./gradlew :tauri-plugin-native-bridge:compileFossDebugKotlin). Android perf gotcha (user-verified fixed): full-density PNG encode = ~1.5s/turn on Xiaomi 13 (3x, 1080×2400) → curl read as broken; fix = JPEG q85 + dest bitmap capped at 2× CSS px (PixelCopy scales into smaller dest for free) → invoke 1550ms→34ms, overlay mounts 132ms after tap; JS must NOT hardcode blob type 'image/png' (decoder sniffs). iOS same optimization (3e7f58135): jpegData(0.85) off-main + WKSnapshotConfiguration.snapshotWidth = width*2/scale when scale>2 (snapshotWidth is in POINTS; image px = points×scale); macOS stays PNG. CDP verify lane: pnpm dev-android + helpers in src/__tests__/android/helpers/ (forwardWebViewDevtools + CdpPage.evaluate; window.__TAURI_INTERNALS__.invoke('plugin:native-bridge|capture_webview_region',{payload}) times raw capture; document.querySelector('foliate-view').next() triggers the wrapped turn; Page.captureScreenshot for mid-turn frames). Remaining: Windows/Linux capture; LIVE curl visuals still unverified (macOS smoke test reached reader, push path fine; interrupted — user was at the machine). Live-run gotchas: dev binary exits instantly if production Readest runs (single-instance, same bundle id — quit prod first); computer-use MCP can't see/screenshot the bare target/debug binary — drive with bash screencapture + JXA CGEvent clicks (AppleScript AX click at fires the Book-Details action, not reader open). Shared target/ symlink gotcha: deleted worktrees leave stale plugin build-script caches ("failed to read plugin permissions" from dead paths) — cargo clean -p <plugin>....
Curl final (curved corner fold): radial-gradient mask on the OLD pseudo — transparent disc grows from the outer-bottom corner (forward) / spine-side corner (backward), fold edge = curved arc like a lifted page corner. The fold animates a GRADIENT STOP via registered @property --foliate-fold (re-rasterizes mask per frame). VT pseudo paint quirks (computed style LIES — always verify with screenshots via vitest browser page.screenshot): width animations compute but don't repaint; mask-position/mask-size animations paint at wrong scale/not at all; masks apply ONLY to the static old snapshot, NOT the live new layer (backward turns must choreograph old receding, not new unfolding); UA sets mix-blend-mode: plus-lighter on old/new (force normal); back both layers with --foliate-vt-bg (from doc --theme-bg-color, textured themes have transparent page bg per #4399); paint uses LINEAR progress ignoring easing (cosmetic). filter/clip-path order: filter runs BEFORE clip/mask → drop-shadows get cut with the page. VT skips (ready rejects InvalidStateError) when document.hidden — Chrome-MCP automation tab is usually hidden; verify in vitest browser (visible) with frozen animations + screenshots. Header/footer in both layers: app marks the boundary with data-view-transition-root on the gridcell; paginator prefers closest('[data-view-transition-root]') from the outermost shadow host. True mesh bend on web: impossible (no DOM pixel access); plan = WebGL curl shader + Tauri native webview capture (WKWebView takeSnapshot / PixelCopy / CapturePreview) as a follow-up; web keeps the arc fold.
iOS 18 VT crash + gating (2026-07-06, 5ba62ef07): iOS 18.7 WKWebView HAS document.startViewTransition but the VT slide CRASHES the WebContent process (Sandbox process-info-codesignature deny then WebContent gone; Android WebView 147 fine) — API presence is NOT a safe gate. App-side gate supportsViewTransitionTurns() (in useCapturedTurn.ts) = startViewTransition + CSS.supports('view-transition-group', 'nearest') (nested VT groups: Chrome/Edge/WebView 140+ ONLY; Safari ≤27 and Firefox lack it per caniuse — so ALL WebKit and Gecko engines get no VT turns). applyPageTurnAttributes only sets turn-style when the gate passes, so synced slide/curl settings degrade safely. Fallback on Tauri: getCapturedTurnStyle() routes slide → the capture pipeline with PageSlideRenderer (src/utils/pageSlide.ts, 2D canvas, translateX toward spine = (rtl?1:-1)*progress*width with the shared rendererRtl mirror; overlay gets overflow:hidden clip + box-shadow edge like the VT slide; backward = old-slides-out-mirrored since only the OUTGOING page can be captured — the overlay div sits above the live iframe so a second capture would include it). Web without full VT: push only; ControlPanel hides Slide/Page Curl (turnStyleOptions = push + layered when supportsViewTransitionTurns() || isTauriAppPlatform()) and coerces an unsupported synced value to display as Push. Tests: useCapturedTurn.test.ts (jsdom, stub CSS.supports + startViewTransition; vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM','tauri') toggles isTauriAppPlatform) + slide cases in captured-turn.browser.test.ts (read canvas transform via new DOMMatrixReadOnly(getComputedStyle(c).transform).e).
Mechanism: slide/curl need old+new page as separate layers — impossible in the rigid multicol strip (see vertical-rl-horizontal-pagination-624). The View Transitions API rasterizes the outgoing page (annotations included) and animates the snapshot over the live, stationary incoming page. Axis-agnostic: works for vertical-rl too. turn-style attribute on the renderer; falls back to push/two-phase when document.startViewTransition is missing (old WebKitGTK/iOS<18).
Hard-won gotchas:
- Shadow-DOM tree scoping:
view-transition-nameon elements inside shadow roots creates NO capture group (Chrome 149) — the name must go on the outermost shadow host in the DOCUMENT tree (walkgetRootNode() instanceof ShadowRoot → .host). In Readest that's thefoliate-viewelement. - Header/footer stay static (user requirement): app header/footer (SectionInfo/ProgressBar) are siblings positioned over the margins; the host snapshot covers margins too and slides over them. Fix: clip
::view-transition-group(foliate-turn)with--foliate-vt-clip: inset(margins)(margins read from--_margin-*on #top) so margins stay owned by the static root snapshot. - Landing race: a neighbor view load mid-transition re-anchors to the stale pre-turn anchor; re-assert
containerPosition = offsetaftertransition.finished(push's cssAnimateScroll does the same at its end). - Finger tracking = scrubbed VT: start the transition at drag threshold (direction from net dx),
await ready,updateTiming({easing:'linear'})+pause()all(foliate-turn)pseudo animations, drivecurrentTime = progress × durationfrom the finger. Release:play()to commit; cancel =reverse(), restorecontainerPositionin the anims-finished microtask (before next paint — avoids flashing the target page), thenskipTransition(). - getComputedStyle on VT pseudos lies: it reports rule-matched styles even with no active transition — test with
document.getAnimations()filtered byeffect.pseudoElementinstead; a layer styledanimation: nonehas no entry (proves it's stationary). - Choreography via classes on documentElement (
foliate-vt-{slide,curl} -{forward,backward} -{left,right}) + one injected document-head stylesheet (pseudo tree lives on :root, not the shadow root). Old-on-top needsz-index: 1(new is on top by default). Curl = perspective rotateY fold (flat snapshot can't mesh-bend); side class = spine side = rtl?right:left.
Tests: paginator-turn-styles.browser.test.ts (slide layering old-moves/new-still, curl, vertical-rl, drag tracking commit + revert, push fallback via deleting startViewTransition).