From c93a93bc81ba36424071317d4e535be2a03f042b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:33:26 +0100 Subject: [PATCH 1/4] [#630] Two-phase book page turn with visible page underneath Replaced sequential flip-out/flip-in with simultaneous two-page stack: - Outgoing page rendered on top (z-index 2) with backface-visibility hidden - Incoming page visible underneath, revealed as outgoing flips away - Shadow follows the fold line during the 500ms turn animation - Outgoing page rotates 120deg (not 90) for realistic page curl effect - Incoming page has subtle opacity fade-in from 0.4 to 1 - GPU-accelerated: only transform, opacity, box-shadow animated - Works on swipe, button click, and keyboard arrows Fixes realproject7/plotlink#630 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/globals.css | 70 +++++++++++++++++++++------------- src/components/ReadingMode.tsx | 65 +++++++++++++++++++------------ 2 files changed, 84 insertions(+), 51 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 651b7283..8ce10920 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -210,45 +210,63 @@ code, pre, .font-mono { } } -/* Reading Mode page-flip transitions (3D book-style) */ +/* Reading Mode — two-phase book page turn */ .page-flip-container { - perspective: 1200px; + perspective: 1500px; } -@keyframes flip-out-forward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(-90deg); opacity: 0; } +.page-flip-stack { + position: relative; + min-height: 100%; } -@keyframes flip-out-backward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(90deg); opacity: 0; } + +.page-flip-page { + position: relative; + will-change: transform, opacity; } -@keyframes flip-in-from-right { - 0% { transform: rotateY(90deg); opacity: 0; } - 100% { transform: rotateY(0deg); opacity: 1; } + +/* Outgoing page stacks on top and flips away */ +.page-outgoing { + position: absolute; + inset: 0; + z-index: 2; + backface-visibility: hidden; + overflow: hidden; } -@keyframes flip-in-from-left { - 0% { transform: rotateY(-90deg); opacity: 0; } - 100% { transform: rotateY(0deg); opacity: 1; } + +/* Incoming page: subtle fade-in from underneath */ +.page-incoming { + animation: page-reveal 500ms ease-out; } -/* Outgoing page: flips away */ -.page-flip-out-left { - transform-origin: left center; - animation: flip-out-forward 200ms ease-in forwards; +@keyframes page-reveal { + 0% { opacity: 0.4; } + 60% { opacity: 0.8; } + 100% { opacity: 1; } } -.page-flip-out-right { - transform-origin: right center; - animation: flip-out-backward 200ms ease-in forwards; + +/* Next: outgoing page flips from right edge (like turning a book page forward) */ +@keyframes flip-out-forward { + 0% { transform: rotateY(0deg); box-shadow: -4px 0 16px rgba(0,0,0,0.1); } + 40% { box-shadow: -10px 0 30px rgba(0,0,0,0.2); } + 100% { transform: rotateY(-120deg); box-shadow: none; } +} + +/* Prev: outgoing page flips from left edge (like turning a book page backward) */ +@keyframes flip-out-backward { + 0% { transform: rotateY(0deg); box-shadow: 4px 0 16px rgba(0,0,0,0.1); } + 40% { box-shadow: 10px 0 30px rgba(0,0,0,0.2); } + 100% { transform: rotateY(120deg); box-shadow: none; } } -/* Incoming page: flips into view */ -.page-flip-in-left { + +.page-flip-out-left { transform-origin: left center; - animation: flip-in-from-right 200ms ease-out; + animation: flip-out-forward 500ms ease-in forwards; } -.page-flip-in-right { + +.page-flip-out-right { transform-origin: right center; - animation: flip-in-from-left 200ms ease-out; + animation: flip-out-backward 500ms ease-in forwards; } /* Custom select dropdown styling */ diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index f9a7ec91..d2a9b744 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -28,7 +28,7 @@ export function ReadingMode({ const [currentIdx, setCurrentIdx] = useState(initialChapterIndex); const [showToc, setShowToc] = useState(false); const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null); - const [flipPhase, setFlipPhase] = useState<"out" | "in" | null>(null); + const [outgoingIdx, setOutgoingIdx] = useState(null); const contentRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); @@ -36,6 +36,7 @@ export function ReadingMode({ const { isMiniApp } = usePlatformDetection(); const chapter = chapters[currentIdx]; + const outgoingChapter = outgoingIdx !== null ? chapters[outgoingIdx] : null; const hasPrev = currentIdx > 0; const hasNext = currentIdx < chapters.length - 1; @@ -46,21 +47,18 @@ export function ReadingMode({ const navigate = useCallback((idx: number, dir: "left" | "right" | null) => { if (flipping.current) return; flipping.current = true; - // Phase 1: flip out the current page + // Capture outgoing page, set incoming as current + setOutgoingIdx(currentIdx); setFlipDir(dir); - setFlipPhase("out"); + setCurrentIdx(idx); + scrollToTop(); + // Clear outgoing page after animation completes setTimeout(() => { - // Phase 2: swap content and flip in the new page - setCurrentIdx(idx); - scrollToTop(); - setFlipPhase("in"); - setTimeout(() => { - setFlipDir(null); - setFlipPhase(null); - flipping.current = false; - }, 200); - }, 200); - }, [scrollToTop]); + setOutgoingIdx(null); + setFlipDir(null); + flipping.current = false; + }, 500); + }, [currentIdx, scrollToTop]); const goPrev = useCallback(() => { if (hasPrev) navigate(currentIdx - 1, "right"); @@ -139,17 +137,34 @@ export function ReadingMode({ } }} > -
- {chapter?.content ? ( - - ) : ( -

Content unavailable

+
+ {/* Incoming page (underneath) */} +
+
+ {chapter?.content ? ( + + ) : ( +

Content unavailable

+ )} +
+
+ + {/* Outgoing page (on top, flipping away) */} + {outgoingChapter && flipDir && ( +
+
+ {outgoingChapter.content ? ( + + ) : ( +

Content unavailable

+ )} +
+
)}
From c7df5d2b0c9ca074fecec34c13af8f0c71290987 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:36:10 +0100 Subject: [PATCH 2/4] [#630] Delay scroll reset until after page flip animation completes scrollToTop() was running immediately, causing the outgoing page to snap to top before flipping. Now deferred to after the 500ms animation so the outgoing page maintains its scroll position during the turn. Addresses T2a review feedback on PR #655. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ReadingMode.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index d2a9b744..3b891d02 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -51,9 +51,10 @@ export function ReadingMode({ setOutgoingIdx(currentIdx); setFlipDir(dir); setCurrentIdx(idx); - scrollToTop(); - // Clear outgoing page after animation completes + // Delay scroll reset until after animation — the outgoing page is + // absolutely positioned over the scroll container during the flip setTimeout(() => { + scrollToTop(); setOutgoingIdx(null); setFlipDir(null); flipping.current = false; From 3536cef22443d45b17e1c31a2ec466e0d5ff9448 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:38:59 +0100 Subject: [PATCH 3/4] [#630] Freeze container height during page flip animation Captures and freezes the stack container's height before swapping content, preventing scroll clamp when transitioning from a long chapter to a shorter one. Height is released after animation completes. Addresses T2a review: outgoing page could jump on long-to-short chapter transitions because the absolute-positioned outgoing page no longer contributed to layout. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ReadingMode.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index 3b891d02..1ef9cd1d 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -30,6 +30,7 @@ export function ReadingMode({ const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null); const [outgoingIdx, setOutgoingIdx] = useState(null); const contentRef = useRef(null); + const stackRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); const flipping = useRef(false); @@ -47,14 +48,20 @@ export function ReadingMode({ const navigate = useCallback((idx: number, dir: "left" | "right" | null) => { if (flipping.current) return; flipping.current = true; + // Freeze container height so scroll geometry is stable during the flip + if (stackRef.current) { + stackRef.current.style.minHeight = `${stackRef.current.offsetHeight}px`; + } // Capture outgoing page, set incoming as current setOutgoingIdx(currentIdx); setFlipDir(dir); setCurrentIdx(idx); - // Delay scroll reset until after animation — the outgoing page is - // absolutely positioned over the scroll container during the flip + // After animation: reset scroll, unfreeze height, clean up setTimeout(() => { scrollToTop(); + if (stackRef.current) { + stackRef.current.style.minHeight = ""; + } setOutgoingIdx(null); setFlipDir(null); flipping.current = false; @@ -138,7 +145,7 @@ export function ReadingMode({ } }} > -
+
{/* Incoming page (underneath) */}
From 17dd19c583ef7d167cdd93ad42461cd1d8d6bf95 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 30 Mar 2026 21:42:02 +0100 Subject: [PATCH 4/4] [#630] Decouple outgoing page from live scroll container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures scrollTop before navigation, scrolls to top immediately for the incoming page, then renders the outgoing page with a negative top offset to maintain its visual scroll position during the flip. This fully decouples the two pages: incoming starts at top, outgoing animates from wherever the reader was — no blank space or mid-chapter jumps on either page. Addresses T2a review: outgoing/incoming shared scroll container issue. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ReadingMode.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index 1ef9cd1d..31cc9d2b 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -29,6 +29,7 @@ export function ReadingMode({ const [showToc, setShowToc] = useState(false); const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null); const [outgoingIdx, setOutgoingIdx] = useState(null); + const [outgoingScroll, setOutgoingScroll] = useState(0); const contentRef = useRef(null); const stackRef = useRef(null); const touchStartX = useRef(0); @@ -48,17 +49,20 @@ export function ReadingMode({ const navigate = useCallback((idx: number, dir: "left" | "right" | null) => { if (flipping.current) return; flipping.current = true; - // Freeze container height so scroll geometry is stable during the flip + // Capture scroll offset so the outgoing page can render at its old position + const scrollOffset = contentRef.current?.scrollTop ?? 0; + setOutgoingScroll(scrollOffset); + // Freeze container height so layout is stable during the flip if (stackRef.current) { stackRef.current.style.minHeight = `${stackRef.current.offsetHeight}px`; } - // Capture outgoing page, set incoming as current + // Set outgoing, swap to incoming, and scroll to top immediately setOutgoingIdx(currentIdx); setFlipDir(dir); setCurrentIdx(idx); - // After animation: reset scroll, unfreeze height, clean up + scrollToTop(); + // After animation: unfreeze height, clean up setTimeout(() => { - scrollToTop(); if (stackRef.current) { stackRef.current.style.minHeight = ""; } @@ -157,13 +161,16 @@ export function ReadingMode({
- {/* Outgoing page (on top, flipping away) */} + {/* Outgoing page (on top, flipping away at its old scroll offset) */} {outgoingChapter && flipDir && (
{outgoingChapter.content ? (