authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 19:05:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 19:05:58-07:00
logbd280de80a5a39059918757c09af5ae9b7973a97
treecd0d09fed1035c4c973382a1fdd4da0352ce15e9
parentda976cf2932f0f632c6e42f0adb604a7f058ac1c
signature Signed by PGP key B5690EEEBB952194

Revert "fix(marketing): restore continuous endorsement scrolling" (#10454)


4 files changed, 217 insertions(+), 179 deletions(-)

apps/marketing/astro.config.mjs-1
...@@ -4,6 +4,5 @@ export default defineConfig({...@@ -4,6 +4,5 @@ export default defineConfig({
4 site: "https://t3.codes",4 site: "https://t3.codes",
5 server: {5 server: {
6 port: Number(process.env.PORT ?? 4173),6 port: Number(process.env.PORT ?? 4173),
7 allowedHosts: true,
8 },7 },
9});8});
apps/marketing/src/lib/homeMotion.test.ts+90-3
...@@ -6,8 +6,17 @@ class ElementStub extends EventTarget {...@@ -6,8 +6,17 @@ 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));
9 querySelectorAll = () => this.children;15 querySelectorAll = () => this.children;
10 getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 }));16 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 });
11}20}
1221
13let observers: ObserverStub[] = [];22let observers: ObserverStub[] = [];
...@@ -70,11 +79,12 @@ function fixture() {...@@ -70,11 +79,12 @@ function fixture() {
70 const mark = new ElementStub();79 const mark = new ElementStub();
71 const otherMark = new ElementStub();80 const otherMark = new ElementStub();
72 field.children = [mark, otherMark];81 field.children = [mark, otherMark];
82 const endorsements = new ElementStub();
73 const caret = new ElementStub();83 const caret = new ElementStub();
74 dispose = startHomeMotion({ hero, field, caret } as unknown as Parameters<84 dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters<
75 typeof startHomeMotion85 typeof startHomeMotion
76 >[0]);86 >[0]);
77 return { hero, field, mark, otherMark, caret, observer: observers[0]! };87 return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! };
78}88}
7989
80function movePointer(hero: ElementStub, x = 400, y = 600) {90function movePointer(hero: ElementStub, x = 400, y = 600) {
...@@ -123,9 +133,86 @@ describe("homepage motion", () => {...@@ -123,9 +133,86 @@ describe("homepage motion", () => {
123 expect(mark.properties.get("--home-motion-state")).toBe("running");133 expect(mark.properties.get("--home-motion-state")).toBe("running");
124 });134 });
125135
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
126 it("cancels pending work and ignores events after cleanup", () => {212 it("cancels pending work and ignores events after cleanup", () => {
127 const { hero, mark, observer } = fixture();213 const { hero, mark, endorsements, observer } = fixture();
128 observer.report(mark, true);214 observer.report(mark, true);
215 observer.report(endorsements, true);
129 movePointer(hero);216 movePointer(hero);
130 dispose?.();217 dispose?.();
131 observer.report(mark, true);218 observer.report(mark, true);
apps/marketing/src/lib/homeMotion.ts+85-2
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1/** Runs homepage motion only while its content is visible. */1/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */
2export function startHomeMotion({2export function startHomeMotion({
3 hero,3 hero,
4 field,4 field,
5 endorsements,
5 caret,6 caret,
6}: {7}: {
7 hero: HTMLElement;8 hero: HTMLElement;
8 field: HTMLElement;9 field: HTMLElement;
10 endorsements: HTMLElement;
9 caret: HTMLElement;11 caret: HTMLElement;
10}) {12}) {
11 if (typeof IntersectionObserver === "undefined") return () => {};13 if (typeof IntersectionObserver === "undefined") return () => {};
...@@ -17,6 +19,12 @@ export function startHomeMotion({...@@ -17,6 +19,12 @@ export function startHomeMotion({
17 const events = new AbortController();19 const events = new AbortController();
18 const eventOptions = { signal: events.signal };20 const eventOptions = { signal: events.signal };
19 let disposed = false;21 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;
20 let pointerFrame: number | undefined;28 let pointerFrame: number | undefined;
21 let pointer: { x: number; y: number } | null = null;29 let pointer: { x: number; y: number } | null = null;
2230
...@@ -26,6 +34,13 @@ export function startHomeMotion({...@@ -26,6 +34,13 @@ export function startHomeMotion({
26 document.visibilityState === "visible" &&34 document.visibilityState === "visible" &&
27 !reducedMotion.matches;35 !reducedMotion.matches;
28 const canParallax = () => finePointer.matches && marks.some(canMove);36 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
29 function resetPointer() {44 function resetPointer() {
30 if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame);45 if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame);
31 pointerFrame = undefined;46 pointerFrame = undefined;
...@@ -34,6 +49,34 @@ export function startHomeMotion({...@@ -34,6 +49,34 @@ export function startHomeMotion({
34 field.style.setProperty("--py", "0px");49 field.style.setProperty("--py", "0px");
35 }50 }
3651
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
37 function update() {80 function update() {
38 for (const mark of marks) {81 for (const mark of marks) {
39 mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused");82 mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused");
...@@ -42,6 +85,7 @@ export function startHomeMotion({...@@ -42,6 +85,7 @@ export function startHomeMotion({
42 const parallax = canParallax();85 const parallax = canParallax();
43 field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s");86 field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s");
44 if (!parallax) resetPointer();87 if (!parallax) resetPointer();
88 updatePaging();
45 }89 }
4690
47 const observer = new IntersectionObserver((entries) => {91 const observer = new IntersectionObserver((entries) => {
...@@ -52,7 +96,7 @@ export function startHomeMotion({...@@ -52,7 +96,7 @@ export function startHomeMotion({
52 }96 }
53 update();97 update();
54 });98 });
55 for (const element of [...marks, caret]) observer.observe(element);99 for (const element of [...marks, endorsements, caret]) observer.observe(element);
56100
57 hero.addEventListener(101 hero.addEventListener(
58 "pointermove",102 "pointermove",
...@@ -77,6 +121,45 @@ export function startHomeMotion({...@@ -77,6 +121,45 @@ export function startHomeMotion({
77 eventOptions,121 eventOptions,
78 );122 );
79 hero.addEventListener("pointerleave", resetPointer, eventOptions);123 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);
80 document.addEventListener("visibilitychange", update, eventOptions);163 document.addEventListener("visibilitychange", update, eventOptions);
81 window.addEventListener("resize", update, eventOptions);164 window.addEventListener("resize", update, eventOptions);
82 reducedMotion.addEventListener("change", update, eventOptions);165 reducedMotion.addEventListener("change", update, eventOptions);
apps/marketing/src/pages/index.astro+42-173
...@@ -17,16 +17,6 @@ const screenshot = await getImage({...@@ -17,16 +17,6 @@ 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];
30---20---
3121
32<Layout>22<Layout>
...@@ -121,80 +111,29 @@ const mobileEndorsementRows = [...@@ -121,80 +111,29 @@ const mobileEndorsementRows = [
121 </div>111 </div>
122 </div>112 </div>
123113
124 <div class="endorsement-carousel endorsement-carousel--desktop" aria-label="Developer endorsements">114 <div class="endorsement-carousel" role="region" aria-label="Developer endorsements" tabindex="0">
125 {115 {tweets.map((tweet) => (
126 desktopEndorsementRows.map((row, rowIndex) => (116 <a
127 <div class:list={["endorsement-row", rowIndex === 1 && "is-reverse"]}>117 class="endorsement-card"
128 <div class="endorsement-track">118 href={tweet.link}
129 {[0, 1].map((groupIndex) => (119 target="_blank"
130 <div class="endorsement-track-group" aria-hidden={groupIndex === 1 ? "true" : undefined}>120 rel="noopener noreferrer"
131 {row.map((tweet) => (121 aria-label={`Read ${tweet.handle}'s post on X`}
132 <a122 >
133 class="endorsement-card"123 <div class="endorsement-author">
134 href={tweet.link}124 <img
135 target="_blank"125 src={`/pfps/${tweet.handle}.webp`}
136 rel="noopener noreferrer"126 alt=""
137 aria-label={`Read ${tweet.handle}'s post on X`}127 width="28"
138 tabindex={groupIndex === 1 ? "-1" : undefined}128 height="28"
139 >129 loading="lazy"
140 <div class="endorsement-author">130 decoding="async"
141 <img131 />
142 src={`/pfps/${tweet.handle}.webp`}132 <div class="endorsement-handle">@{tweet.handle}</div>
143 alt=""133 </div>
144 width="28"134 <p>{tweet.excerpt ?? tweet.content}</p>
145 height="28"135 </a>
146 loading="lazy"136 ))}
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 }
198 </div>137 </div>
199 </section>138 </section>
200139
...@@ -488,9 +427,10 @@ const mobileEndorsementRows = [...@@ -488,9 +427,10 @@ const mobileEndorsementRows = [
488427
489 const hero = document.querySelector<HTMLElement>(".hero");428 const hero = document.querySelector<HTMLElement>(".hero");
490 const field = document.querySelector<HTMLElement>(".hero-float");429 const field = document.querySelector<HTMLElement>(".hero-float");
430 const endorsements = document.querySelector<HTMLElement>(".endorsement-carousel");
491 const caret = document.querySelector<HTMLElement>(".caret");431 const caret = document.querySelector<HTMLElement>(".caret");
492 if (hero && field && caret) {432 if (hero && field && endorsements && caret) {
493 startHomeMotion({ hero, field, caret });433 startHomeMotion({ hero, field, endorsements, caret });
494 }434 }
495</script>435</script>
496436
...@@ -883,67 +823,22 @@ const mobileEndorsementRows = [...@@ -883,67 +823,22 @@ const mobileEndorsementRows = [
883 }823 }
884824
885 .endorsement-carousel {825 .endorsement-carousel {
886 position: relative;826 display: grid;
887 display: flex;827 grid-auto-flow: column;
888 flex-direction: column;828 grid-template-rows: repeat(2, 132px);
889 gap: 12px;829 grid-auto-columns: clamp(264px, 22vw, 308px);
890 }
891
892 .endorsement-carousel--mobile {
893 display: none;
894 }
895
896 .endorsement-carousel::before,
897 .endorsement-carousel::after {
898 content: "";
899 position: absolute;
900 top: 0;
901 bottom: 0;
902 z-index: 2;
903 width: max(48px, calc((100vw - 1240px) / 2 + 56px));
904 pointer-events: none;
905 }
906
907 .endorsement-carousel::before {
908 left: 0;
909 background: linear-gradient(90deg, var(--bg), transparent);
910 }
911
912 .endorsement-carousel::after {
913 right: 0;
914 background: linear-gradient(270deg, var(--bg), transparent);
915 }
916
917 .endorsement-row {
918 overflow: hidden;
919 }
920
921 .endorsement-track {
922 width: max-content;
923 display: flex;
924 animation: endorsementMarquee 76s linear infinite;
925 will-change: transform;
926 }
927
928 .endorsement-track-group {
929 display: flex;
930 gap: 12px;830 gap: 12px;
931 padding-right: 12px;831 overflow-x: auto;
932 }832 overscroll-behavior-x: contain;
933833 /* Percentages resolve against this element's own box rather than the
934 .endorsement-row.is-reverse .endorsement-track {834 viewport, so the first card lines up with the heading's .container edge
935 animation-direction: reverse;835 even when a classic scrollbar makes 100vw wider than the layout. */
936 animation-duration: 84s;836 padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px));
937 }837 scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px));
938
939 .endorsement-carousel:hover .endorsement-track,
940 .endorsement-carousel:focus-within .endorsement-track {
941 animation-play-state: paused;
942 }838 }
943839
944 .endorsement-card {840 .endorsement-card {
945 position: relative;841 position: relative;
946 width: clamp(264px, 22vw, 308px);
947 height: 132px;842 height: 132px;
948 padding: 14px 16px;843 padding: 14px 16px;
949 display: flex;844 display: flex;
...@@ -1254,20 +1149,6 @@ const mobileEndorsementRows = [...@@ -1254,20 +1149,6 @@ const mobileEndorsementRows = [
1254 margin-bottom: 24px;1149 margin-bottom: 24px;
1255 }1150 }
12561151
1257 @keyframes endorsementMarquee {
1258 from {
1259 transform: translateX(0);
1260 }
1261 to {
1262 transform: translateX(-50%);
1263 }
1264 }
1265
1266 @media (prefers-reduced-motion: reduce) {
1267 .endorsement-track { animation: none; }
1268 .endorsement-row { overflow-x: auto; }
1269 }
1270
1271 /* ── Responsive ────────────────────────────────────────── */1152 /* ── Responsive ────────────────────────────────────────── */
1272 @media (max-width: 960px) {1153 @media (max-width: 960px) {
1273 .hero-preview {1154 .hero-preview {
...@@ -1294,25 +1175,13 @@ const mobileEndorsementRows = [...@@ -1294,25 +1175,13 @@ const mobileEndorsementRows = [
1294 .endorsements-head {1175 .endorsements-head {
1295 margin-bottom: 40px;1176 margin-bottom: 40px;
1296 }1177 }
1297 .endorsement-carousel--desktop {1178 .endorsement-carousel {
1298 display: none;1179 grid-template-rows: repeat(3, 132px);
1299 }1180 grid-auto-columns: 268px;
1300 .endorsement-carousel--mobile {1181 padding-inline: 20px;
1301 display: flex;1182 scroll-padding-inline: 20px;
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;
1312 }1183 }
1313 .endorsement-card {1184 .endorsement-card {
1314 width: 268px;
1315 height: 132px;
1316 padding: 14px;1185 padding: 14px;
1317 }1186 }
1318 .endorsement-card p {1187 .endorsement-card p {