From 63b56a6c51150710246097aa623d6e52e1039e78 Mon Sep 17 00:00:00 2001 From: TeoSlayer Date: Sun, 25 Oct 2026 22:55:05 -0700 Subject: [PATCH] Use consistent SVG icons and native mobile teaser motion --- docs/cloud-motion.md | 4 +++ docs/cloud-teaser.md | 10 ++++-- scripts/check-teaser-scroll.mjs | 36 ++++++++++++++++++--- src/components/CloudIcon.astro | 29 +++++++++++++++++ src/pages/cloud-landing.astro | 57 +++++++++++++++++---------------- src/pages/cloud-teaser.astro | 25 ++++++++------- src/scripts/cloud-teaser.ts | 45 +++++++++++++++++++++----- src/styles/cloud-teaser.css | 5 +-- 8 files changed, 155 insertions(+), 56 deletions(-) create mode 100644 src/components/CloudIcon.astro diff --git a/docs/cloud-motion.md b/docs/cloud-motion.md index ce130cd7..daf6eb4c 100644 --- a/docs/cloud-motion.md +++ b/docs/cloud-motion.md @@ -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. diff --git a/docs/cloud-teaser.md b/docs/cloud-teaser.md index 18136c12..49f3e398 100644 --- a/docs/cloud-teaser.md +++ b/docs/cloud-teaser.md @@ -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. @@ -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. @@ -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. diff --git a/scripts/check-teaser-scroll.mjs b/scripts/check-teaser-scroll.mjs index 5a12eeb4..62b683ff 100644 --- a/scripts/check-teaser-scroll.mjs +++ b/scripts/check-teaser-scroll.mjs @@ -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(); @@ -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); @@ -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(); @@ -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.'); diff --git a/src/components/CloudIcon.astro b/src/components/CloudIcon.astro new file mode 100644 index 00000000..edc38d76 --- /dev/null +++ b/src/components/CloudIcon.astro @@ -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; +--- + + diff --git a/src/pages/cloud-landing.astro b/src/pages/cloud-landing.astro index 5b116f27..c50ec385 100644 --- a/src/pages/cloud-landing.astro +++ b/src/pages/cloud-landing.astro @@ -1,5 +1,6 @@ --- import BaseHead from '../components/BaseHead.astro'; +import CloudIcon from '../components/CloudIcon.astro'; // A standalone, noindex warm preview. Keep /cloud and its shared theme unchanged. const app = 'https://cloud.pilotprotocol.network'; @@ -48,7 +49,7 @@ const identityApps = [recommendedAgents[0], apps[0], apps[1]]; const incomingRequests = ["Coffee this week?", "Could you make an intro?", "When’s everyone free?", "So, what do you do?", "Can you give me feedback on this?", "Know a good plumber?", "Know a good designer?", "Can I pitch you something?"]; const examples = [ { - label: 'Coffee', icon: '☕', title: 'Coffee with Priya', + label: 'Coffee', icon: 'coffee' as const, title: 'Coffee with Priya', app: { name: 'Dots', logo: 'dots.webp', owner: 'Priya' }, thread: [['them', 'coffee this week?'], ['you', 'thursday?'], ['them', 'what time?'], ['you', 'let me check…'], ['them', 'afternoon maybe?']], ask: 'Ask Maya’s agent about coffee on Thursday.', @@ -58,7 +59,7 @@ const examples = [ looseEnd: 'A quick coffee. A not-so-quick conversation.', }, { - label: 'An introduction', icon: '↗', title: 'An introduction for Sam', + label: 'An introduction', icon: 'arrow-up-right' as const, title: 'An introduction for Sam', app: { name: 'Hermes', logo: 'hermes.png', owner: 'Sam' }, thread: [['them', 'do you know Alex?'], ['you', 'yes, why?'], ['them', 'could you introduce us?'], ['you', 'what should I tell them?'], ['them', 'I’ll write something up…']], ask: 'Ask Maya if she can introduce me to Alex.', @@ -68,9 +69,9 @@ const examples = [ looseEnd: 'Another thread to follow up on.', }, { - label: 'A group plan', icon: '♡', title: 'Friday with everyone', + label: 'A group plan', icon: 'users' as const, title: 'Friday with everyone', threadIcon: 'smile' as const, app: { name: 'OpenClaw', logo: 'openclaw.png', owner: 'Lucas' }, - thread: [['them', 'everyone around friday?'], ['you', 'I think so, is Sam?'], ['them', 'let me check'], ['you', 'and what time?'], ['them', 'one thing at a time 😅']], + thread: [['them', 'everyone around friday?'], ['you', 'I think so, is Sam?'], ['them', 'let me check'], ['you', 'and what time?'], ['them', 'one thing at a time']], ask: 'Find a time for coffee with Maya and Sam on Friday.', reply: 'Would Friday at 4 work for everyone?', responses: [ @@ -81,7 +82,7 @@ const examples = [ looseEnd: 'One plan. Three conversations.', }, { - label: 'A quick question', icon: '✳', title: 'What do you do?', + label: 'A quick question', icon: 'message' as const, title: 'What do you do?', app: { name: 'ChatGPT', logo: 'chatgpt.svg', owner: 'Alex' }, thread: [['them', 'what are you working on?'], ['you', 'product design'], ['them', 'any examples?'], ['you', 'one sec, finding the link'], ['them', 'thank you!']], ask: 'Ask Maya’s agent about her work.', @@ -137,7 +138,7 @@ const faq = [ @@ -146,11 +147,11 @@ const faq = [
- -
Two friends laughing together over coffee at a café
more time for this.
+ +
Two friends laughing together over coffee at a café
more time for this.
@@ -159,7 +160,7 @@ const faq = [
{incomingRequests.map((request, index) =>

{request}

)}

Illustrative requests from friends and contacts

-

Let us help.

Make this the last time you do these chores.

+

Let us help.

Make this the last time you do these chores.

@@ -169,7 +170,7 @@ const faq = [

@maya

New AI. Same link.

-
Connected with Muse
+
Connected with Muse

Your people come with you.

You both sign in and connect an AI. You can use different apps. Pilot is hosted for you, with no Pilot software to install.

- Claim your handle + Claim your handle
@@ -203,32 +204,32 @@ const faq = [
{examples.map((example, i) => (
- +
The usual wayyou do the chasing

{example.title}

-
{example.thread.map(([who, message], index) =>

2 && 'trailing']}>{message}

)}
+
{example.thread.map(([who, message], index) =>

2 && 'trailing']}>{message}{index === example.thread.length - 1 && example.threadIcon && <> }

)}
{example.looseEnd}
-
The Pilot way
+
The Pilot way

{example.app.owner}, to {example.app.name}{example.ask}

{example.app.owner}’s {example.app.name}{example.reply}

{example.responses.map((response) =>

{response.name}{response.text}

)} -
{example.result}

{example.detail}

+
{example.result}

{example.detail}

You keep the relationship. Your agents handle the details.

))} - +
@@ -246,9 +247,9 @@ const faq = [ @@ -263,12 +264,12 @@ const faq = [

Across the Pilot Protocol network

{networkProof.count}Pilot nodes seen
in 30 minutes

{networkProof.registered &&

{networkProof.registered} addresses registered since launch.

} -

Snapshot: Registry data

+

Snapshot: Registry data

}

Seed investors

- $4.5M seed raised Read the announcement + $4.5M seed raised Read the announcement
@@ -283,18 +284,18 @@ const faq = [
-

Your people, by invitation.

You accept connections and choose a circle to guide what your agent shares.

-

Keep the AI you know.

Background agent or chat app: connect the AI you already use through MCP.

-

We take care of the hosting.

Your Pilot agent runs in the cloud, even when your laptop is closed.

+

Your people, by invitation.

You accept connections and choose a circle to guide what your agent shares.

+

Keep the AI you know.

Background agent or chat app: connect the AI you already use through MCP.

+

We take care of the hosting.

Your Pilot agent runs in the cloud, even when your laptop is closed.

A few things you might be wondering

Good questions.
Honest answers.

A curious Pilot puppy
-
{faq.map(([question, answer]) =>
{question}

{answer}

)}
+
{faq.map(([question, answer]) =>
{question}

{answer}

)}
-
Pilot, ready to meet you

Your people are the whole point.

Take your
life back.

Claim a handle. Connect your AI.
Share your link.

Claim your handle Free. Hosted for you. Both people connect an AI.
+
Pilot, ready to meet you

Your people are the whole point.

Take your
life back.

Claim a handle. Connect your AI.
Share your link.

Claim your handle Free. Hosted for you. Both people connect an AI.
diff --git a/src/pages/cloud-teaser.astro b/src/pages/cloud-teaser.astro index a65876cb..3fd48d16 100644 --- a/src/pages/cloud-teaser.astro +++ b/src/pages/cloud-teaser.astro @@ -1,5 +1,6 @@ --- import BaseHead from '../components/BaseHead.astro'; +import CloudIcon from '../components/CloudIcon.astro'; import '../styles/cloud-teaser.css'; const app = 'https://cloud.pilotprotocol.network'; @@ -48,7 +49,7 @@ const requests = [ Skip to the story
Pilot - Claim your handle + Claim your handle
@@ -67,15 +68,15 @@ const requests = [
-

Let us help.

Make this the last time
you do these chores.

+

Let us help.

Make this the last time
you do these chores.

diff --git a/src/scripts/cloud-teaser.ts b/src/scripts/cloud-teaser.ts index 0f6f2144..3b127081 100644 --- a/src/scripts/cloud-teaser.ts +++ b/src/scripts/cloud-teaser.ts @@ -14,10 +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)'); -// 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 the browser's scrolling thread move the picture, including on iOS. +// Syntax support alone is insufficient: verify the actual track after a scroll +// settles and permanently fall back if a browser accepts but stalls the timeline. +let nativeTimeline = CSS.supports('animation-timeline', 'scroll(root block)') && CSS.supports('animation-range', '0px 1px'); let horizontal = false; let active = 0; let lastPainted = -1; @@ -25,6 +25,25 @@ let stageHeight = 0; let stageWidth = 0; let startY = 0; let pendingFrame = 0; +let timelineCheck = 0; + +function scrollProgress() { + return Math.max(0, Math.min(panels.length - 1, (window.scrollY - startY) / stageHeight)); +} + +function verifyTimeline() { + timelineCheck = 0; + if (!horizontal || !nativeTimeline || !track || !stageHeight) return; + const expectedX = -scrollProgress() * stageWidth; + const transform = getComputedStyle(track).transform; + const values = transform.match(/^matrix(3d)?\(([^)]+)\)$/); + const actualX = values ? Number(values[2].split(',')[values[1] ? 12 : 4]) : 0; + if (!Number.isFinite(actualX) || Math.abs(actualX - expectedX) > 4) { + nativeTimeline = false; + root.classList.remove('teaser-native'); + paint(); + } +} function setActive(index: number) { if (index === lastPainted) return; @@ -49,7 +68,7 @@ function setActive(index: number) { function paint() { pendingFrame = 0; if (!horizontal || !track || !stageHeight) return; - const progress = Math.max(0, Math.min(panels.length - 1, (window.scrollY - startY) / stageHeight)); + const progress = scrollProgress(); // Supported browsers animate the track on their scroll timeline. The JS // fallback changes one transform, never inherited styles on every panel. if (!nativeTimeline) track.style.transform = `translate3d(${-progress * stageWidth}px, 0, 0)`; @@ -58,7 +77,14 @@ function paint() { } function onScroll() { - if (horizontal && !pendingFrame) pendingFrame = requestAnimationFrame(paint); + if (!horizontal) return; + if (!pendingFrame) pendingFrame = requestAnimationFrame(paint); + if (nativeTimeline) { + window.clearTimeout(timelineCheck); + // No style/layout reads while the finger or native momentum is moving. + // The timeout also covers older browsers without a scrollend event. + timelineCheck = window.setTimeout(verifyTimeline, 160); + } } function goTo(index: number) { @@ -109,7 +135,7 @@ function configure(keepPlace = false) { } else { story.style.setProperty('--teaser-step', `${stageHeight}px`); story.style.setProperty('--teaser-distance', `${stageHeight * (panels.length - 1)}px`); - story.style.setProperty('--teaser-shift', `${-100 * (panels.length - 1)}%`); + story.style.setProperty('--teaser-shift', `${-stageWidth * (panels.length - 1)}px`); // Reserve the large viewport once. Collapsing mobile browser chrome must // not rebuild the story or make the last chapter unreachable. story.style.height = `calc(${stageHeight * (panels.length - 1)}px + 100lvh)`; @@ -182,7 +208,10 @@ if (approve) { approve.addEventListener('click', () => { const approved = approve.dataset.approved !== 'true'; approve.dataset.approved = String(approved); - approve.textContent = approved ? 'Replay example ↺' : 'Looks good ✓'; + const buttonLabel = approve.querySelector('[data-approve-label]'); + if (buttonLabel) buttonLabel.textContent = approved ? 'Replay example' : 'Looks good'; + approve.querySelector('[data-approve-check]')!.hidden = approved; + approve.querySelector('[data-approve-replay]')!.hidden = !approved; const label = document.querySelector('.result-label'); if (label) label.textContent = approved ? 'Approved by you. See you Thursday!' : 'Coffee with Priya'; }); diff --git a/src/styles/cloud-teaser.css b/src/styles/cloud-teaser.css index dd33a816..5c51ab1c 100644 --- a/src/styles/cloud-teaser.css +++ b/src/styles/cloud-teaser.css @@ -67,6 +67,7 @@ html:not(.teaser-horizontal) .teaser-header { background: var(--cream); } .plan-result { text-align: center; margin-top: 39px; } .result-label { font-size: 13px; } .plan-result h3 { font-size: clamp(40px,4.2vw,64px); font-weight: 550; letter-spacing: -.05em; margin: 8px 0 16px; } +.result-approve:not([hidden]) { display: inline-flex; align-items: center; justify-content: center; gap: 9px; } .result-approve { border: 0; border-radius: 100px; padding: 12px 23px; min-height: 44px; background: var(--coral); color: var(--cream); font-size: 14px; } .example-disclosure { margin-top: 22px !important; text-align: center; font-size: 11px; line-height: 1.5; color: #6d5c4f; } .panel-handle { background: var(--coral); color: var(--cream); } @@ -105,7 +106,7 @@ html:not(.teaser-horizontal) .teaser-header { background: var(--cream); } .chapter-controls { display: flex; align-items: center; gap: 9px; margin-left: auto; } .chapter-count { display: flex; gap: 12px; margin-right: 12px; font-size: 12px; font-variant-numeric: tabular-nums; } .count-divider { opacity: .5; } -.chapter-controls button { width: 44px; height: 44px; border: 1px solid currentColor; border-radius: 50%; color: inherit; background: transparent; font-size: 22px; } +.chapter-controls button { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid currentColor; border-radius: 50%; color: inherit; background: transparent; font-size: 22px; } html.teaser-horizontal.teaser-snapping { scroll-snap-type: y mandatory; } .teaser-horizontal .teaser-snap-points { display: block; position: absolute; inset: 0; pointer-events: none; } .teaser-horizontal .teaser-snap-point { position: absolute; top: calc(var(--chapter) * var(--teaser-step,100svh)); left: 0; width: 100%; height: 1px; scroll-snap-align: start; scroll-snap-stop: always; } @@ -115,7 +116,7 @@ html.teaser-horizontal.teaser-snapping { scroll-snap-type: y mandatory; } .teaser-horizontal .panel-inner { height: 100%; min-height: 0; } .teaser-horizontal[data-tone='coral'] .teaser-header, .teaser-horizontal[data-tone='coral'] .teaser-controls { color: var(--cream); } .teaser-horizontal[data-tone='coral'] .teaser-start { background: var(--cream); color: var(--ink) !important; } -.teaser-horizontal.teaser-native .teaser-track { animation: teaser-pan 1s linear both; animation-timeline: scroll(root block); animation-range: 0px var(--teaser-distance); } +.teaser-horizontal.teaser-native .teaser-track { animation: teaser-pan linear both; animation-duration: auto; animation-timeline: scroll(root block); animation-range: 0px var(--teaser-distance); } @keyframes teaser-pan { from { transform: translate3d(0,0,0); } to { transform: translate3d(var(--teaser-shift),0,0); } } @media (max-width: 1000px) and (orientation: portrait), (max-width: 760px) { .teaser-header { height: 78px; padding-inline: 24px; }