| author | |
| committer | |
| log | da976cf2932f0f632c6e42f0adb604a7f058ac1c |
| tree | 24ae777bcf746d16cdcf1b0b5e0a9e7c8fb904e2 |
| parent | 52b2bf77a941740b4d979f3f4cae1c2851c14a69 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 179 insertions(+), 217 deletions(-)
apps/marketing/astro.config.mjs+1| ... | ... | @@ -4,5 +4,6 @@ export default defineConfig({ |
| 4 | 4 | site: "https://t3.codes", |
| 5 | 5 | server: { |
| 6 | 6 | port: Number(process.env.PORT ?? 4173), |
| 7 | allowedHosts: true, | |
| 7 | 8 | }, |
| 8 | 9 | }); |
apps/marketing/src/lib/homeMotion.test.ts+3-90| ... | ... | @@ -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[] = []; |
| ... | ... | @@ -79,12 +70,11 @@ function fixture() { |
| 79 | 70 | const mark = new ElementStub(); |
| 80 | 71 | const otherMark = new ElementStub(); |
| 81 | 72 | field.children = [mark, otherMark]; |
| 82 | const endorsements = new ElementStub(); | |
| 83 | 73 | const caret = new ElementStub(); |
| 84 | dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< | |
| 74 | dispose = startHomeMotion({ hero, field, caret } as unknown as Parameters< | |
| 85 | 75 | typeof startHomeMotion |
| 86 | 76 | >[0]); |
| 87 | return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; | |
| 77 | return { hero, field, mark, otherMark, caret, observer: observers[0]! }; | |
| 88 | 78 | } |
| 89 | 79 | |
| 90 | 80 | function movePointer(hero: ElementStub, x = 400, y = 600) { |
| ... | ... | @@ -133,86 +123,9 @@ 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, observer } = fixture(); | |
| 214 | 128 | observer.report(mark, true); |
| 215 | observer.report(endorsements, true); | |
| 216 | 129 | movePointer(hero); |
| 217 | 130 | dispose?.(); |
| 218 | 131 | observer.report(mark, true); |
apps/marketing/src/lib/homeMotion.ts+2-85| ... | ... | @@ -1,13 +1,11 @@ |
| 1 | /** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ | |
| 1 | /** Runs homepage motion only while its content is visible. */ | |
| 2 | 2 | export function startHomeMotion({ |
| 3 | 3 | hero, |
| 4 | 4 | field, |
| 5 | endorsements, | |
| 6 | 5 | caret, |
| 7 | 6 | }: { |
| 8 | 7 | hero: HTMLElement; |
| 9 | 8 | field: HTMLElement; |
| 10 | endorsements: HTMLElement; | |
| 11 | 9 | caret: HTMLElement; |
| 12 | 10 | }) { |
| 13 | 11 | if (typeof IntersectionObserver === "undefined") return () => {}; |
| ... | ... | @@ -19,12 +17,6 @@ export function startHomeMotion({ |
| 19 | 17 | const events = new AbortController(); |
| 20 | 18 | const eventOptions = { signal: events.signal }; |
| 21 | 19 | 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 | 20 | let pointerFrame: number | undefined; |
| 29 | 21 | let pointer: { x: number; y: number } | null = null; |
| 30 | 22 | |
| ... | ... | @@ -34,13 +26,6 @@ export function startHomeMotion({ |
| 34 | 26 | document.visibilityState === "visible" && |
| 35 | 27 | !reducedMotion.matches; |
| 36 | 28 | 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 | ||
| 44 | 29 | function resetPointer() { |
| 45 | 30 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); |
| 46 | 31 | pointerFrame = undefined; |
| ... | ... | @@ -49,34 +34,6 @@ export function startHomeMotion({ |
| 49 | 34 | field.style.setProperty("--py", "0px"); |
| 50 | 35 | } |
| 51 | 36 | |
| 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 | 37 | function update() { |
| 81 | 38 | for (const mark of marks) { |
| 82 | 39 | mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); |
| ... | ... | @@ -85,7 +42,6 @@ export function startHomeMotion({ |
| 85 | 42 | const parallax = canParallax(); |
| 86 | 43 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); |
| 87 | 44 | if (!parallax) resetPointer(); |
| 88 | updatePaging(); | |
| 89 | 45 | } |
| 90 | 46 | |
| 91 | 47 | const observer = new IntersectionObserver((entries) => { |
| ... | ... | @@ -96,7 +52,7 @@ export function startHomeMotion({ |
| 96 | 52 | } |
| 97 | 53 | update(); |
| 98 | 54 | }); |
| 99 | for (const element of [...marks, endorsements, caret]) observer.observe(element); | |
| 55 | for (const element of [...marks, caret]) observer.observe(element); | |
| 100 | 56 | |
| 101 | 57 | hero.addEventListener( |
| 102 | 58 | "pointermove", |
| ... | ... | @@ -121,45 +77,6 @@ export function startHomeMotion({ |
| 121 | 77 | eventOptions, |
| 122 | 78 | ); |
| 123 | 79 | 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 | 80 | document.addEventListener("visibilitychange", update, eventOptions); |
| 164 | 81 | window.addEventListener("resize", update, eventOptions); |
| 165 | 82 | reducedMotion.addEventListener("change", update, eventOptions); |
apps/marketing/src/pages/index.astro+173-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,9 @@ 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"); | |
| 431 | 491 | const caret = document.querySelector<HTMLElement>(".caret"); |
| 432 | if (hero && field && endorsements && caret) { | |
| 433 | startHomeMotion({ hero, field, endorsements, caret }); | |
| 492 | if (hero && field && caret) { | |
| 493 | startHomeMotion({ hero, field, caret }); | |
| 434 | 494 | } |
| 435 | 495 | </script> |
| 436 | 496 | |
| ... | ... | @@ -823,22 +883,67 @@ const screenshot = await getImage({ |
| 823 | 883 | } |
| 824 | 884 | |
| 825 | 885 | .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); | |
| 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; | |
| 830 | 930 | 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)); | |
| 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; | |
| 838 | 942 | } |
| 839 | 943 | |
| 840 | 944 | .endorsement-card { |
| 841 | 945 | position: relative; |
| 946 | width: clamp(264px, 22vw, 308px); | |
| 842 | 947 | height: 132px; |
| 843 | 948 | padding: 14px 16px; |
| 844 | 949 | display: flex; |
| ... | ... | @@ -1149,6 +1254,20 @@ const screenshot = await getImage({ |
| 1149 | 1254 | margin-bottom: 24px; |
| 1150 | 1255 | } |
| 1151 | 1256 | |
| 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 | ||
| 1152 | 1271 | /* ── Responsive ────────────────────────────────────────── */ |
| 1153 | 1272 | @media (max-width: 960px) { |
| 1154 | 1273 | .hero-preview { |
| ... | ... | @@ -1175,13 +1294,25 @@ 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; | |
| 1315 | height: 132px; | |
| 1185 | 1316 | padding: 14px; |
| 1186 | 1317 | } |
| 1187 | 1318 | .endorsement-card p { |