| 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 | 6 | properties = new Map<string, string>(); |
| 7 | 7 | style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; |
| 8 | 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 | 9 | querySelectorAll = () => this.children; |
| 16 | 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 | 13 | let observers: ObserverStub[] = []; |
| ... | ... | @@ -34,29 +25,25 @@ class ObserverStub { |
| 34 | 25 | } |
| 35 | 26 | } |
| 36 | 27 | |
| 37 | let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | |
| 38 | let viewport = new EventTarget(); | |
| 28 | let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); | |
| 39 | 29 | let reduced = Object.assign(new EventTarget(), { matches: false }); |
| 40 | 30 | let fine = Object.assign(new EventTarget(), { matches: true }); |
| 41 | 31 | let frames = new Map<number, FrameRequestCallback>(); |
| 42 | 32 | let dispose: (() => void) | undefined; |
| 43 | 33 | |
| 44 | 34 | beforeEach(() => { |
| 45 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | |
| 46 | 35 | observers = []; |
| 47 | 36 | frames = new Map(); |
| 48 | page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | |
| 49 | viewport = new EventTarget(); | |
| 37 | page = Object.assign(new EventTarget(), { visibilityState: "visible" }); | |
| 50 | 38 | reduced = Object.assign(new EventTarget(), { matches: false }); |
| 51 | 39 | fine = Object.assign(new EventTarget(), { matches: true }); |
| 52 | 40 | vi.stubGlobal("document", page); |
| 53 | 41 | vi.stubGlobal( |
| 54 | 42 | "window", |
| 55 | Object.assign(viewport, { | |
| 43 | Object.assign(new EventTarget(), { | |
| 56 | 44 | matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), |
| 57 | 45 | }), |
| 58 | 46 | ); |
| 59 | vi.stubGlobal("Node", ElementStub); | |
| 60 | 47 | vi.stubGlobal("IntersectionObserver", ObserverStub); |
| 61 | 48 | let frameId = 0; |
| 62 | 49 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { |
| ... | ... | @@ -69,7 +56,6 @@ beforeEach(() => { |
| 69 | 56 | afterEach(() => { |
| 70 | 57 | dispose?.(); |
| 71 | 58 | dispose = undefined; |
| 72 | vi.useRealTimers(); | |
| 73 | 59 | vi.unstubAllGlobals(); |
| 74 | 60 | }); |
| 75 | 61 | |
| ... | ... | @@ -79,12 +65,12 @@ function fixture() { |
| 79 | 65 | const mark = new ElementStub(); |
| 80 | 66 | const otherMark = new ElementStub(); |
| 81 | 67 | field.children = [mark, otherMark]; |
| 82 | const endorsements = new ElementStub(); | |
| 68 | const track = new ElementStub(); | |
| 83 | 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 | 71 | typeof startHomeMotion |
| 86 | 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 | 76 | 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 | 80 | describe("homepage motion", () => { |
| 95 | it("gates each mark and caret and batches pointer input into one frame", () => { | |
| 96 | const { hero, field, mark, otherMark, caret, observer } = fixture(); | |
| 81 | it("gates each mark, marquee track, and caret and batches pointer input into one frame", () => { | |
| 82 | const { hero, field, mark, otherMark, track, caret, observer } = fixture(); | |
| 97 | 83 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 84 | expect(track.properties.get("--home-motion-state")).toBe("paused"); | |
| 98 | 85 | observer.report(mark, true); |
| 86 | observer.report(track, true); | |
| 99 | 87 | observer.report(caret, true); |
| 100 | 88 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 101 | 89 | expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); |
| 90 | expect(track.properties.get("--home-motion-state")).toBe("running"); | |
| 102 | 91 | expect(caret.properties.get("--home-motion-state")).toBe("running"); |
| 103 | 92 | |
| 104 | 93 | movePointer(hero, 100, 100); |
| ... | ... | @@ -117,6 +106,7 @@ describe("homepage motion", () => { |
| 117 | 106 | expect(frames.size).toBe(0); |
| 118 | 107 | expect(field.properties.get("--px")).toBe("0px"); |
| 119 | 108 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); |
| 109 | expect(track.properties.get("--home-motion-state")).toBe("paused"); | |
| 120 | 110 | expect(caret.properties.get("--home-motion-state")).toBe("paused"); |
| 121 | 111 | page.visibilityState = "visible"; |
| 122 | 112 | page.dispatchEvent(new Event("visibilitychange")); |
| ... | ... | @@ -133,86 +123,10 @@ describe("homepage motion", () => { |
| 133 | 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 | 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 | 128 | observer.report(mark, true); |
| 215 | observer.report(endorsements, true); | |
| 129 | observer.report(track, true); | |
| 216 | 130 | movePointer(hero); |
| 217 | 131 | dispose?.(); |
| 218 | 132 | observer.report(mark, true); |
| ... | ... | @@ -220,7 +134,7 @@ describe("homepage motion", () => { |
| 220 | 134 | reduced.dispatchEvent(new Event("change")); |
| 221 | 135 | expect(observer.disconnect).toHaveBeenCalledTimes(1); |
| 222 | 136 | expect(frames.size).toBe(0); |
| 223 | expect(vi.getTimerCount()).toBe(0); | |
| 224 | 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 | /** 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 | 2 | export function startHomeMotion({ |
| 3 | 3 | hero, |
| 4 | 4 | field, |
| 5 | endorsements, | |
| 5 | tracks, | |
| 6 | 6 | caret, |
| 7 | 7 | }: { |
| 8 | 8 | hero: HTMLElement; |
| 9 | 9 | field: HTMLElement; |
| 10 | endorsements: HTMLElement; | |
| 10 | tracks: HTMLElement[]; | |
| 11 | 11 | caret: HTMLElement; |
| 12 | 12 | }) { |
| 13 | 13 | if (typeof IntersectionObserver === "undefined") return () => {}; |
| 14 | 14 | |
| 15 | 15 | const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark")); |
| 16 | const gated = [...marks, ...tracks, caret]; | |
| 16 | 17 | const visible = new Set<Element>(); |
| 17 | 18 | const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 18 | 19 | const finePointer = window.matchMedia("(pointer: fine)"); |
| 19 | 20 | const events = new AbortController(); |
| 20 | 21 | const eventOptions = { signal: events.signal }; |
| 21 | 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 | 23 | let pointerFrame: number | undefined; |
| 29 | 24 | let pointer: { x: number; y: number } | null = null; |
| 30 | 25 | |
| ... | ... | @@ -34,12 +29,6 @@ export function startHomeMotion({ |
| 34 | 29 | document.visibilityState === "visible" && |
| 35 | 30 | !reducedMotion.matches; |
| 36 | 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 | 33 | function resetPointer() { |
| 45 | 34 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); |
| ... | ... | @@ -49,43 +38,13 @@ export function startHomeMotion({ |
| 49 | 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 | 41 | function update() { |
| 81 | for (const mark of marks) { | |
| 82 | mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); | |
| 42 | for (const element of gated) { | |
| 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 | 45 | const parallax = canParallax(); |
| 86 | 46 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); |
| 87 | 47 | if (!parallax) resetPointer(); |
| 88 | updatePaging(); | |
| 89 | 48 | } |
| 90 | 49 | |
| 91 | 50 | const observer = new IntersectionObserver((entries) => { |
| ... | ... | @@ -96,7 +55,7 @@ export function startHomeMotion({ |
| 96 | 55 | } |
| 97 | 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 | 60 | hero.addEventListener( |
| 102 | 61 | "pointermove", |
| ... | ... | @@ -121,47 +80,7 @@ export function startHomeMotion({ |
| 121 | 80 | eventOptions, |
| 122 | 81 | ); |
| 123 | 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 | 83 | document.addEventListener("visibilitychange", update, eventOptions); |
| 164 | window.addEventListener("resize", update, eventOptions); | |
| 165 | 84 | reducedMotion.addEventListener("change", update, eventOptions); |
| 166 | 85 | finePointer.addEventListener("change", update, eventOptions); |
| 167 | 86 | update(); |
apps/marketing/src/pages/index.astro+172-42| ... | ... | @@ -17,6 +17,16 @@ const screenshot = await getImage({ |
| 17 | 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 | 32 | <Layout> |
| ... | ... | @@ -111,29 +121,80 @@ const screenshot = await getImage({ |
| 111 | 121 | </div> |
| 112 | 122 | </div> |
| 113 | 123 | |
| 114 | <div class="endorsement-carousel" role="region" aria-label="Developer endorsements" tabindex="0"> | |
| 115 | {tweets.map((tweet) => ( | |
| 116 | <a | |
| 117 | class="endorsement-card" | |
| 118 | href={tweet.link} | |
| 119 | target="_blank" | |
| 120 | rel="noopener noreferrer" | |
| 121 | aria-label={`Read ${tweet.handle}'s post on X`} | |
| 122 | > | |
| 123 | <div class="endorsement-author"> | |
| 124 | <img | |
| 125 | src={`/pfps/${tweet.handle}.webp`} | |
| 126 | alt="" | |
| 127 | width="28" | |
| 128 | height="28" | |
| 129 | loading="lazy" | |
| 130 | decoding="async" | |
| 131 | /> | |
| 132 | <div class="endorsement-handle">@{tweet.handle}</div> | |
| 133 | </div> | |
| 134 | <p>{tweet.excerpt ?? tweet.content}</p> | |
| 135 | </a> | |
| 136 | ))} | |
| 124 | <div class="endorsement-carousel endorsement-carousel--desktop" aria-label="Developer endorsements"> | |
| 125 | { | |
| 126 | desktopEndorsementRows.map((row, rowIndex) => ( | |
| 127 | <div class:list={["endorsement-row", rowIndex === 1 && "is-reverse"]}> | |
| 128 | <div class="endorsement-track"> | |
| 129 | {[0, 1].map((groupIndex) => ( | |
| 130 | <div class="endorsement-track-group" aria-hidden={groupIndex === 1 ? "true" : undefined}> | |
| 131 | {row.map((tweet) => ( | |
| 132 | <a | |
| 133 | class="endorsement-card" | |
| 134 | href={tweet.link} | |
| 135 | target="_blank" | |
| 136 | rel="noopener noreferrer" | |
| 137 | aria-label={`Read ${tweet.handle}'s post on X`} | |
| 138 | tabindex={groupIndex === 1 ? "-1" : undefined} | |
| 139 | > | |
| 140 | <div class="endorsement-author"> | |
| 141 | <img | |
| 142 | src={`/pfps/${tweet.handle}.webp`} | |
| 143 | alt="" | |
| 144 | width="28" | |
| 145 | height="28" | |
| 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 | 198 | </div> |
| 138 | 199 | </section> |
| 139 | 200 | |
| ... | ... | @@ -427,10 +488,10 @@ const screenshot = await getImage({ |
| 427 | 488 | |
| 428 | 489 | const hero = document.querySelector<HTMLElement>(".hero"); |
| 429 | 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 | 492 | const caret = document.querySelector<HTMLElement>(".caret"); |
| 432 | if (hero && field && endorsements && caret) { | |
| 433 | startHomeMotion({ hero, field, endorsements, caret }); | |
| 493 | if (hero && field && caret) { | |
| 494 | startHomeMotion({ hero, field, tracks, caret }); | |
| 434 | 495 | } |
| 435 | 496 | </script> |
| 436 | 497 | |
| ... | ... | @@ -822,23 +883,81 @@ const screenshot = await getImage({ |
| 822 | 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 | 889 | .endorsement-carousel { |
| 826 | display: grid; | |
| 827 | grid-auto-flow: column; | |
| 828 | grid-template-rows: repeat(2, 132px); | |
| 829 | grid-auto-columns: clamp(264px, 22vw, 308px); | |
| 890 | position: relative; | |
| 891 | display: flex; | |
| 892 | flex-direction: column; | |
| 830 | 893 | gap: 12px; |
| 831 | overflow-x: auto; | |
| 832 | overscroll-behavior-x: contain; | |
| 833 | /* Percentages resolve against this element's own box rather than the | |
| 834 | viewport, so the first card lines up with the heading's .container edge | |
| 835 | even when a classic scrollbar makes 100vw wider than the layout. */ | |
| 836 | padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); | |
| 837 | scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); | |
| 894 | } | |
| 895 | ||
| 896 | .endorsement-carousel--mobile { | |
| 897 | display: none; | |
| 898 | } | |
| 899 | ||
| 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 | 958 | .endorsement-card { |
| 841 | 959 | position: relative; |
| 960 | width: clamp(264px, 22vw, 308px); | |
| 842 | 961 | height: 132px; |
| 843 | 962 | padding: 14px 16px; |
| 844 | 963 | display: flex; |
| ... | ... | @@ -1175,13 +1294,24 @@ const screenshot = await getImage({ |
| 1175 | 1294 | .endorsements-head { |
| 1176 | 1295 | margin-bottom: 40px; |
| 1177 | 1296 | } |
| 1178 | .endorsement-carousel { | |
| 1179 | grid-template-rows: repeat(3, 132px); | |
| 1180 | grid-auto-columns: 268px; | |
| 1181 | padding-inline: 20px; | |
| 1182 | scroll-padding-inline: 20px; | |
| 1297 | .endorsement-carousel--desktop { | |
| 1298 | display: none; | |
| 1299 | } | |
| 1300 | .endorsement-carousel--mobile { | |
| 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 | 1313 | .endorsement-card { |
| 1314 | width: 268px; | |
| 1185 | 1315 | padding: 14px; |
| 1186 | 1316 | } |
| 1187 | 1317 | .endorsement-card p { |