Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/cloud-teaser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
33 changes: 26 additions & 7 deletions scripts/check-teaser-scroll.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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.');
5 changes: 4 additions & 1 deletion src/scripts/cloud-teaser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ const cue = document.querySelector<HTMLElement>('[data-scroll-cue]');
const progressFill = document.querySelector<HTMLElement>('.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;
Expand Down
Loading