From a87dffaad3ee54351da26e7ace2c1c2ab4a36187 Mon Sep 17 00:00:00 2001 From: TeoSlayer Date: Sun, 25 Oct 2026 22:02:44 -0700 Subject: [PATCH] Use direct scroll transforms for touch teaser navigation --- docs/cloud-teaser.md | 6 +++++- scripts/check-teaser-scroll.mjs | 33 ++++++++++++++++++++++++++------- src/scripts/cloud-teaser.ts | 5 ++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/cloud-teaser.md b/docs/cloud-teaser.md index 79b53d75..d4ac37f7 100644 --- a/docs/cloud-teaser.md +++ b/docs/cloud-teaser.md @@ -18,7 +18,7 @@ One Inter Tight typeface, cream/clay colors, unframed photography, and sparse sc Native vertical document scrolling moves the story horizontally. Mandatory CSS snap points settle on full chapters; there is no wheel or touch interception, synthetic momentum, timed advance, background video, or soundtrack. Previous/next buttons and Left/Right keys are available. Inactive chapters are inert. -Supported browsers drive the track with a native CSS scroll timeline. The JavaScript fallback writes only its transform once per animation frame. The previous inherited per-panel custom-property writes and decorative parallax were removed. Chapter state changes only at boundaries; the progress bar does not animate on touch devices. +Supported desktop browsers drive the track with a native CSS scroll timeline. Touch devices always use the measured document scroll position, writing only the track transform once per animation frame. This avoids relying on CSS syntax support as evidence that the sticky track actually advances on iPhone Safari. Other unsupported browsers use the same transform path. The previous inherited per-panel custom-property writes and decorative parallax were removed. Chapter state changes only at boundaries; the progress bar does not animate on touch devices. The stage uses stable small-viewport height. Height-only resize events on touch devices no longer rebuild the layout or call scrollTo when browser chrome expands or collapses. Real width changes remeasure after a short debounce. The final chapter reserves large-viewport scroll space. These changes address identifiable sources of lag; no physical-phone frame-rate measurement is claimed. @@ -38,3 +38,7 @@ Implementation references: [WebKit scroll-driven animations](https://webkit.org/ - The separate `/pilot-film` page was checked at 375×667 in both formats. It starts paused and provides downloads and the full transcript. No film asset loads on the teaser. Browser checks include Chrome device emulation, not physical iPhone/Safari testing. The previous version’s 320×568 check is not claimed as a fresh visual check of this revision; the same short-window fallback remains covered by the controller logic. + +### iPhone Safari frozen-slide follow-up + +The founder reported that the new design loaded but stayed on its first slide while scrolling in iPhone Safari. Touch devices now bypass CSS scroll timelines even when CSS.supports returns true. The scroll handler directly positions the track using one transform per frame; it retains native scroll snapping and the height-only resize protection. The regression check now covers touch browsers both with and without advertised CSS timeline support, verifies the actual transform at every chapter, and confirms that the final CTA remains reachable after a width change. Desktop native/fallback paths and reduced motion still pass. This is a targeted compatibility fix; physical-iPhone confirmation remains with the reporter. diff --git a/scripts/check-teaser-scroll.mjs b/scripts/check-teaser-scroll.mjs index 2d3ba369..b19460fc 100644 --- a/scripts/check-teaser-scroll.mjs +++ b/scripts/check-teaser-scroll.mjs @@ -6,7 +6,13 @@ import { stripTypeScriptTypes } from 'node:module'; import vm from 'node:vm'; const source = stripTypeScriptTypes(readFileSync(new URL('../src/scripts/cloud-teaser.ts', import.meta.url), 'utf8')).replace('export {};', ''); -for (const nativeTimeline of [true, false]) { +for (const { nativeSupport, touch } of [ + { nativeSupport: true, touch: true }, // iPhone Safari: parsed CSS is not enough. + { nativeSupport: false, touch: true }, + { nativeSupport: true, touch: false }, + { nativeSupport: false, touch: false }, +]) { + const nativeTimeline = nativeSupport && !touch; const writes = []; const resets = []; const scrolls = []; @@ -23,30 +29,43 @@ for (const nativeTimeline of [true, false]) { const track = element(); const panels = Array.from({ length: 5 }, (_, i) => ({ ...element(), dataset: { name: `Scene ${i}`, tone: 'light' }, querySelector: () => ({ clientHeight: 660, scrollHeight: 660, getBoundingClientRect: () => ({ top: 0, bottom: 660 }), children: [{ getBoundingClientRect: () => ({ top: 150, bottom: 250 }) }] }) })); const reduced = { matches: false, addEventListener(type, callback) { this.change = callback; } }; - const browser = { innerWidth: 390, innerHeight: 700, scrollY: 0, matchMedia: query => query.includes('reduced') ? reduced : { matches: true }, addEventListener: (name, fn) => { events[name] = fn; }, scrollTo: options => { scrolls.push(options); browser.scrollY = options.top; }, clearTimeout() { timer = undefined; }, setTimeout(fn) { timer = fn; return 1; } }; + const browser = { innerWidth: 390, innerHeight: 700, scrollY: 0, matchMedia: query => query.includes('reduced') ? reduced : { matches: touch }, addEventListener: (name, fn) => { events[name] = fn; }, scrollTo: options => { scrolls.push(options); browser.scrollY = options.top; }, clearTimeout() { timer = undefined; }, setTimeout(fn) { timer = fn; return 1; } }; const nodes = { '.teaser-story': story, '.teaser-stage': stage, '.teaser-track': track, '.teaser-controls': element(), '[data-previous]': element(), '[data-next]': element(), '[data-chapter]': element(), '[data-story-status]': element(), '[data-scroll-cue]': element(), '.story-progress > span': element() }; const document = { documentElement: root, activeElement: null, querySelector: selector => nodes[selector] ?? null, querySelectorAll: selector => selector === '.teaser-panel' ? panels : [], createElement: element, addEventListener() {}, fonts: { ready: Promise.resolve() } }; - vm.runInNewContext(source, { document, window: browser, CSS: { supports: () => nativeTimeline }, requestAnimationFrame: fn => frames.push(fn), getComputedStyle: () => ({ paddingTop: '80', paddingBottom: '80', position: 'static' }) }); + vm.runInNewContext(source, { document, window: browser, CSS: { supports: () => nativeSupport }, requestAnimationFrame: fn => frames.push(fn), getComputedStyle: () => ({ paddingTop: '80', paddingBottom: '80', position: 'static' }) }); await Promise.resolve(); assert.equal(classes.has('teaser-horizontal'), true); assert.equal(classes.has('teaser-native'), nativeTimeline); writes.length = resets.length = scrolls.length = 0; - for (let i = 0; i < 50; i++) { browser.innerHeight = 700 + i; events.resize(); } - assert.equal(timer, undefined, 'toolbar-only resize must not schedule a layout reset'); + if (touch) { + for (let i = 0; i < 50; i++) { browser.innerHeight = 700 + i; events.resize(); } + assert.equal(timer, undefined, 'toolbar-only resize must not schedule a layout reset'); + } assert.equal(resets.length, 0); assert.equal(scrolls.length, 0, 'toolbar resize must not jump or snap the page'); for (let i = 0; i < 30; i++) { browser.scrollY = i * 8; events.scroll(); frames.splice(0).forEach(fn => fn()); } assert.equal(writes.some(([key]) => key.startsWith('--')), false, 'scroll must not write inherited custom properties'); - assert.equal(writes.length, nativeTimeline ? 0 : 30, 'native scroll has no per-frame style writes; fallback has only the track transform'); + assert.equal(writes.length, (nativeTimeline ? 0 : 30) + (touch ? 0 : 30), 'only track and desktop progress transforms may change'); + for (let index = 1; index < panels.length; index++) { + browser.scrollY = index * stage.clientHeight; + events.scroll(); + frames.splice(0).forEach(fn => fn()); + if (!nativeTimeline) assert.equal(track.style.transform, `translate3d(${-index * browser.innerWidth}px, 0, 0)`, 'scrolling must actually move the visible slide, even when touch browsers report timeline support'); + assert.equal(root.dataset.chapter, String(index)); + assert.equal(nodes['[data-chapter]'].textContent, String(index + 1).padStart(2, '0')); + assert.equal(panels.every((panel, i) => panel.inert === (i !== index)), true); + } + assert.equal(nodes['[data-next]'].disabled, true, 'the final CTA chapter must be reachable'); browser.innerWidth = 430; events.resize(); assert.equal(typeof timer, 'function', 'a real width change must remeasure the layout'); timer(); assert.equal(scrolls.length, 1); + assert.equal(browser.scrollY, 4 * stage.clientHeight, 'a width change preserves the final chapter'); reduced.matches = true; reduced.change(); assert.equal(classes.has('teaser-horizontal'), false); assert.equal(classes.has('teaser-native'), false); assert.equal(panels.every(panel => !panel.inert), true); } -console.log('Teaser scroll regression checks passed: native/fallback, toolbar resizing, width changes, reduced motion.'); +console.log('Teaser scroll regression checks passed: touch slide movement with/without CSS support, desktop native/fallback, toolbar resizing, width changes, reduced motion.'); diff --git a/src/scripts/cloud-teaser.ts b/src/scripts/cloud-teaser.ts index d410aa36..16e1e990 100644 --- a/src/scripts/cloud-teaser.ts +++ b/src/scripts/cloud-teaser.ts @@ -14,7 +14,10 @@ const cue = document.querySelector('[data-scroll-cue]'); const progressFill = document.querySelector('.story-progress > span'); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const touchDevice = window.matchMedia('(hover: none), (pointer: coarse)'); -const nativeTimeline = CSS.supports('animation-timeline', 'scroll(root block)') && CSS.supports('animation-range', '0px 1px'); +// iPhone Safari can accept these properties without advancing this sticky +// track's timeline. Touch devices use the measured scroll position directly; +// one transform per frame retains native scrolling and avoids layout work. +const nativeTimeline = !touchDevice.matches && CSS.supports('animation-timeline', 'scroll(root block)') && CSS.supports('animation-range', '0px 1px'); let horizontal = false; let active = 0; let lastPainted = -1;