add 3d page flip animation

Hunter
Monday, February 2nd, 2026 at 6:28 PM

resources/editor.js+4-2
resources/page-flip-animation.jsnew
resources/preview-manager.js+81-9
resources/spread-layout.js+46-4
resources/spread-navigation.js+26-4
resources/viewer-generator.js+162-10
resources/zine-styles.js+89 

resources/editor.js
 21  // Message handler for iframe communication
 22  window.addEventListener('message', function(event) {
 23  	if (event.data === 'toggleFullscreen') {
    -		toggleFullscreen(updatePreviewWrapper);
 24 +		toggleFullscreen();
 25 +		updatePreviewWrapper();
 26  	} else if (event.data === 'prevSpread') {
 27  		navigateSpread(-1, updatePreviewWrapper);
 28  	} else if (event.data === 'nextSpread') {

105  	// Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px)
106  	window.addEventListener('resize', function() {
107  		if (getIsFullscreen() && window.innerHeight <= 200) {
    -			toggleFullscreen(updatePreviewWrapper);
108 +			toggleFullscreen();
109 +			updatePreviewWrapper();
110  		}
111  	});
112  }
resources/page-flip-animation.js
  1 +// Page flip animation state
  2 +let isAnimating = false;
  3 +let currentSpreadIndex = 0;
  4 +
  5 +// Get animation state
  6 +export function getIsAnimating() {
  7 +	return isAnimating;
  8 +}
  9 +
 10 +// Page structure for flip animation
 11 +// Each "leaf" has a front and back side
 12 +// front-cover/page1 are on opposite sides of the same leaf
 13 +// page2/page3 are on opposite sides of the next leaf, etc.
 14 +const PAGE_LEAVES = [
 15 +	{ front: 'front-cover', back: 'page1' },      // Leaf 0
 16 +	{ front: 'page2', back: 'page3' },            // Leaf 1
 17 +	{ front: 'page4', back: 'page5' },            // Leaf 2
 18 +	{ front: 'page6', back: 'back-cover' }        // Leaf 3
 19 +];
 20 +
 21 +// Initialize the 3D page flip system
 22 +export function initPageFlip(container, doc) {
 23 +	currentSpreadIndex = 0;
 24 +
 25 +	// Create the book structure
 26 +	const book = doc.createElement('div');
 27 +	book.className = 'zine-book';
 28 +
 29 +	// Create all leaves (page pairs)
 30 +	PAGE_LEAVES.forEach((leaf, index) => {
 31 +		const leafEl = doc.createElement('div');
 32 +		leafEl.className = 'zine-leaf';
 33 +		leafEl.dataset.leafIndex = index;
 34 +		leafEl.dataset.state = 'closed'; // closed, flipping, open
 35 +
 36 +		// Front side of the leaf
 37 +		const frontSide = doc.createElement('div');
 38 +		frontSide.className = 'zine-leaf-front';
 39 +		const frontPage = doc.getElementById(leaf.front);
 40 +		if (frontPage) {
 41 +			frontSide.appendChild(frontPage.cloneNode(true));
 42 +		}
 43 +
 44 +		// Back side of the leaf
 45 +		const backSide = doc.createElement('div');
 46 +		backSide.className = 'zine-leaf-back';
 47 +		const backPage = doc.getElementById(leaf.back);
 48 +		if (backPage) {
 49 +			backSide.appendChild(backPage.cloneNode(true));
 50 +		}
 51 +
 52 +		leafEl.appendChild(frontSide);
 53 +		leafEl.appendChild(backSide);
 54 +		book.appendChild(leafEl);
 55 +	});
 56 +
 57 +	container.appendChild(book);
 58 +
 59 +	// Hide original pages in body
 60 +	const originalPages = doc.querySelectorAll('.page:not(.zine-base-page)');
 61 +	originalPages.forEach(page => {
 62 +		page.style.display = 'none';
 63 +	});
 64 +
 65 +	// Set initial state - all leaves closed, showing front cover
 66 +	updateLeafStates(0, doc);
 67 +}
 68 +
 69 +// Update which leaves are open/closed based on current spread
 70 +function updateLeafStates(spreadIndex, doc) {
 71 +	const leaves = doc.querySelectorAll('.zine-leaf');
 72 +
 73 +	leaves.forEach((leaf, index) => {
 74 +		// Spread 0 = front cover (no leaves flipped)
 75 +		// Spread 1 = pages 1-2 (leaf 0 flipped)
 76 +		// Spread 2 = pages 3-4 (leaves 0-1 flipped)
 77 +		// Spread 3 = pages 5-6 (leaves 0-2 flipped)
 78 +		// Spread 4 = back cover (all leaves flipped)
 79 +
 80 +		if (index < spreadIndex) {
 81 +			// This leaf should be flipped to the left (showing back)
 82 +			leaf.dataset.state = 'open';
 83 +			leaf.style.transform = 'rotateY(-180deg)';
 84 +			// Open leaves on left have lower z-index
 85 +			leaf.style.zIndex = String(index + 1);
 86 +		} else {
 87 +			// This leaf should be closed (on the right, showing front)
 88 +			leaf.dataset.state = 'closed';
 89 +			leaf.style.transform = 'rotateY(0deg)';
 90 +			// Closed leaves stack with first on top
 91 +			leaf.style.zIndex = String(20 - index);
 92 +		}
 93 +	});
 94 +
 95 +}
 96 +
 97 +// Animate page flip
 98 +export function animatePageFlip(fromSpread, toSpread, container, doc, onComplete) {
 99 +	if (isAnimating) return;
100 +
101 +	isAnimating = true;
102 +	currentSpreadIndex = toSpread;
103 +
104 +	const direction = toSpread > fromSpread ? 'forward' : 'backward';
105 +	const leaves = doc.querySelectorAll('.zine-leaf');
106 +
107 +	if (direction === 'forward') {
108 +		// Flipping forward (right to left)
109 +		const leafToFlip = leaves[fromSpread];
110 +		if (leafToFlip) {
111 +			leafToFlip.dataset.state = 'flipping';
112 +			leafToFlip.style.zIndex = '100'; // Put it on top during flip
113 +
114 +			// Enable transition
115 +			leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
116 +
117 +			// Trigger the flip
118 +			requestAnimationFrame(() => {
119 +				leafToFlip.style.transform = 'rotateY(-180deg)';
120 +			});
121 +
122 +			// Clean up after animation
123 +			const cleanup = () => {
124 +				leafToFlip.dataset.state = 'open';
125 +				leafToFlip.style.transition = '';
126 +				// Set z-index for open state (lower than closed leaves)
127 +				leafToFlip.style.zIndex = String(fromSpread + 1);
128 +				isAnimating = false;
129 +				if (onComplete) onComplete();
130 +				leafToFlip.removeEventListener('transitionend', cleanup);
131 +			};
132 +			leafToFlip.addEventListener('transitionend', cleanup);
133 +		}
134 +	} else {
135 +		// Flipping backward (left to right)
136 +		const leafToFlip = leaves[toSpread];
137 +		if (leafToFlip) {
138 +			leafToFlip.dataset.state = 'flipping';
139 +			leafToFlip.style.zIndex = '100'; // Put it on top during flip
140 +
141 +			// Enable transition
142 +			leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
143 +
144 +			// Trigger the flip
145 +			requestAnimationFrame(() => {
146 +				leafToFlip.style.transform = 'rotateY(0deg)';
147 +			});
148 +
149 +			// Clean up after animation
150 +			const cleanup = () => {
151 +				leafToFlip.dataset.state = 'closed';
152 +				leafToFlip.style.transition = '';
153 +				// Set z-index for closed state (higher than open leaves)
154 +				leafToFlip.style.zIndex = String(20 - toSpread);
155 +				isAnimating = false;
156 +				if (onComplete) onComplete();
157 +				leafToFlip.removeEventListener('transitionend', cleanup);
158 +			};
159 +			leafToFlip.addEventListener('transitionend', cleanup);
160 +		}
161 +	}
162 +}
163 +
164 +// Set current spread without animation (for initial load)
165 +export function setSpreadImmediate(spreadIndex, doc) {
166 +	currentSpreadIndex = spreadIndex;
167 +	updateLeafStates(spreadIndex, doc);
168 +}
resources/preview-manager.js
  1  import { insertAfterHead } from './html-utils.js';
  2  import { extractTitleAndFavicon, updateMainPageTitleAndFavicon } from './html-utils.js';
    -import { generateSpreadCSS } from './zine-styles.js';
  3 +import { generateSpreadCSS, ZINE_PAGE_CSS, ZINE_FLIP_CSS, ZINE_PRINT_CSS } from './zine-styles.js';
  4  import { SPREADS } from './constants.js';
  5  import { getCurrentSpread } from './spread-navigation.js';
  6  import { isMobileDevice } from './mobile-keyboard.js';
    -import { setupSpreadLayout, scaleSpreadToFit } from './spread-layout.js';
  7 +import { setupSpreadLayout, scaleSpreadToFit, navigateToSpread } from './spread-layout.js';
  8 +import { setSpreadImmediate } from './page-flip-animation.js';
  9 +
 10 +// Track if flip mode is enabled
 11 +let flipModeEnabled = true; // Default to enabled
 12 +let previousSpread = 0;
 13 +
 14 +export function setFlipMode(enabled) {
 15 +	flipModeEnabled = enabled;
 16 +}
 17 +
 18 +export function isFlipModeEnabled() {
 19 +	return flipModeEnabled;
 20 +}
 21 +
 22 +// Generate CSS for flip mode
 23 +function generateFlipModeCSS() {
 24 +	return `
 25 +		* { margin: 0; padding: 0; box-sizing: border-box; }
 26 +
 27 +		@media screen {
 28 +			html, body {
 29 +				height: 100% !important;
 30 +				overflow: hidden !important;
 31 +			}
 32 +
 33 +			body {
 34 +				display: flex !important;
 35 +				justify-content: center !important;
 36 +				align-items: center !important;
 37 +			}
 38 +
 39 +			body > *:not(.zine-spread-container):not(.iframe-fullscreen-toggle) {
 40 +				display: none !important;
 41 +			}
 42 +
 43 +			${ZINE_PAGE_CSS}
 44 +			${ZINE_FLIP_CSS}
 45 +
 46 +			.zine-spread-container {
 47 +				display: flex !important;
 48 +				position: relative;
 49 +			}
 50 +
 51 +			.page {
 52 +				display: block !important;
 53 +			}
 54 +		}
 55 +
 56 +		@media print {
 57 +			${ZINE_PRINT_CSS}
 58 +		}
 59 +	`;
 60 +}
 61  
 62  export function updatePreview(editorView, isEditorFocused) {
 63  	const preview = document.getElementById('preview');

 82  
 83  	// Inject spread CSS right after <head> so user styles come last and take precedence
 84  	const currentSpread = getCurrentSpread();
    -	const spreadCSS = generateSpreadCSS(currentSpread);
 85 +	const spreadCSS = flipModeEnabled ? generateFlipModeCSS() : generateSpreadCSS(currentSpread);
 86  	const processedCode = insertAfterHead(code, `<style id="zine-editor-spread-css">${spreadCSS}</style>`);
 87  
 88  	preview.srcdoc = processedCode || '<!DOCTYPE html><html><head></head><body></body></html>';

118  				const container = doc.createElement('div');
119  				container.className = 'zine-spread-container';
120  
    -				setupSpreadLayout(container, spread, doc);
    -				doc.body.appendChild(container);
121 +				if (flipModeEnabled) {
122 +					// Setup flip animation mode
123 +					setupSpreadLayout(container, spread, doc, true);
124 +					doc.body.appendChild(container);
125 +
126 +					// Set initial spread state without animation
127 +					setTimeout(() => {
128 +						setSpreadImmediate(currentSpread, doc);
129 +						const scaleToFit = () => scaleSpreadToFit(container, doc);
130 +						scaleToFit();
131 +						doc.defaultView.addEventListener('resize', scaleToFit);
132 +					}, 0);
133 +				} else {
134 +					// Original non-animated mode
135 +					setupSpreadLayout(container, spread, doc, false);
136 +					doc.body.appendChild(container);
137 +
138 +					// Scale container to fit viewport
139 +					const scaleToFit = () => scaleSpreadToFit(container, doc);
140 +					scaleToFit();
141 +					doc.defaultView.addEventListener('resize', scaleToFit);
142 +				}
143  
    -				// Scale container to fit viewport
    -				const scaleToFit = () => scaleSpreadToFit(container, doc);
    -				scaleToFit();
    -				doc.defaultView.addEventListener('resize', scaleToFit);
144 +				// Store container and flip mode state for navigation updates
145 +				doc._zineContainer = container;
146 +				doc._flipModeEnabled = flipModeEnabled;
147  			}
148  
149  			// Add keyboard listener for navigation and shortcuts
resources/spread-layout.js
 1  import { SPREADS } from './constants.js';
 2  import { createEmptyPlaceholder } from './html-utils.js';
 3 +import { initPageFlip, animatePageFlip, setSpreadImmediate, getIsAnimating } from './page-flip-animation.js';
 4 +
 5 +// Track whether flip mode is enabled
 6 +let flipModeEnabled = false;
 7 +let currentFlipSpread = 0;
 8 +
 9 +// Enable/disable flip animation mode
10 +export function setFlipMode(enabled) {
11 +	flipModeEnabled = enabled;
12 +}
13 +
14 +export function isFlipModeEnabled() {
15 +	return flipModeEnabled;
16 +}
17  
18  // Common logic for displaying a spread (used by both editor preview and standalone viewer)
   -export function setupSpreadLayout(container, spread, doc) {
19 +export function setupSpreadLayout(container, spread, doc, useFlipAnimation = false) {
20 +	if (useFlipAnimation) {
21 +		// Initialize flip animation on first call
22 +		if (!container.querySelector('.zine-book')) {
23 +			initPageFlip(container, doc);
24 +		}
25 +		return;
26 +	}
27 +
28 +	// Original non-animated behavior
29  	// Add empty placeholder before right page if no left page
30  	if (!spread.left && spread.right) {
31  		container.appendChild(createEmptyPlaceholder(doc));

44  	}
45  }
46  
47 +// Navigate to a spread with optional flip animation
48 +export function navigateToSpread(container, spreadIndex, doc, useFlipAnimation = false, onComplete) {
49 +	if (useFlipAnimation && container.querySelector('.zine-book')) {
50 +		if (!getIsAnimating()) {
51 +			animatePageFlip(currentFlipSpread, spreadIndex, container, doc, onComplete);
52 +			currentFlipSpread = spreadIndex;
53 +		}
54 +	} else {
55 +		// Original non-animated behavior
56 +		const spread = SPREADS[spreadIndex];
57 +		container.innerHTML = '';
58 +		setupSpreadLayout(container, spread, doc, false);
59 +		if (onComplete) onComplete();
60 +	}
61 +}
62 +
63  // Scale spread container to fit viewport
64  export function scaleSpreadToFit(container, doc, bottomPadding = 0) {
65 +	// Find the book container if in flip mode
66 +	const bookContainer = container.querySelector('.zine-book') || container;
67 +
68  	const vw = doc.documentElement.clientWidth;
69  	const vh = doc.documentElement.clientHeight - bottomPadding;
   -	const spreadWidthPx = container.offsetWidth;
   -	const spreadHeightPx = container.offsetHeight;
70 +	const spreadWidthPx = bookContainer.offsetWidth;
71 +	const spreadHeightPx = bookContainer.offsetHeight;
72  	if (spreadWidthPx === 0 || spreadHeightPx === 0) return;
73  	const scaleX = (vw - 40) / spreadWidthPx;
74  	const scaleY = (vh - 40) / spreadHeightPx;
75  	const scale = Math.min(scaleX, scaleY);
   -	container.style.transform = `scale(${scale})`;
76 +	bookContainer.style.transform = `scale(${scale})`;
77  }
resources/spread-navigation.js
41  	if (newSpread >= 0 && newSpread < SPREADS.length) {
42  		currentSpread = newSpread;
43  		updateSpreadIndicator();
   -		updatePreviewCallback();
   -		// Refocus preview so keyboard navigation continues to work
   -		document.getElementById('preview').focus();
44 +
45 +		// Try to animate if flip mode is enabled
46 +		const preview = document.getElementById('preview');
47 +		try {
48 +			const doc = preview.contentDocument;
49 +			const container = doc?._zineContainer;
50 +			const flipModeEnabled = doc?._flipModeEnabled;
51 +
52 +			if (container && flipModeEnabled) {
53 +				// Dynamic import to avoid circular dependency
54 +				import('./spread-layout.js').then(({ navigateToSpread }) => {
55 +					navigateToSpread(container, newSpread, doc, true, () => {
56 +						// Refocus preview so keyboard navigation continues to work
57 +						preview.focus();
58 +					});
59 +				});
60 +			} else {
61 +				updatePreviewCallback();
62 +				preview.focus();
63 +			}
64 +		} catch (e) {
65 +			// Fallback to regular update
66 +			updatePreviewCallback();
67 +			preview.focus();
68 +		}
69  	}
70  }
71  
72  // Fullscreen toggle
   -export function toggleFullscreen(updatePreviewCallback) {
73 +export function toggleFullscreen() {
74  	isFullscreen = !isFullscreen;
75  	const editorPane = document.querySelector('.editor-pane');
76  	const previewPane = document.querySelector('.preview-pane');
resources/viewer-generator.js
  1  import { PAGE_IDS, SPREADS, NAV_HEIGHT, NAV_BUTTON_CSS, NAV_BUTTON_STYLES } from './constants.js';
    -import { ZINE_PAGE_CSS, ZINE_PRINT_CSS } from './zine-styles.js';
  2 +import { ZINE_PAGE_CSS, ZINE_PRINT_CSS, ZINE_FLIP_CSS } from './zine-styles.js';
  3  
  4  // Generate standalone viewer CSS and JS (matches preview pane rendering)
  5  export function generateViewerCode() {

 22  				display: none !important;
 23  			}
 24  			${ZINE_PAGE_CSS}
 25 +			${ZINE_FLIP_CSS}
 26 +
 27 +			/* Dynamic z-index based on leaf state */
 28 +			.zine-leaf[data-state="closed"][data-leaf-index="0"] { z-index: 14 !important; }
 29 +			.zine-leaf[data-state="closed"][data-leaf-index="1"] { z-index: 13 !important; }
 30 +			.zine-leaf[data-state="closed"][data-leaf-index="2"] { z-index: 12 !important; }
 31 +			.zine-leaf[data-state="closed"][data-leaf-index="3"] { z-index: 11 !important; }
 32 +			.zine-leaf[data-state="open"][data-leaf-index="0"] { z-index: 4 !important; }
 33 +			.zine-leaf[data-state="open"][data-leaf-index="1"] { z-index: 3 !important; }
 34 +			.zine-leaf[data-state="open"][data-leaf-index="2"] { z-index: 2 !important; }
 35 +			.zine-leaf[data-state="open"][data-leaf-index="3"] { z-index: 1 !important; }
 36 +			.zine-leaf[data-state="flipping"] { z-index: 20 !important; }
 37 +
 38  			.zine-spread-container {
 39  				display: flex !important;
 40  				position: relative;

 78  		const NAV_HEIGHT = ${NAV_HEIGHT};
 79  		let currentSpread = 0;
 80  		let container;
 81 +		let isAnimating = false;
 82 +		const USE_FLIP_ANIMATION = true;
 83 +
 84 +		// Page leaf structure (front and back of each physical page)
 85 +		const PAGE_LEAVES = [
 86 +			{ front: 'front-cover', back: 'page1' },
 87 +			{ front: 'page2', back: 'page3' },
 88 +			{ front: 'page4', back: 'page5' },
 89 +			{ front: 'page6', back: 'back-cover' }
 90 +		];
 91 +
 92 +		function easeInOutCubic(t) {
 93 +			return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
 94 +		}
 95  
 96  		function scaleToFit() {
 97  			if (!container) return;
 98 +			const bookContainer = container.querySelector('.zine-book') || container;
 99  			const vw = document.documentElement.clientWidth;
100  			const vh = document.documentElement.clientHeight - NAV_HEIGHT;
    -			const spreadWidthPx = container.offsetWidth;
    -			const spreadHeightPx = container.offsetHeight;
101 +			const spreadWidthPx = bookContainer.offsetWidth;
102 +			const spreadHeightPx = bookContainer.offsetHeight;
103  			if (spreadWidthPx === 0 || spreadHeightPx === 0) return;
104  			const scaleX = (vw - 40) / spreadWidthPx;
105  			const scaleY = (vh - 40) / spreadHeightPx;
106  			const scale = Math.min(scaleX, scaleY);
    -			container.style.transform = 'scale(' + scale + ')';
107 +			bookContainer.style.transform = 'scale(' + scale + ')';
108 +		}
109 +
110 +		function initFlipMode() {
111 +			const book = document.createElement('div');
112 +			book.className = 'zine-book';
113 +
114 +			// Create all leaves
115 +			PAGE_LEAVES.forEach((leaf, index) => {
116 +				const leafEl = document.createElement('div');
117 +				leafEl.className = 'zine-leaf';
118 +				leafEl.dataset.leafIndex = index;
119 +				leafEl.dataset.state = 'closed';
120 +				leafEl.style.zIndex = String(20 - index); // Initial z-index for closed state
121 +
122 +				const frontSide = document.createElement('div');
123 +				frontSide.className = 'zine-leaf-front';
124 +				const frontPage = document.getElementById(leaf.front);
125 +				if (frontPage) frontSide.appendChild(frontPage.cloneNode(true));
126 +
127 +				const backSide = document.createElement('div');
128 +				backSide.className = 'zine-leaf-back';
129 +				const backPage = document.getElementById(leaf.back);
130 +				if (backPage) backSide.appendChild(backPage.cloneNode(true));
131 +
132 +				leafEl.appendChild(frontSide);
133 +				leafEl.appendChild(backSide);
134 +				book.appendChild(leafEl);
135 +			});
136 +
137 +			container.appendChild(book);
138  		}
139  
    -		function showSpread(index) {
140 +		function animateFlip(fromSpread, toSpread, onComplete) {
141 +			if (isAnimating) return;
142 +			isAnimating = true;
143 +
144 +			const direction = toSpread > fromSpread ? 'forward' : 'backward';
145 +			const leaves = document.querySelectorAll('.zine-leaf');
146 +
147 +			if (direction === 'forward') {
148 +				const leafToFlip = leaves[fromSpread];
149 +				if (leafToFlip) {
150 +					leafToFlip.dataset.state = 'flipping';
151 +					leafToFlip.style.zIndex = '100';
152 +					const startTime = performance.now();
153 +					const duration = 600;
154 +
155 +					function animate(currentTime) {
156 +						const elapsed = currentTime - startTime;
157 +						const progress = Math.min(elapsed / duration, 1);
158 +						const eased = easeInOutCubic(progress);
159 +						const rotation = eased * -180;
160 +						leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)';
161 +
162 +						if (progress < 1) {
163 +							requestAnimationFrame(animate);
164 +						} else {
165 +							leafToFlip.dataset.state = 'open';
166 +							leafToFlip.style.zIndex = String(fromSpread + 1);
167 +							isAnimating = false;
168 +							if (onComplete) onComplete();
169 +						}
170 +					}
171 +					requestAnimationFrame(animate);
172 +				}
173 +			} else {
174 +				const leafToFlip = leaves[toSpread];
175 +				if (leafToFlip) {
176 +					leafToFlip.dataset.state = 'flipping';
177 +					leafToFlip.style.zIndex = '100';
178 +					const startTime = performance.now();
179 +					const duration = 600;
180 +
181 +					function animate(currentTime) {
182 +						const elapsed = currentTime - startTime;
183 +						const progress = Math.min(elapsed / duration, 1);
184 +						const eased = easeInOutCubic(progress);
185 +						const rotation = -180 + (eased * 180);
186 +						leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)';
187 +
188 +						if (progress < 1) {
189 +							requestAnimationFrame(animate);
190 +						} else {
191 +							leafToFlip.dataset.state = 'closed';
192 +							leafToFlip.style.zIndex = String(20 - toSpread);
193 +							isAnimating = false;
194 +							if (onComplete) onComplete();
195 +						}
196 +					}
197 +					requestAnimationFrame(animate);
198 +				}
199 +			}
200 +		}
201 +
202 +		function showSpread(index, animated) {
203  			const spread = SPREADS[index];
    -			const visible = [spread.left, spread.right].filter(Boolean);
204  
    -			// Move existing pages back to body before clearing container
205 +			if (USE_FLIP_ANIMATION && animated && container.querySelector('.zine-book')) {
206 +				animateFlip(currentSpread, index, () => {
207 +					document.getElementById('zine-indicator').textContent = spread.label;
208 +					document.getElementById('zine-prev').disabled = index === 0;
209 +					document.getElementById('zine-next').disabled = index === SPREADS.length - 1;
210 +				});
211 +				return;
212 +			}
213 +
214 +			// Non-animated or initial setup
215 +			const visible = [spread.left, spread.right].filter(Boolean);
216  			PAGE_IDS.forEach(id => {
217  				const page = document.getElementById(id);
218  				if (page) {

252  		function navigate(delta) {
253  			const newIndex = currentSpread + delta;
254  			if (newIndex >= 0 && newIndex < SPREADS.length) {
255 +				const oldSpread = currentSpread;
256  				currentSpread = newIndex;
    -				showSpread(currentSpread);
257 +				showSpread(currentSpread, true);
258  			}
259  		}
260  

309  					document.getElementById('zine-prev').addEventListener('click', () => navigate(-1));
310  					document.getElementById('zine-next').addEventListener('click', () => navigate(1));
311  
    -					showSpread(currentSpread);
312 +					if (USE_FLIP_ANIMATION) {
313 +						initFlipMode();
314 +						const leaves = document.querySelectorAll('.zine-leaf');
315 +						leaves.forEach((leaf, index) => {
316 +							if (index < currentSpread) {
317 +								leaf.dataset.state = 'open';
318 +								leaf.style.transform = 'rotateY(-180deg)';
319 +								leaf.style.zIndex = String(index + 1);
320 +							}
321 +						});
322 +					} else {
323 +						showSpread(currentSpread, false);
324 +					}
325 +					scaleToFit();
326  
327  					const blob = new Blob([html], { type: 'text/html' });
328  					const url = URL.createObjectURL(blob);

336  
337  			window.addEventListener('resize', scaleToFit);
338  
    -			showSpread(0);
339 +			// Initialize flip mode
340 +			if (USE_FLIP_ANIMATION) {
341 +				initFlipMode();
342 +				document.getElementById('zine-indicator').textContent = SPREADS[0].label;
343 +				document.getElementById('zine-prev').disabled = true;
344 +				document.getElementById('zine-next').disabled = false;
345 +				scaleToFit();
346 +			} else {
347 +				showSpread(0, false);
348 +			}
349  		});
350  	`;
351  
resources/zine-styles.js
 41  	a { color: black; }
 42  `;
 43  
 44 +// CSS for 3D page flip animation
 45 +export const ZINE_FLIP_CSS = `
 46 +	.zine-book {
 47 +		position: relative;
 48 +		width: ${PAGE_WIDTH_IN * 2}in;
 49 +		height: ${PAGE_HEIGHT_IN}in;
 50 +		perspective: 2000px;
 51 +		transform-style: preserve-3d;
 52 +		transform-origin: center center;
 53 +	}
 54 +
 55 +	.zine-leaf {
 56 +		position: absolute;
 57 +		width: ${PAGE_WIDTH_IN}in;
 58 +		height: ${PAGE_HEIGHT_IN}in;
 59 +		left: ${PAGE_WIDTH_IN}in;
 60 +		top: 0;
 61 +		transform-origin: left center;
 62 +		transform-style: preserve-3d;
 63 +		transition: none;
 64 +	}
 65 +
 66 +	/* When flipping, use high z-index so it's on top during animation */
 67 +	.zine-leaf[data-state="flipping"] {
 68 +		z-index: 20 !important;
 69 +		transition: none;
 70 +	}
 71 +
 72 +	.zine-leaf-front,
 73 +	.zine-leaf-back {
 74 +		position: absolute;
 75 +		width: 100%;
 76 +		height: 100%;
 77 +		backface-visibility: hidden;
 78 +		-webkit-backface-visibility: hidden;
 79 +	}
 80 +
 81 +	.zine-leaf-front {
 82 +		transform: rotateY(0deg);
 83 +		z-index: 2;
 84 +	}
 85 +
 86 +	.zine-leaf-back {
 87 +		transform: rotateY(180deg);
 88 +		z-index: 1;
 89 +	}
 90 +
 91 +	.zine-leaf-front .page,
 92 +	.zine-leaf-back .page {
 93 +		width: 100%;
 94 +		height: 100%;
 95 +		display: block !important;
 96 +	}
 97 +
 98 +	/* Base pages - shown beneath the flipping leaves */
 99 +	.zine-base-pages {
100 +		position: absolute;
101 +		width: 100%;
102 +		height: 100%;
103 +		display: flex;
104 +		justify-content: center;
105 +		align-items: center;
106 +		z-index: 0;
107 +	}
108 +
109 +	.zine-base-page {
110 +		position: absolute;
111 +		width: ${PAGE_WIDTH_IN}in;
112 +		height: ${PAGE_HEIGHT_IN}in;
113 +		display: none !important;
114 +		z-index: 0;
115 +	}
116 +
117 +	/* Position base pages */
118 +	.zine-base-page[data-page-id="front-cover"],
119 +	.zine-base-page[data-page-id="page2"],
120 +	.zine-base-page[data-page-id="page4"],
121 +	.zine-base-page[data-page-id="page6"] {
122 +		right: 0;
123 +	}
124 +
125 +	.zine-base-page[data-page-id="page1"],
126 +	.zine-base-page[data-page-id="page3"],
127 +	.zine-base-page[data-page-id="page5"],
128 +	.zine-base-page[data-page-id="back-cover"] {
129 +		left: 0;
130 +	}
131 +`;
132 +
133  // Shared CSS for zine pages (used by both editor preview and exported viewer)
134  export const ZINE_PAGE_CSS = `
135  	${ALL_PAGES_SELECTOR} {