| author | |
| committer | |
| log | b22646c312a04033ac9da97d663422b83dbe7d0f |
| tree | aa18566b98a21617c1f74e450a9c577b669c0574 |
| parent | bd280de80a5a39059918757c09af5ae9b7973a97 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>3 files changed, 194 insertions(+), 231 deletions(-)
apps/marketing/src/lib/homeMotion.test.ts+15-101| ... | @@ -6,17 +6,8 @@ class ElementStub extends EventTarget { | ... | @@ -6,17 +6,8 @@ class ElementStub extends EventTarget { |
| 6 | properties = new Map<string, string>(); | 6 | properties = new Map<string, string>(); |
| 7 | style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; | 7 | style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; |
| 8 | children: ElementStub[] = []; | 8 | children: ElementStub[] = []; |
| 9 | scrollLeft = 0; | ||
| 10 | scrollWidth = 1_200; | ||
| 11 | clientWidth = 400; | ||
| 12 | matches = () => false; | ||
| 13 | contains = (target: EventTarget | null) => | ||
| 14 | target === this || (target instanceof ElementStub && this.children.includes(target)); | ||
| 15 | querySelectorAll = () => this.children; | 9 | querySelectorAll = () => this.children; |
| 16 | getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); | 10 | getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); |
| 17 | scrollTo = vi.fn((options: ScrollToOptions) => { | ||
| 18 | this.scrollLeft = options.left ?? this.scrollLeft; | ||
| 19 | }); | ||
| 20 | } | 11 | } |
| 21 | 12 | ||
| 22 | let observers: ObserverStub[] = []; | 13 | let observers: ObserverStub[] = []; |
| ... | @@ -34,29 +25,25 @@ class ObserverStub { | ... | @@ -34,29 +25,25 @@ class ObserverStub { |
| 34 | } | 25 | } |
| 35 | } | 26 | } |
| 36 | 27 | ||
| 37 | let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | 28 | let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); |
| 38 | let viewport = new EventTarget(); | ||
| 39 | let reduced = Object.assign(new EventTarget(), { matches: false }); | 29 | let reduced = Object.assign(new EventTarget(), { matches: false }); |
| 40 | let fine = Object.assign(new EventTarget(), { matches: true }); | 30 | let fine = Object.assign(new EventTarget(), { matches: true }); |
| 41 | let frames = new Map<number, FrameRequestCallback>(); | 31 | let frames = new Map<number, FrameRequestCallback>(); |
| 42 | let dispose: (() => void) | undefined; | 32 | let dispose: (() => void) | undefined; |
| 43 | 33 | ||
| 44 | beforeEach(() => { | 34 | beforeEach(() => { |
| 45 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | ||
| 46 | observers = []; | 35 | observers = []; |
| 47 | frames = new Map(); | 36 | frames = new Map(); |
| 48 | page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | 37 | page = Object.assign(new EventTarget(), { visibilityState: "visible" }); |
| 49 | viewport = new EventTarget(); | ||
| 50 | reduced = Object.assign(new EventTarget(), { matches: false }); | 38 | reduced = Object.assign(new EventTarget(), { matches: false }); |
| 51 | fine = Object.assign(new EventTarget(), { matches: true }); | 39 | fine = Object.assign(new EventTarget(), { matches: true }); |
| 52 | vi.stubGlobal("document", page); | 40 | vi.stubGlobal("document", page); |
| 53 | vi.stubGlobal( | 41 | vi.stubGlobal( |
| 54 | "window", | 42 | "window", |
| 55 | Object.assign(viewport, { | 43 | Object.assign(new EventTarget(), { |
| 56 | matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), | 44 | matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), |
| 57 | }), | 45 | }), |
| 58 | ); | 46 | ); |
| 59 | vi.stubGlobal("Node", ElementStub); | ||
| 60 | vi.stubGlobal("IntersectionObserver", ObserverStub); | 47 | vi.stubGlobal("IntersectionObserver", ObserverStub); |
| 61 | let frameId = 0; | 48 | let frameId = 0; |
| 62 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | 49 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { |
| ... | @@ -69,7 +56,6 @@ beforeEach(() => { | ... | @@ -69,7 +56,6 @@ beforeEach(() => { |
| 69 | afterEach(() => { | 56 | afterEach(() => { |
| 70 | dispose?.(); | 57 | dispose?.(); |
| 71 | dispose = undefined; | 58 | dispose = undefined; |
| 72 | vi.useRealTimers(); | ||
| 73 | vi.unstubAllGlobals(); | 59 | vi.unstubAllGlobals(); |
| 74 | }); | 60 | }); |
| 75 | 61 | ||
| ... | @@ -79,12 +65,12 @@ function fixture() { | ... | @@ -79,12 +65,12 @@ function fixture() { |
| 79 | const mark = new ElementStub(); | 65 | const mark = new ElementStub(); |
| 80 | const otherMark = new ElementStub(); | 66 | const otherMark = new ElementStub(); |
| 81 | field.children = [mark, otherMark]; | 67 | field.children = [mark, otherMark]; |
| 82 | const endorsements = new ElementStub(); | 68 | const track = new ElementStub(); |
| 83 | const caret = new ElementStub(); | 69 | const caret = new ElementStub(); |
| 84 | dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< | 70 | dispose = startHomeMotion({ hero, field, tracks: [track], caret } as unknown as Parameters< |
| 85 | typeof startHomeMotion | 71 | typeof startHomeMotion |
| 86 | >[0]); | 72 | >[0]); |
| 87 | return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; | 73 | return { hero, field, mark, otherMark, track, caret, observer: observers[0]! }; |
| 88 | } | 74 | } |
| 89 | 75 | ||
| 90 | function movePointer(hero: ElementStub, x = 400, y = 600) { | 76 | function movePointer(hero: ElementStub, x = 400, y = 600) { |
| ... | @@ -92,13 +78,16 @@ function movePointer(hero: ElementStub, x = 400, y = 600) { | ... | @@ -92,13 +78,16 @@ function movePointer(hero: ElementStub, x = 400, y = 600) { |
| 92 | } | 78 | } |
| 93 | 79 | ||
| 94 | describe("homepage motion", () => { | 80 | describe("homepage motion", () => { |
| 95 | it("gates each mark and caret and batches pointer input into one frame", () => { | 81 | it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { |
| 96 | const { hero, field, mark, otherMark, caret, observer } = fixture(); | 82 | const { hero, field, mark, otherMark, track, caret, observer } = fixture(); |
| 97 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | 83 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 84 | expect(track.properties.get("--home-motion-state")).toBe("paused"); | ||
| 98 | observer.report(mark, true); | 85 | observer.report(mark, true); |
| 86 | observer.report(track, true); | ||
| 99 | observer.report(caret, true); | 87 | observer.report(caret, true); |
| 100 | expect(mark.properties.get("--home-motion-state")).toBe("running"); | 88 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 101 | expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); | 89 | expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); |
| 90 | expect(track.properties.get("--home-motion-state")).toBe("running"); | ||
| 102 | expect(caret.properties.get("--home-motion-state")).toBe("running"); | 91 | expect(caret.properties.get("--home-motion-state")).toBe("running"); |
| 103 | 92 | ||
| 104 | movePointer(hero, 100, 100); | 93 | movePointer(hero, 100, 100); |
| ... | @@ -117,6 +106,7 @@ describe("homepage motion", () => { | ... | @@ -117,6 +106,7 @@ describe("homepage motion", () => { |
| 117 | expect(frames.size).toBe(0); | 106 | expect(frames.size).toBe(0); |
| 118 | expect(field.properties.get("--px")).toBe("0px"); | 107 | expect(field.properties.get("--px")).toBe("0px"); |
| 119 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | 108 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 109 | expect(track.properties.get("--home-motion-state")).toBe("paused"); | ||
| 120 | expect(caret.properties.get("--home-motion-state")).toBe("paused"); | 110 | expect(caret.properties.get("--home-motion-state")).toBe("paused"); |
| 121 | page.visibilityState = "visible"; | 111 | page.visibilityState = "visible"; |
| 122 | page.dispatchEvent(new Event("visibilitychange")); | 112 | page.dispatchEvent(new Event("visibilitychange")); |
| ... | @@ -133,86 +123,10 @@ describe("homepage motion", () => { | ... | @@ -133,86 +123,10 @@ describe("homepage motion", () => { |
| 133 | expect(mark.properties.get("--home-motion-state")).toBe("running"); | 123 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 134 | }); | 124 | }); |
| 135 | 125 | ||
| 136 | it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { | ||
| 137 | const { endorsements, observer } = fixture(); | ||
| 138 | expect(vi.getTimerCount()).toBe(0); | ||
| 139 | observer.report(endorsements, true); | ||
| 140 | vi.advanceTimersByTime(7_999); | ||
| 141 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | ||
| 142 | vi.advanceTimersByTime(16_001); | ||
| 143 | expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ | ||
| 144 | 400, 800, 400, | ||
| 145 | ]); | ||
| 146 | expect( | ||
| 147 | endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), | ||
| 148 | ).toBe(true); | ||
| 149 | |||
| 150 | endorsements.clientWidth = endorsements.scrollWidth; | ||
| 151 | viewport.dispatchEvent(new Event("resize")); | ||
| 152 | expect(vi.getTimerCount()).toBe(0); | ||
| 153 | expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); | ||
| 154 | endorsements.clientWidth = 400; | ||
| 155 | viewport.dispatchEvent(new Event("resize")); | ||
| 156 | expect(vi.getTimerCount()).toBe(1); | ||
| 157 | }); | ||
| 158 | |||
| 159 | it("pauses paging for hover, focus, hidden content, and reduced motion", () => { | ||
| 160 | const { endorsements, observer } = fixture(); | ||
| 161 | observer.report(endorsements, true); | ||
| 162 | const changeVisibility = (visible: boolean) => { | ||
| 163 | page.visibilityState = visible ? "visible" : "hidden"; | ||
| 164 | page.dispatchEvent(new Event("visibilitychange")); | ||
| 165 | }; | ||
| 166 | const changeMotion = (matches: boolean) => { | ||
| 167 | reduced.matches = matches; | ||
| 168 | reduced.dispatchEvent(new Event("change")); | ||
| 169 | }; | ||
| 170 | const pauses = [ | ||
| 171 | [ | ||
| 172 | () => endorsements.dispatchEvent(new Event("pointerenter")), | ||
| 173 | () => endorsements.dispatchEvent(new Event("pointerleave")), | ||
| 174 | ], | ||
| 175 | [ | ||
| 176 | () => endorsements.dispatchEvent(new Event("focusin")), | ||
| 177 | () => | ||
| 178 | endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), | ||
| 179 | ], | ||
| 180 | [() => changeVisibility(false), () => changeVisibility(true)], | ||
| 181 | [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], | ||
| 182 | [() => changeMotion(true), () => changeMotion(false)], | ||
| 183 | ] as const; | ||
| 184 | for (const [pause, resume] of pauses) { | ||
| 185 | pause(); | ||
| 186 | expect(vi.getTimerCount()).toBe(0); | ||
| 187 | vi.advanceTimersByTime(16_000); | ||
| 188 | resume(); | ||
| 189 | expect(vi.getTimerCount()).toBe(1); | ||
| 190 | } | ||
| 191 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | ||
| 192 | vi.advanceTimersByTime(8_000); | ||
| 193 | expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); | ||
| 194 | endorsements.dispatchEvent(new Event("pointerenter")); | ||
| 195 | expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); | ||
| 196 | expect(vi.getTimerCount()).toBe(0); | ||
| 197 | }); | ||
| 198 | |||
| 199 | it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { | ||
| 200 | const { endorsements, observer } = fixture(); | ||
| 201 | observer.report(endorsements, true); | ||
| 202 | endorsements.dispatchEvent(new Event(event)); | ||
| 203 | observer.report(endorsements, false); | ||
| 204 | observer.report(endorsements, true); | ||
| 205 | endorsements.dispatchEvent(new Event("pointerleave")); | ||
| 206 | viewport.dispatchEvent(new Event("resize")); | ||
| 207 | vi.advanceTimersByTime(60_000); | ||
| 208 | expect(vi.getTimerCount()).toBe(0); | ||
| 209 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | ||
| 210 | }); | ||
| 211 | |||
| 212 | it("cancels pending work and ignores events after cleanup", () => { | 126 | it("cancels pending work and ignores events after cleanup", () => { |
| 213 | const { hero, mark, endorsements, observer } = fixture(); | 127 | const { hero, mark, track, observer } = fixture(); |
| 214 | observer.report(mark, true); | 128 | observer.report(mark, true); |
| 215 | observer.report(endorsements, true); | 129 | observer.report(track, true); |
| 216 | movePointer(hero); | 130 | movePointer(hero); |
| 217 | dispose?.(); | 131 | dispose?.(); |
| 218 | observer.report(mark, true); | 132 | observer.report(mark, true); |
| ... | @@ -220,7 +134,7 @@ describe("homepage motion", () => { | ... | @@ -220,7 +134,7 @@ describe("homepage motion", () => { |
| 220 | reduced.dispatchEvent(new Event("change")); | 134 | reduced.dispatchEvent(new Event("change")); |
| 221 | expect(observer.disconnect).toHaveBeenCalledTimes(1); | 135 | expect(observer.disconnect).toHaveBeenCalledTimes(1); |
| 222 | expect(frames.size).toBe(0); | 136 | expect(frames.size).toBe(0); |
| 223 | expect(vi.getTimerCount()).toBe(0); | ||
| 224 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | 137 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 138 | expect(track.properties.get("--home-motion-state")).toBe("paused"); | ||
| 225 | }); | 139 | }); |
| 226 | }); | 140 | }); |
apps/marketing/src/lib/homeMotion.ts+7-88| ... | @@ -1,30 +1,25 @@ | ... | @@ -1,30 +1,25 @@ |
| 1 | /** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ | 1 | /** Runs homepage motion (marquee, mark drift, caret, parallax) only while its content is visible. */ |
| 2 | export function startHomeMotion({ | 2 | export function startHomeMotion({ |
| 3 | hero, | 3 | hero, |
| 4 | field, | 4 | field, |
| 5 | endorsements, | 5 | tracks, |
| 6 | caret, | 6 | caret, |
| 7 | }: { | 7 | }: { |
| 8 | hero: HTMLElement; | 8 | hero: HTMLElement; |
| 9 | field: HTMLElement; | 9 | field: HTMLElement; |
| 10 | endorsements: HTMLElement; | 10 | tracks: HTMLElement[]; |
| 11 | caret: HTMLElement; | 11 | caret: HTMLElement; |
| 12 | }) { | 12 | }) { |
| 13 | if (typeof IntersectionObserver === "undefined") return () => {}; | 13 | if (typeof IntersectionObserver === "undefined") return () => {}; |
| 14 | 14 | ||
| 15 | const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark")); | 15 | const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark")); |
| 16 | const gated = [...marks, ...tracks, caret]; | ||
| 16 | const visible = new Set<Element>(); | 17 | const visible = new Set<Element>(); |
| 17 | const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); | 18 | const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 18 | const finePointer = window.matchMedia("(pointer: fine)"); | 19 | const finePointer = window.matchMedia("(pointer: fine)"); |
| 19 | const events = new AbortController(); | 20 | const events = new AbortController(); |
| 20 | const eventOptions = { signal: events.signal }; | 21 | const eventOptions = { signal: events.signal }; |
| 21 | let disposed = false; | 22 | let disposed = false; |
| 22 | let hovered = endorsements.matches(":hover"); | ||
| 23 | let focused = endorsements.contains(document.activeElement); | ||
| 24 | let userControlled = false; | ||
| 25 | let direction = 1; | ||
| 26 | let automaticScroll = false; | ||
| 27 | let pageTimer: ReturnType<typeof setTimeout> | undefined; | ||
| 28 | let pointerFrame: number | undefined; | 23 | let pointerFrame: number | undefined; |
| 29 | let pointer: { x: number; y: number } | null = null; | 24 | let pointer: { x: number; y: number } | null = null; |
| 30 | 25 | ||
| ... | @@ -34,12 +29,6 @@ export function startHomeMotion({ | ... | @@ -34,12 +29,6 @@ export function startHomeMotion({ |
| 34 | document.visibilityState === "visible" && | 29 | document.visibilityState === "visible" && |
| 35 | !reducedMotion.matches; | 30 | !reducedMotion.matches; |
| 36 | const canParallax = () => finePointer.matches && marks.some(canMove); | 31 | const canParallax = () => finePointer.matches && marks.some(canMove); |
| 37 | const canPage = () => | ||
| 38 | canMove(endorsements) && | ||
| 39 | !hovered && | ||
| 40 | !focused && | ||
| 41 | !userControlled && | ||
| 42 | endorsements.scrollWidth > endorsements.clientWidth; | ||
| 43 | 32 | ||
| 44 | function resetPointer() { | 33 | function resetPointer() { |
| 45 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); | 34 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); |
| ... | @@ -49,43 +38,13 @@ export function startHomeMotion({ | ... | @@ -49,43 +38,13 @@ export function startHomeMotion({ |
| 49 | field.style.setProperty("--py", "0px"); | 38 | field.style.setProperty("--py", "0px"); |
| 50 | } | 39 | } |
| 51 | 40 | ||
| 52 | function updatePaging() { | ||
| 53 | if (canPage()) { | ||
| 54 | pageTimer ??= setTimeout(advancePage, 8_000); | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | if (pageTimer !== undefined) clearTimeout(pageTimer); | ||
| 58 | pageTimer = undefined; | ||
| 59 | if (automaticScroll) { | ||
| 60 | automaticScroll = false; | ||
| 61 | endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | function advancePage() { | ||
| 66 | pageTimer = undefined; | ||
| 67 | if (!canPage()) return; | ||
| 68 | const end = endorsements.scrollWidth - endorsements.clientWidth; | ||
| 69 | const current = endorsements.scrollLeft; | ||
| 70 | if (current >= end - 1) direction = -1; | ||
| 71 | else if (current <= 1) direction = 1; | ||
| 72 | automaticScroll = true; | ||
| 73 | endorsements.scrollTo({ | ||
| 74 | left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), | ||
| 75 | behavior: "smooth", | ||
| 76 | }); | ||
| 77 | updatePaging(); | ||
| 78 | } | ||
| 79 | |||
| 80 | function update() { | 41 | function update() { |
| 81 | for (const mark of marks) { | 42 | for (const element of gated) { |
| 82 | mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); | 43 | element.style.setProperty("--home-motion-state", canMove(element) ? "running" : "paused"); |
| 83 | } | 44 | } |
| 84 | caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); | ||
| 85 | const parallax = canParallax(); | 45 | const parallax = canParallax(); |
| 86 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); | 46 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); |
| 87 | if (!parallax) resetPointer(); | 47 | if (!parallax) resetPointer(); |
| 88 | updatePaging(); | ||
| 89 | } | 48 | } |
| 90 | 49 | ||
| 91 | const observer = new IntersectionObserver((entries) => { | 50 | const observer = new IntersectionObserver((entries) => { |
| ... | @@ -96,7 +55,7 @@ export function startHomeMotion({ | ... | @@ -96,7 +55,7 @@ export function startHomeMotion({ |
| 96 | } | 55 | } |
| 97 | update(); | 56 | update(); |
| 98 | }); | 57 | }); |
| 99 | for (const element of [...marks, endorsements, caret]) observer.observe(element); | 58 | for (const element of gated) observer.observe(element); |
| 100 | 59 | ||
| 101 | hero.addEventListener( | 60 | hero.addEventListener( |
| 102 | "pointermove", | 61 | "pointermove", |
| ... | @@ -121,47 +80,7 @@ export function startHomeMotion({ | ... | @@ -121,47 +80,7 @@ export function startHomeMotion({ |
| 121 | eventOptions, | 80 | eventOptions, |
| 122 | ); | 81 | ); |
| 123 | hero.addEventListener("pointerleave", resetPointer, eventOptions); | 82 | hero.addEventListener("pointerleave", resetPointer, eventOptions); |
| 124 | endorsements.addEventListener( | ||
| 125 | "pointerenter", | ||
| 126 | () => { | ||
| 127 | hovered = true; | ||
| 128 | updatePaging(); | ||
| 129 | }, | ||
| 130 | eventOptions, | ||
| 131 | ); | ||
| 132 | endorsements.addEventListener( | ||
| 133 | "pointerleave", | ||
| 134 | () => { | ||
| 135 | hovered = false; | ||
| 136 | updatePaging(); | ||
| 137 | }, | ||
| 138 | eventOptions, | ||
| 139 | ); | ||
| 140 | endorsements.addEventListener( | ||
| 141 | "focusin", | ||
| 142 | () => { | ||
| 143 | focused = true; | ||
| 144 | updatePaging(); | ||
| 145 | }, | ||
| 146 | eventOptions, | ||
| 147 | ); | ||
| 148 | endorsements.addEventListener( | ||
| 149 | "focusout", | ||
| 150 | (event) => { | ||
| 151 | focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); | ||
| 152 | updatePaging(); | ||
| 153 | }, | ||
| 154 | eventOptions, | ||
| 155 | ); | ||
| 156 | const takeControl = () => { | ||
| 157 | userControlled = true; | ||
| 158 | updatePaging(); | ||
| 159 | }; | ||
| 160 | endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); | ||
| 161 | endorsements.addEventListener("pointerdown", takeControl, eventOptions); | ||
| 162 | endorsements.addEventListener("keydown", takeControl, eventOptions); | ||
| 163 | document.addEventListener("visibilitychange", update, eventOptions); | 83 | document.addEventListener("visibilitychange", update, eventOptions); |
| 164 | window.addEventListener("resize", update, eventOptions); | ||
| 165 | reducedMotion.addEventListener("change", update, eventOptions); | 84 | reducedMotion.addEventListener("change", update, eventOptions); |
| 166 | finePointer.addEventListener("change", update, eventOptions); | 85 | finePointer.addEventListener("change", update, eventOptions); |
| 167 | update(); | 86 | update(); |
apps/marketing/src/pages/index.astro+172-42| ... | @@ -17,6 +17,16 @@ const screenshot = await getImage({ | ... | @@ -17,6 +17,16 @@ const screenshot = await getImage({ |
| 17 | quality: 90, | 17 | quality: 90, |
| 18 | }); | 18 | }); |
| 19 | 19 | ||
| 20 | const desktopEndorsementRows = [ | ||
| 21 | tweets.filter((_, index) => index % 2 === 0), | ||
| 22 | tweets.filter((_, index) => index % 2 === 1), | ||
| 23 | ]; | ||
| 24 | |||
| 25 | const mobileEndorsementRows = [ | ||
| 26 | tweets.filter((_, index) => index % 3 === 0), | ||
| 27 | tweets.filter((_, index) => index % 3 === 1), | ||
| 28 | tweets.filter((_, index) => index % 3 === 2), | ||
| 29 | ]; | ||
| 20 | --- | 30 | --- |
| 21 | 31 | ||
| 22 | <Layout> | 32 | <Layout> |
| ... | @@ -111,29 +121,80 @@ const screenshot = await getImage({ | ... | @@ -111,29 +121,80 @@ const screenshot = await getImage({ |
| 111 | </div> | 121 | </div> |
| 112 | </div> | 122 | </div> |
| 113 | 123 | ||
| 114 | <div class="endorsement-carousel" role="region" aria-label="Developer endorsements" tabindex="0"> | 124 | <div class="endorsement-carousel endorsement-carousel--desktop" aria-label="Developer endorsements"> |
| 115 | {tweets.map((tweet) => ( | 125 | { |
| 116 | <a | 126 | desktopEndorsementRows.map((row, rowIndex) => ( |
| 117 | class="endorsement-card" | 127 | <div class:list={["endorsement-row", rowIndex === 1 && "is-reverse"]}> |
| 118 | href={tweet.link} | 128 | <div class="endorsement-track"> |
| 119 | target="_blank" | 129 | {[0, 1].map((groupIndex) => ( |
| 120 | rel="noopener noreferrer" | 130 | <div class="endorsement-track-group" aria-hidden={groupIndex === 1 ? "true" : undefined}> |
| 121 | aria-label={`Read ${tweet.handle}'s post on X`} | 131 | {row.map((tweet) => ( |
| 122 | > | 132 | <a |
| 123 | <div class="endorsement-author"> | 133 | class="endorsement-card" |
| 124 | <img | 134 | href={tweet.link} |
| 125 | src={`/pfps/${tweet.handle}.webp`} | 135 | target="_blank" |
| 126 | alt="" | 136 | rel="noopener noreferrer" |
| 127 | width="28" | 137 | aria-label={`Read ${tweet.handle}'s post on X`} |
| 128 | height="28" | 138 | tabindex={groupIndex === 1 ? "-1" : undefined} |
| 129 | loading="lazy" | 139 | > |
| 130 | decoding="async" | 140 | <div class="endorsement-author"> |
| 131 | /> | 141 | <img |
| 132 | <div class="endorsement-handle">@{tweet.handle}</div> | 142 | src={`/pfps/${tweet.handle}.webp`} |
| 133 | </div> | 143 | alt="" |
| 134 | <p>{tweet.excerpt ?? tweet.content}</p> | 144 | width="28" |
| 135 | </a> | 145 | height="28" |
| 136 | ))} | 146 | loading="lazy" |
| 147 | decoding="async" | ||
| 148 | /> | ||
| 149 | <div class="endorsement-handle">@{tweet.handle}</div> | ||
| 150 | </div> | ||
| 151 | <p>{tweet.excerpt ?? tweet.content}</p> | ||
| 152 | </a> | ||
| 153 | ))} | ||
| 154 | </div> | ||
| 155 | ))} | ||
| 156 | </div> | ||
| 157 | </div> | ||
| 158 | )) | ||
| 159 | } | ||
| 160 | </div> | ||
| 161 | |||
| 162 | <div class="endorsement-carousel endorsement-carousel--mobile" aria-label="Developer endorsements"> | ||
| 163 | { | ||
| 164 | mobileEndorsementRows.map((row, rowIndex) => ( | ||
| 165 | <div class:list={["endorsement-row", rowIndex === 1 && "is-reverse"]}> | ||
| 166 | <div class="endorsement-track"> | ||
| 167 | {[0, 1].map((groupIndex) => ( | ||
| 168 | <div class="endorsement-track-group" aria-hidden={groupIndex === 1 ? "true" : undefined}> | ||
| 169 | {row.map((tweet) => ( | ||
| 170 | <a | ||
| 171 | class="endorsement-card" | ||
| 172 | href={tweet.link} | ||
| 173 | target="_blank" | ||
| 174 | rel="noopener noreferrer" | ||
| 175 | aria-label={`Read ${tweet.handle}'s post on X`} | ||
| 176 | tabindex={groupIndex === 1 ? "-1" : undefined} | ||
| 177 | > | ||
| 178 | <div class="endorsement-author"> | ||
| 179 | <img | ||
| 180 | src={`/pfps/${tweet.handle}.webp`} | ||
| 181 | alt="" | ||
| 182 | width="28" | ||
| 183 | height="28" | ||
| 184 | loading="lazy" | ||
| 185 | decoding="async" | ||
| 186 | /> | ||
| 187 | <div class="endorsement-handle">@{tweet.handle}</div> | ||
| 188 | </div> | ||
| 189 | <p>{tweet.excerpt ?? tweet.content}</p> | ||
| 190 | </a> | ||
| 191 | ))} | ||
| 192 | </div> | ||
| 193 | ))} | ||
| 194 | </div> | ||
| 195 | </div> | ||
| 196 | )) | ||
| 197 | } | ||
| 137 | </div> | 198 | </div> |
| 138 | </section> | 199 | </section> |
| 139 | 200 | ||
| ... | @@ -427,10 +488,10 @@ const screenshot = await getImage({ | ... | @@ -427,10 +488,10 @@ const screenshot = await getImage({ |
| 427 | 488 | ||
| 428 | const hero = document.querySelector<HTMLElement>(".hero"); | 489 | const hero = document.querySelector<HTMLElement>(".hero"); |
| 429 | const field = document.querySelector<HTMLElement>(".hero-float"); | 490 | const field = document.querySelector<HTMLElement>(".hero-float"); |
| 430 | const endorsements = document.querySelector<HTMLElement>(".endorsement-carousel"); | 491 | const tracks = Array.from(document.querySelectorAll<HTMLElement>(".endorsement-track")); |
| 431 | const caret = document.querySelector<HTMLElement>(".caret"); | 492 | const caret = document.querySelector<HTMLElement>(".caret"); |
| 432 | if (hero && field && endorsements && caret) { | 493 | if (hero && field && caret) { |
| 433 | startHomeMotion({ hero, field, endorsements, caret }); | 494 | startHomeMotion({ hero, field, tracks, caret }); |
| 434 | } | 495 | } |
| 435 | </script> | 496 | </script> |
| 436 | 497 | ||
| ... | @@ -822,23 +883,81 @@ const screenshot = await getImage({ | ... | @@ -822,23 +883,81 @@ const screenshot = await getImage({ |
| 822 | letter-spacing: -0.005em; | 883 | letter-spacing: -0.005em; |
| 823 | } | 884 | } |
| 824 | 885 | ||
| 886 | /* Two marquee rows scrolling in opposite directions. Each track holds the | ||
| 887 | row twice so a -50% translate loops seamlessly. The animation only runs | ||
| 888 | while a track is on screen (see homeMotion), and pauses on hover/focus. */ | ||
| 825 | .endorsement-carousel { | 889 | .endorsement-carousel { |
| 826 | display: grid; | 890 | position: relative; |
| 827 | grid-auto-flow: column; | 891 | display: flex; |
| 828 | grid-template-rows: repeat(2, 132px); | 892 | flex-direction: column; |
| 829 | grid-auto-columns: clamp(264px, 22vw, 308px); | ||
| 830 | gap: 12px; | 893 | gap: 12px; |
| 831 | overflow-x: auto; | 894 | } |
| 832 | overscroll-behavior-x: contain; | 895 | |
| 833 | /* Percentages resolve against this element's own box rather than the | 896 | .endorsement-carousel--mobile { |
| 834 | viewport, so the first card lines up with the heading's .container edge | 897 | display: none; |
| 835 | even when a classic scrollbar makes 100vw wider than the layout. */ | 898 | } |
| 836 | padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); | 899 | |
| 837 | scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); | 900 | .endorsement-carousel::before, |
| 901 | .endorsement-carousel::after { | ||
| 902 | content: ""; | ||
| 903 | position: absolute; | ||
| 904 | top: 0; | ||
| 905 | bottom: 0; | ||
| 906 | z-index: 2; | ||
| 907 | width: max(48px, calc((100vw - 1240px) / 2 + 56px)); | ||
| 908 | pointer-events: none; | ||
| 909 | } | ||
| 910 | |||
| 911 | .endorsement-carousel::before { | ||
| 912 | left: 0; | ||
| 913 | background: linear-gradient(90deg, var(--bg), transparent); | ||
| 914 | } | ||
| 915 | |||
| 916 | .endorsement-carousel::after { | ||
| 917 | right: 0; | ||
| 918 | background: linear-gradient(270deg, var(--bg), transparent); | ||
| 919 | } | ||
| 920 | |||
| 921 | .endorsement-row { | ||
| 922 | overflow: hidden; | ||
| 923 | } | ||
| 924 | |||
| 925 | .endorsement-track { | ||
| 926 | width: max-content; | ||
| 927 | display: flex; | ||
| 928 | animation: endorsementMarquee 76s linear infinite; | ||
| 929 | animation-play-state: var(--home-motion-state, paused); | ||
| 930 | will-change: transform; | ||
| 931 | } | ||
| 932 | |||
| 933 | .endorsement-track-group { | ||
| 934 | display: flex; | ||
| 935 | gap: 12px; | ||
| 936 | padding-right: 12px; | ||
| 937 | } | ||
| 938 | |||
| 939 | .endorsement-row.is-reverse .endorsement-track { | ||
| 940 | animation-direction: reverse; | ||
| 941 | animation-duration: 84s; | ||
| 942 | } | ||
| 943 | |||
| 944 | .endorsement-carousel:hover .endorsement-track, | ||
| 945 | .endorsement-carousel:focus-within .endorsement-track { | ||
| 946 | animation-play-state: paused; | ||
| 947 | } | ||
| 948 | |||
| 949 | @media (prefers-reduced-motion: reduce) { | ||
| 950 | .endorsement-track { animation: none; } | ||
| 951 | } | ||
| 952 | |||
| 953 | @keyframes endorsementMarquee { | ||
| 954 | from { transform: translateX(0); } | ||
| 955 | to { transform: translateX(-50%); } | ||
| 838 | } | 956 | } |
| 839 | 957 | ||
| 840 | .endorsement-card { | 958 | .endorsement-card { |
| 841 | position: relative; | 959 | position: relative; |
| 960 | width: clamp(264px, 22vw, 308px); | ||
| 842 | height: 132px; | 961 | height: 132px; |
| 843 | padding: 14px 16px; | 962 | padding: 14px 16px; |
| 844 | display: flex; | 963 | display: flex; |
| ... | @@ -1175,13 +1294,24 @@ const screenshot = await getImage({ | ... | @@ -1175,13 +1294,24 @@ const screenshot = await getImage({ |
| 1175 | .endorsements-head { | 1294 | .endorsements-head { |
| 1176 | margin-bottom: 40px; | 1295 | margin-bottom: 40px; |
| 1177 | } | 1296 | } |
| 1178 | .endorsement-carousel { | 1297 | .endorsement-carousel--desktop { |
| 1179 | grid-template-rows: repeat(3, 132px); | 1298 | display: none; |
| 1180 | grid-auto-columns: 268px; | 1299 | } |
| 1181 | padding-inline: 20px; | 1300 | .endorsement-carousel--mobile { |
| 1182 | scroll-padding-inline: 20px; | 1301 | display: flex; |
| 1302 | } | ||
| 1303 | .endorsement-carousel::before, | ||
| 1304 | .endorsement-carousel::after { | ||
| 1305 | width: 28px; | ||
| 1306 | } | ||
| 1307 | .endorsement-track { | ||
| 1308 | animation-duration: 68s; | ||
| 1309 | } | ||
| 1310 | .endorsement-row.is-reverse .endorsement-track { | ||
| 1311 | animation-duration: 76s; | ||
| 1183 | } | 1312 | } |
| 1184 | .endorsement-card { | 1313 | .endorsement-card { |
| 1314 | width: 268px; | ||
| 1185 | padding: 14px; | 1315 | padding: 14px; |
| 1186 | } | 1316 | } |
| 1187 | .endorsement-card p { | 1317 | .endorsement-card p { |