authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 19:09:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 02:09:22+00:00
logb22646c312a04033ac9da97d663422b83dbe7d0f
treeaa18566b98a21617c1f74e450a9c577b669c0574
parentbd280de80a5a39059918757c09af5ae9b7973a97
signature Signed by PGP key B5690EEEBB952194

fix(marketing): bring back the endorsement marquee (#10455)

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}
2112
22let observers: ObserverStub[] = [];13let observers: ObserverStub[] = [];
...@@ -34,29 +25,25 @@ class ObserverStub {...@@ -34,29 +25,25 @@ class ObserverStub {
34 }25 }
35}26}
3627
37let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null });28let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
38let viewport = new EventTarget();
39let reduced = Object.assign(new EventTarget(), { matches: false });29let reduced = Object.assign(new EventTarget(), { matches: false });
40let fine = Object.assign(new EventTarget(), { matches: true });30let fine = Object.assign(new EventTarget(), { matches: true });
41let frames = new Map<number, FrameRequestCallback>();31let frames = new Map<number, FrameRequestCallback>();
42let dispose: (() => void) | undefined;32let dispose: (() => void) | undefined;
4333
44beforeEach(() => {34beforeEach(() => {
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(() => {
69afterEach(() => {56afterEach(() => {
70 dispose?.();57 dispose?.();
71 dispose = undefined;58 dispose = undefined;
72 vi.useRealTimers();
73 vi.unstubAllGlobals();59 vi.unstubAllGlobals();
74});60});
7561
...@@ -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 startHomeMotion71 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}
8975
90function movePointer(hero: ElementStub, x = 400, y = 600) {76function 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}
9379
94describe("homepage motion", () => {80describe("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");
10392
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 });
135125
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. */
2export function startHomeMotion({2export 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 () => {};
1414
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;
3025
...@@ -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;
4332
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 }
5140
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 }
9049
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);
10059
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});
1919
20const desktopEndorsementRows = [
21 tweets.filter((_, index) => index % 2 === 0),
22 tweets.filter((_, index) => index % 2 === 1),
23];
24
25const 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---
2131
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>
113123
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 <a126 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 <img134 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>
139200
...@@ -427,10 +488,10 @@ const screenshot = await getImage({...@@ -427,10 +488,10 @@ const screenshot = await getImage({
427488
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>
436497
...@@ -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 }
824885
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 the896 .endorsement-carousel--mobile {
834 viewport, so the first card lines up with the heading's .container edge897 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 }
839957
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 {