| author | |
| committer | |
| log | bd280de80a5a39059918757c09af5ae9b7973a97 |
| tree | cd0d09fed1035c4c973382a1fdd4da0352ce15e9 |
| parent | da976cf2932f0f632c6e42f0adb604a7f058ac1c |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 217 insertions(+), 179 deletions(-)
apps/marketing/astro.config.mjs-1| ... | ... | @@ -4,6 +4,5 @@ export default defineConfig({ |
| 4 | 4 | site: "https://t3.codes", |
| 5 | 5 | server: { |
| 6 | 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 | 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)); | |
| 9 | 15 | querySelectorAll = () => this.children; |
| 10 | 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 | } |
| 12 | 21 | |
| 13 | 22 | let observers: ObserverStub[] = []; |
| ... | ... | @@ -70,11 +79,12 @@ function fixture() { |
| 70 | 79 | const mark = new ElementStub(); |
| 71 | 80 | const otherMark = new ElementStub(); |
| 72 | 81 | field.children = [mark, otherMark]; |
| 82 | const endorsements = new ElementStub(); | |
| 73 | 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 | 85 | typeof startHomeMotion |
| 76 | 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 | } |
| 79 | 89 | |
| 80 | 90 | function movePointer(hero: ElementStub, x = 400, y = 600) { |
| ... | ... | @@ -123,9 +133,86 @@ describe("homepage motion", () => { |
| 123 | 133 | expect(mark.properties.get("--home-motion-state")).toBe("running"); |
| 124 | 134 | }); |
| 125 | 135 | |
| 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 | 212 | it("cancels pending work and ignores events after cleanup", () => { |
| 127 | const { hero, mark, observer } = fixture(); | |
| 213 | const { hero, mark, endorsements, observer } = fixture(); | |
| 128 | 214 | observer.report(mark, true); |
| 215 | observer.report(endorsements, true); | |
| 129 | 216 | movePointer(hero); |
| 130 | 217 | dispose?.(); |
| 131 | 218 | observer.report(mark, true); |
apps/marketing/src/lib/homeMotion.ts+85-2| ... | ... | @@ -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. */ | |
| 2 | 2 | export function startHomeMotion({ |
| 3 | 3 | hero, |
| 4 | 4 | field, |
| 5 | endorsements, | |
| 5 | 6 | caret, |
| 6 | 7 | }: { |
| 7 | 8 | hero: HTMLElement; |
| 8 | 9 | field: HTMLElement; |
| 10 | endorsements: HTMLElement; | |
| 9 | 11 | caret: HTMLElement; |
| 10 | 12 | }) { |
| 11 | 13 | if (typeof IntersectionObserver === "undefined") return () => {}; |
| ... | ... | @@ -17,6 +19,12 @@ export function startHomeMotion({ |
| 17 | 19 | const events = new AbortController(); |
| 18 | 20 | const eventOptions = { signal: events.signal }; |
| 19 | 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 | 28 | let pointerFrame: number | undefined; |
| 21 | 29 | let pointer: { x: number; y: number } | null = null; |
| 22 | 30 | |
| ... | ... | @@ -26,6 +34,13 @@ export function startHomeMotion({ |
| 26 | 34 | document.visibilityState === "visible" && |
| 27 | 35 | !reducedMotion.matches; |
| 28 | 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 | 44 | function resetPointer() { |
| 30 | 45 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); |
| 31 | 46 | pointerFrame = undefined; |
| ... | ... | @@ -34,6 +49,34 @@ export function startHomeMotion({ |
| 34 | 49 | field.style.setProperty("--py", "0px"); |
| 35 | 50 | } |
| 36 | 51 | |
| 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 | 80 | function update() { |
| 38 | 81 | for (const mark of marks) { |
| 39 | 82 | mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); |
| ... | ... | @@ -42,6 +85,7 @@ export function startHomeMotion({ |
| 42 | 85 | const parallax = canParallax(); |
| 43 | 86 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); |
| 44 | 87 | if (!parallax) resetPointer(); |
| 88 | updatePaging(); | |
| 45 | 89 | } |
| 46 | 90 | |
| 47 | 91 | const observer = new IntersectionObserver((entries) => { |
| ... | ... | @@ -52,7 +96,7 @@ export function startHomeMotion({ |
| 52 | 96 | } |
| 53 | 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); | |
| 56 | 100 | |
| 57 | 101 | hero.addEventListener( |
| 58 | 102 | "pointermove", |
| ... | ... | @@ -77,6 +121,45 @@ export function startHomeMotion({ |
| 77 | 121 | eventOptions, |
| 78 | 122 | ); |
| 79 | 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 | 163 | document.addEventListener("visibilitychange", update, eventOptions); |
| 81 | 164 | window.addEventListener("resize", update, eventOptions); |
| 82 | 165 | reducedMotion.addEventListener("change", update, eventOptions); |
apps/marketing/src/pages/index.astro+42-173| ... | ... | @@ -17,16 +17,6 @@ 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 | ]; | |
| 30 | 20 | --- |
| 31 | 21 | |
| 32 | 22 | <Layout> |
| ... | ... | @@ -121,80 +111,29 @@ const mobileEndorsementRows = [ |
| 121 | 111 | </div> |
| 122 | 112 | </div> |
| 123 | 113 | |
| 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 | } | |
| 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 | ))} | |
| 198 | 137 | </div> |
| 199 | 138 | </section> |
| 200 | 139 | |
| ... | ... | @@ -488,9 +427,10 @@ const mobileEndorsementRows = [ |
| 488 | 427 | |
| 489 | 428 | const hero = document.querySelector<HTMLElement>(".hero"); |
| 490 | 429 | const field = document.querySelector<HTMLElement>(".hero-float"); |
| 430 | const endorsements = document.querySelector<HTMLElement>(".endorsement-carousel"); | |
| 491 | 431 | const caret = document.querySelector<HTMLElement>(".caret"); |
| 492 | if (hero && field && caret) { | |
| 493 | startHomeMotion({ hero, field, caret }); | |
| 432 | if (hero && field && endorsements && caret) { | |
| 433 | startHomeMotion({ hero, field, endorsements, caret }); | |
| 494 | 434 | } |
| 495 | 435 | </script> |
| 496 | 436 | |
| ... | ... | @@ -883,67 +823,22 @@ const mobileEndorsementRows = [ |
| 883 | 823 | } |
| 884 | 824 | |
| 885 | 825 | .endorsement-carousel { |
| 886 | position: relative; | |
| 887 | display: flex; | |
| 888 | flex-direction: column; | |
| 889 | gap: 12px; | |
| 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; | |
| 826 | display: grid; | |
| 827 | grid-auto-flow: column; | |
| 828 | grid-template-rows: repeat(2, 132px); | |
| 829 | grid-auto-columns: clamp(264px, 22vw, 308px); | |
| 930 | 830 | gap: 12px; |
| 931 | padding-right: 12px; | |
| 932 | } | |
| 933 | ||
| 934 | .endorsement-row.is-reverse .endorsement-track { | |
| 935 | animation-direction: reverse; | |
| 936 | animation-duration: 84s; | |
| 937 | } | |
| 938 | ||
| 939 | .endorsement-carousel:hover .endorsement-track, | |
| 940 | .endorsement-carousel:focus-within .endorsement-track { | |
| 941 | animation-play-state: paused; | |
| 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)); | |
| 942 | 838 | } |
| 943 | 839 | |
| 944 | 840 | .endorsement-card { |
| 945 | 841 | position: relative; |
| 946 | width: clamp(264px, 22vw, 308px); | |
| 947 | 842 | height: 132px; |
| 948 | 843 | padding: 14px 16px; |
| 949 | 844 | display: flex; |
| ... | ... | @@ -1254,20 +1149,6 @@ const mobileEndorsementRows = [ |
| 1254 | 1149 | margin-bottom: 24px; |
| 1255 | 1150 | } |
| 1256 | 1151 | |
| 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 | 1152 | /* ── Responsive ────────────────────────────────────────── */ |
| 1272 | 1153 | @media (max-width: 960px) { |
| 1273 | 1154 | .hero-preview { |
| ... | ... | @@ -1294,25 +1175,13 @@ const mobileEndorsementRows = [ |
| 1294 | 1175 | .endorsements-head { |
| 1295 | 1176 | margin-bottom: 40px; |
| 1296 | 1177 | } |
| 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; | |
| 1178 | .endorsement-carousel { | |
| 1179 | grid-template-rows: repeat(3, 132px); | |
| 1180 | grid-auto-columns: 268px; | |
| 1181 | padding-inline: 20px; | |
| 1182 | scroll-padding-inline: 20px; | |
| 1312 | 1183 | } |
| 1313 | 1184 | .endorsement-card { |
| 1314 | width: 268px; | |
| 1315 | height: 132px; | |
| 1316 | 1185 | padding: 14px; |
| 1317 | 1186 | } |
| 1318 | 1187 | .endorsement-card p { |