diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..fba55f53aaf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,9 @@ jobs: run: vpr typecheck - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -89,7 +91,9 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts new file mode 100644 index 000000000000..42f7775a0157 --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { startHomeMotion } from "./homeMotion"; + +class ElementStub extends EventTarget { + properties = new Map(); + style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; + children: ElementStub[] = []; + scrollLeft = 0; + scrollWidth = 1_200; + clientWidth = 400; + matches = () => false; + contains = (target: EventTarget | null) => + target === this || (target instanceof ElementStub && this.children.includes(target)); + querySelectorAll = () => this.children; + getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); + scrollTo = vi.fn((options: ScrollToOptions) => { + this.scrollLeft = options.left ?? this.scrollLeft; + }); +} + +let observers: ObserverStub[] = []; +class ObserverStub { + constructor(private readonly callback: IntersectionObserverCallback) { + observers.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + report(target: ElementStub, isIntersecting: boolean) { + this.callback( + [{ target, isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); +let viewport = new EventTarget(); +let reduced = Object.assign(new EventTarget(), { matches: false }); +let fine = Object.assign(new EventTarget(), { matches: true }); +let frames = new Map(); +let dispose: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + observers = []; + frames = new Map(); + page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); + viewport = new EventTarget(); + reduced = Object.assign(new EventTarget(), { matches: false }); + fine = Object.assign(new EventTarget(), { matches: true }); + vi.stubGlobal("document", page); + vi.stubGlobal( + "window", + Object.assign(viewport, { + matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), + }), + ); + vi.stubGlobal("Node", ElementStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function fixture() { + const hero = new ElementStub(); + const field = new ElementStub(); + const mark = new ElementStub(); + const otherMark = new ElementStub(); + field.children = [mark, otherMark]; + const endorsements = new ElementStub(); + const caret = new ElementStub(); + dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< + typeof startHomeMotion + >[0]); + return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; +} + +function movePointer(hero: ElementStub, x = 400, y = 600) { + hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); +} + +describe("homepage motion", () => { + it("gates each mark and caret and batches pointer input into one frame", () => { + const { hero, field, mark, otherMark, caret, observer } = fixture(); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + observer.report(mark, true); + observer.report(caret, true); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("running"); + + movePointer(hero, 100, 100); + movePointer(hero); + expect(frames.size).toBe(1); + expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); + const [id, callback] = [...frames][0]!; + frames.delete(id); + callback(0); + expect(field.properties.get("--px")).toBe("18.0px"); + expect(field.properties.get("--py")).toBe("14.0px"); + + movePointer(hero); + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + expect(frames.size).toBe(0); + expect(field.properties.get("--px")).toBe("0px"); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("paused"); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + reduced.matches = true; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + reduced.matches = false; + fine.matches = false; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + }); + + it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { + const { endorsements, observer } = fixture(); + expect(vi.getTimerCount()).toBe(0); + observer.report(endorsements, true); + vi.advanceTimersByTime(7_999); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(16_001); + expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ + 400, 800, 400, + ]); + expect( + endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), + ).toBe(true); + + endorsements.clientWidth = endorsements.scrollWidth; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + endorsements.clientWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(1); + }); + + it("pauses paging for hover, focus, hidden content, and reduced motion", () => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + const changeVisibility = (visible: boolean) => { + page.visibilityState = visible ? "visible" : "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + }; + const changeMotion = (matches: boolean) => { + reduced.matches = matches; + reduced.dispatchEvent(new Event("change")); + }; + const pauses = [ + [ + () => endorsements.dispatchEvent(new Event("pointerenter")), + () => endorsements.dispatchEvent(new Event("pointerleave")), + ], + [ + () => endorsements.dispatchEvent(new Event("focusin")), + () => + endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), + ], + [() => changeVisibility(false), () => changeVisibility(true)], + [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], + [() => changeMotion(true), () => changeMotion(false)], + ] as const; + for (const [pause, resume] of pauses) { + pause(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(16_000); + resume(); + expect(vi.getTimerCount()).toBe(1); + } + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8_000); + expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); + endorsements.dispatchEvent(new Event("pointerenter")); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event(event)); + observer.report(endorsements, false); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event("pointerleave")); + viewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(60_000); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + }); + + it("cancels pending work and ignores events after cleanup", () => { + const { hero, mark, endorsements, observer } = fixture(); + observer.report(mark, true); + observer.report(endorsements, true); + movePointer(hero); + dispose?.(); + observer.report(mark, true); + movePointer(hero); + reduced.dispatchEvent(new Event("change")); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + }); +}); diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts new file mode 100644 index 000000000000..5322eae4406d --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.ts @@ -0,0 +1,176 @@ +/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ +export function startHomeMotion({ + hero, + field, + endorsements, + caret, +}: { + hero: HTMLElement; + field: HTMLElement; + endorsements: HTMLElement; + caret: HTMLElement; +}) { + if (typeof IntersectionObserver === "undefined") return () => {}; + + const marks = Array.from(field.querySelectorAll(".hero-float-mark")); + const visible = new Set(); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const events = new AbortController(); + const eventOptions = { signal: events.signal }; + let disposed = false; + let hovered = endorsements.matches(":hover"); + let focused = endorsements.contains(document.activeElement); + let userControlled = false; + let direction = 1; + let automaticScroll = false; + let pageTimer: ReturnType | undefined; + let pointerFrame: number | undefined; + let pointer: { x: number; y: number } | null = null; + + const canMove = (element: Element) => + !disposed && + visible.has(element) && + document.visibilityState === "visible" && + !reducedMotion.matches; + const canParallax = () => finePointer.matches && marks.some(canMove); + const canPage = () => + canMove(endorsements) && + !hovered && + !focused && + !userControlled && + endorsements.scrollWidth > endorsements.clientWidth; + + function resetPointer() { + if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pointer = null; + field.style.setProperty("--px", "0px"); + field.style.setProperty("--py", "0px"); + } + + function updatePaging() { + if (canPage()) { + pageTimer ??= setTimeout(advancePage, 8_000); + return; + } + if (pageTimer !== undefined) clearTimeout(pageTimer); + pageTimer = undefined; + if (automaticScroll) { + automaticScroll = false; + endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); + } + } + + function advancePage() { + pageTimer = undefined; + if (!canPage()) return; + const end = endorsements.scrollWidth - endorsements.clientWidth; + const current = endorsements.scrollLeft; + if (current >= end - 1) direction = -1; + else if (current <= 1) direction = 1; + automaticScroll = true; + endorsements.scrollTo({ + left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), + behavior: "smooth", + }); + updatePaging(); + } + + function update() { + for (const mark of marks) { + mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); + } + caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); + const parallax = canParallax(); + field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); + if (!parallax) resetPointer(); + updatePaging(); + } + + const observer = new IntersectionObserver((entries) => { + if (disposed) return; + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + update(); + }); + for (const element of [...marks, endorsements, caret]) observer.observe(element); + + hero.addEventListener( + "pointermove", + (event) => { + if (!canParallax()) return; + pointer = { x: event.clientX, y: event.clientY }; + pointerFrame ??= requestAnimationFrame(() => { + pointerFrame = undefined; + if (!pointer || !canParallax()) return; + const bounds = hero.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + field.style.setProperty( + "--px", + `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, + ); + field.style.setProperty( + "--py", + `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, + ); + }); + }, + eventOptions, + ); + hero.addEventListener("pointerleave", resetPointer, eventOptions); + endorsements.addEventListener( + "pointerenter", + () => { + hovered = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "pointerleave", + () => { + hovered = false; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusin", + () => { + focused = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusout", + (event) => { + focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); + updatePaging(); + }, + eventOptions, + ); + const takeControl = () => { + userControlled = true; + updatePaging(); + }; + endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); + endorsements.addEventListener("pointerdown", takeControl, eventOptions); + endorsements.addEventListener("keydown", takeControl, eventOptions); + document.addEventListener("visibilitychange", update, eventOptions); + window.addEventListener("resize", update, eventOptions); + reducedMotion.addEventListener("change", update, eventOptions); + finePointer.addEventListener("change", update, eventOptions); + update(); + + return () => { + if (disposed) return; + disposed = true; + events.abort(); + observer.disconnect(); + update(); + }; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd28b446ccf1..a4fdc966b9d8 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -355,6 +355,7 @@ const screenshot = await getImage({