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
4 changes: 4 additions & 0 deletions docs/cloud-motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ Updated the preview branch to the current main branch before publication. The st
### Link-story revision — 25 October 2026

Learn more now follows the teaser’s seven-part narrative. Its header stays hidden until the Pilot-link reveal enters the viewport; no navigation was added. The illustrative demo now shows inbound coffee, introduction, group-plan and informational requests. The existing pause/reduced-motion, height-reservation, identity selection and cleanup logic remains. Historical counts and browser checks above apply to their named earlier revisions; current release checks are recorded in `cloud-teaser.md` and `pilot-film.md`.

## Cross-platform icons

The teaser and Learn more page use inline SVG paths from `CloudIcon.astro` instead of emoji or font-symbol icons. The local approval/replay interaction preserves the icon markup. The teaser’s native scroll timeline and stalled-timeline fallback are described in `cloud-teaser.md`; no additional decorative motion was introduced.
10 changes: 8 additions & 2 deletions docs/cloud-teaser.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ One Inter Tight typeface, cream/clay colors, unframed photography, and seven con

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 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 marker updates once per chapter on touch devices, rather than animating continuously.
Supported browsers, including touch devices, drive the track with a native CSS scroll timeline. The previous unconditional JavaScript transform on phones caused the visual motion to depend on main-thread scroll events. Native movement now uses automatic scroll-timeline duration and an explicit pixel endpoint. A check after scrolling settles compares the actual transform with the expected slide position. If a browser accepts the CSS but stalls its timeline, it permanently switches to the measured-transform fallback for that page load, immediately repairing the picture without resetting the scroll position. Unsupported browsers use the same fallback. There are no computed-style reads during active native scrolling, and no per-frame style writes on the native touch path. Chapter state changes only at boundaries; the touch progress marker also updates once per chapter. [Safari 26.4 introduced threaded scroll-driven animations](https://webkit.org/blog/17862/webkit-features-for-safari-26-4/).

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 @@ -32,7 +32,7 @@ Implementation references: [WebKit scroll-driven animations](https://webkit.org/

## Verification

The seven-chapter regression harness checks actual touch transforms with and without advertised CSS timeline support, desktop native/fallback modes, toolbar resizing, width changes and reduced motion. It verifies the last chapter remains reachable. Existing phone compatibility behavior is preserved.
The seven-chapter regression harness checks native touch timelines, stalled-timeline recovery, and actual fallback transforms without CSS timeline support, desktop native/fallback modes, toolbar resizing, width changes and reduced motion. It verifies the last chapter remains reachable. Existing phone compatibility behavior is preserved.

Current visual and build checks are recorded with the release. Browser emulation is not physical iPhone testing. The founder’s earlier symptom was a counter advancing while the image stayed frozen; touch devices consequently bypass CSS scroll timelines and use one transform write per animation frame. Physical-iPhone confirmation remains pending.

Expand All @@ -51,3 +51,9 @@ The new progress marker follows touch chapters with a single boundary update, pr
The expanded request wall and conversational wording follow the approved quick draft. At 1352×667, the prior distribution composition exceeded its grid content area and switched the whole story into vertical fallback. Compact desktop spacing and height-aware display type restore horizontal mode while retaining the accessibility fallback. The repaired distribution slide and reachable 07/07 were visually checked before this copy revision. Current controller regression covers all seven chapters. A fresh complete physical-phone check is not claimed.

Current conversational revision: fresh Chrome checks at 1352×667 and 375×667 show the expanded request wall fits and horizontal mode remains active. Mobile checks covered the grouped link, autopilot example, distribution and 07/07 final action. Native scrolling visibly advances both slide and counter. Browser emulation only; no new physical-iPhone claim.

### SVG icons and mobile scroll follow-up — 25 October 2026

- Both campaign pages use the shared inline `CloudIcon.astro` paths for decorative starbursts, hearts, coffee, group/chat icons, arrows, checks, replay and disclosures. They inherit the page palette, have fixed proportional bounds, and are hidden from assistive technology alongside visible text labels. No emoji font or icon-font dependency remains. The teaser approval button changes only its label and icon visibility, preserving SVGs on replay.
- Chrome responsive mode at 375×667: inspected the starburst, approval/replay controls, sharing slide and final CTA. Native scroll advanced the visible slides from03/07 through07/07. The Elements panel confirmed `teaser-native` remained active after the scroll checks. Learn more’s four example buttons and heart icon were visually checked at the same size. Desktop chapter buttons and the starburst were also checked.
- Regression coverage includes working native timelines on touch and desktop, zero computed-transform reads while scrolling, stalled timeline recovery preserving scroll/chapter, subsequent fallback gestures, toolbar-only resizes, width changes, and reduced motion. Browser emulation does not establish physical-iPhone frame rate.
36 changes: 32 additions & 4 deletions scripts/check-teaser-scroll.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ import vm from 'node:vm';

const source = stripTypeScriptTypes(readFileSync(new URL('../src/scripts/cloud-teaser.ts', import.meta.url), 'utf8')).replace('export {};', '');
for (const { nativeSupport, touch } of [
{ nativeSupport: true, touch: true }, // iPhone Safari: parsed CSS is not enough.
{ nativeSupport: true, touch: true }, // iPhone Safari: verify motion, not just syntax.
{ nativeSupport: false, touch: true },
{ nativeSupport: true, touch: false },
{ nativeSupport: false, touch: false },
]) {
const nativeTimeline = nativeSupport && !touch;
const nativeTimeline = nativeSupport;
const writes = [];
const resets = [];
const scrolls = [];
const events = {};
const frames = [];
let timer;
let timelineStalled = false;
let transformReads = 0;
const style = () => new Proxy({ setProperty(key, value) { writes.push([key, value]); }, removeProperty() {} }, { set(target, key, value) { writes.push([key, value]); target[key] = value; return true; } });
const element = () => ({ style: style(), dataset: {}, hidden: false, inert: false, contains: () => false, setAttribute() {}, addEventListener() {}, append() {}, prepend() {}, focus() {}, scrollIntoView() {} });
const root = element();
Expand All @@ -32,7 +34,14 @@ for (const { nativeSupport, touch } of [
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: () => nativeSupport }, 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: node => {
if (node === track) {
transformReads++;
const x = timelineStalled ? 0 : -(browser.scrollY / stage.clientHeight) * browser.innerWidth;
return { transform: touch ? `matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, ${x}, 0, 0, 1)` : `matrix(1, 0, 0, 1, ${x}, 0)` };
}
return { paddingTop: '80', paddingBottom: '80', position: 'static' };
} });
await Promise.resolve();
assert.equal(classes.has('teaser-horizontal'), true);
assert.equal(classes.has('teaser-native'), nativeTimeline);
Expand All @@ -46,6 +55,12 @@ for (const { nativeSupport, touch } of [
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) + (touch ? 0 : 30), 'only track and desktop progress transforms may change');
assert.equal(transformReads, 0, 'native movement must not read computed transforms while scrolling');
if (nativeTimeline) {
timer();
assert.equal(transformReads, 1, 'check the actual picture after scrolling settles');
assert.equal(classes.has('teaser-native'), true, 'a working timeline stays native on touch and desktop');
}
for (let index = 1; index < panels.length; index++) {
browser.scrollY = index * stage.clientHeight;
events.scroll();
Expand All @@ -63,10 +78,23 @@ for (const { nativeSupport, touch } of [
timer();
assert.equal(scrolls.length, 1);
assert.equal(browser.scrollY, (panels.length - 1) * stage.clientHeight, 'a width change preserves the final chapter');
if (nativeTimeline) {
timelineStalled = true;
events.scroll();
frames.splice(0).forEach(fn => fn());
timer();
assert.equal(classes.has('teaser-native'), false, 'a stalled native timeline must fall back, even when CSS syntax is supported');
assert.equal(track.style.transform, `translate3d(${-6 * browser.innerWidth}px, 0, 0)`, 'repair the picture immediately without resetting scroll or chapter');
assert.equal(nodes['[data-chapter]'].textContent, '07');
browser.scrollY = 5 * stage.clientHeight;
events.scroll();
frames.splice(0).forEach(fn => fn());
assert.equal(track.style.transform, `translate3d(${-5 * browser.innerWidth}px, 0, 0)`, 'fallback must keep following subsequent gestures');
}
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: touch slide movement with/without CSS support, desktop native/fallback, toolbar resizing, width changes, reduced motion.');
console.log('Teaser scroll checks passed: native touch/desktop motion, no per-frame touch style writes, stalled-timeline recovery, unsupported fallback, toolbar resizing, width changes, reduced motion.');
29 changes: 29 additions & 0 deletions src/components/CloudIcon.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
// Inline paths avoid platform emoji fonts and need no client-side icon library.
const paths = {
'arrow-up-right': ['M6 18 18 6M6 6h12v12'],
'arrow-right': ['M4 12h16m-6-6 6 6-6 6'],
'arrow-left': ['M20 12H4m6-6-6 6 6 6'],
'arrow-down': ['M12 4v16m-6-6 6 6 6-6'],
check: ['m5 12 4 4L19 6'],
plus: ['M12 5v14M5 12h14'],
heart: ['M20.3 5.7a5.6 5.6 0 0 0-7.9 0l-.4.4-.4-.4a5.6 5.6 0 0 0-7.9 7.9L12 21l8.3-7.4a5.6 5.6 0 0 0 0-7.9Z'],
asterisk: ['M12 3v18M3 12h18M5.6 5.6l12.8 12.8M5.6 18.4 18.4 5.6'],
moon: ['M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z'],
coffee: ['M18 8h1a3 3 0 0 1 0 6h-1M3 8h15v7a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4ZM3 22h18M7 1v3M11 1v3M15 1v3'],
users: ['M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8', 'M13 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z'],
message: ['M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5Z'],
replay: ['M3 10a9 9 0 1 1 2.6 8.4M3 4v6h6'],
smile: ['M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01'],
} as const;

export type CloudIconName = keyof typeof paths;
interface Props { name: CloudIconName; }
const { name } = Astro.props;
---
<svg class="cloud-icon" data-icon={name} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width={name === 'asterisk' ? 3 : 1.8} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
{paths[name].map(d => <path d={d} />)}
</svg>
<style>
.cloud-icon { display: inline-block; width: 1em; height: 1em; flex: none; vertical-align: -.125em; }
</style>
Loading
Loading