---
name: page-turn-styles-viewtransitions-555
description: "#555 slide/curl page-turn styles via View Transitions — snapshot layering, shadow-DOM name scoping, margin clip, scrubbed drag tracking"
metadata: 
  node_type: memory
  type: project
  originSessionId: 91cbce94-0703-478d-9671-b12629fd8d9f
---

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-name` on elements inside shadow roots creates NO capture group (Chrome 149) — the name must go on the outermost shadow host in the DOCUMENT tree (walk `getRootNode() instanceof ShadowRoot → .host`). In Readest that's the `foliate-view` element.
- **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 = offset` after `transition.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, drive `currentTime = progress × duration` from the finger. Release: `play()` to commit; cancel = `reverse()`, restore `containerPosition` in the anims-finished microtask (before next paint — avoids flashing the target page), then `skipTransition()`.
- **getComputedStyle on VT pseudos lies:** it reports rule-matched styles even with no active transition — test with `document.getAnimations()` filtered by `effect.pseudoElement` instead; a layer styled `animation: none` has 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 needs `z-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).
