From 4a0c311460609439a4bf76f507c62411f4fe3e27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:58:49 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20pf=206?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Di3xhBuUF8X45hwNhYnSUG From 4df9d61e091516813305dc07023bd43f7b0bc3ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:04:40 +0000 Subject: [PATCH 2/4] web: hero video yields to reduced motion; section tabs read as pressed The embed autoplayed with no way to stop it, and the desktop tabs gave a screen reader neither a nav, the open tab, nor a main landmark to skip to. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Di3xhBuUF8X45hwNhYnSUG --- web/src/components/sections/Hero.test.tsx | 103 ++++++++++++++++++ web/src/components/sections/Hero.tsx | 63 +++++++---- .../components/sections/RightPanel.test.tsx | 93 ++++++++++++++++ web/src/components/sections/RightPanel.tsx | 22 ++-- 4 files changed, 253 insertions(+), 28 deletions(-) create mode 100644 web/src/components/sections/Hero.test.tsx create mode 100644 web/src/components/sections/RightPanel.test.tsx diff --git a/web/src/components/sections/Hero.test.tsx b/web/src/components/sections/Hero.test.tsx new file mode 100644 index 000000000..5a2212e39 --- /dev/null +++ b/web/src/components/sections/Hero.test.tsx @@ -0,0 +1,103 @@ +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render } from '@testing-library/react'; +import Hero from './Hero'; + +// The hero's video is a YouTube embed that starts by itself and has no control +// to stop it. Someone whose system asks for reduced motion gets the poster +// under it and no embed at all, and the server, which cannot know, sends the +// poster alone. + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...rest + }: AnchorHTMLAttributes & { href: string; children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock('@/lib/posthogEvents', () => ({ captureEvent: () => {} })); + +const REDUCE = '(prefers-reduced-motion: reduce)'; + +// One reduced-motion setting for the page, which a test can flip the way the +// system settings would, firing change on every query that listens. +let reduce = false; +const listeners = new Set<() => void>(); +function setReduce(next: boolean) { + reduce = next; + act(() => listeners.forEach((fn) => fn())); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + reduce = false; + listeners.clear(); + vi.stubGlobal('matchMedia', (query: string) => ({ + media: query, + get matches() { + return query === REDUCE ? reduce : false; + }, + addEventListener: (_: string, fn: () => void) => listeners.add(fn), + removeEventListener: (_: string, fn: () => void) => listeners.delete(fn), + })); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const poster = (root: ParentNode) => root.querySelector('img[src="/product_v2.jpg"]'); +const video = (root: ParentNode) => root.querySelector('iframe'); + +describe('Hero', () => { + it('plays the video over the poster, hidden while the player loads', () => { + const { container } = render(); + expect(poster(container)).not.toBeNull(); + expect(video(container)?.getAttribute('src')).toContain('youtube.com/embed/'); + expect(video(container)?.style.opacity).toBe('0'); + + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + + act(() => vi.advanceTimersByTime(89000)); + expect(video(container)?.style.opacity).toBe('0'); + }); + + it('shows the poster alone when reduced motion is on', () => { + reduce = true; + const { container } = render(); + expect(poster(container)).not.toBeNull(); + expect(video(container)).toBeNull(); + + act(() => vi.advanceTimersByTime(10000)); + expect(video(container)).toBeNull(); + }); + + it('drops the video when reduced motion turns on, and starts it hidden again when it turns off', () => { + const { container } = render(); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + + setReduce(true); + expect(video(container)).toBeNull(); + expect(poster(container)).not.toBeNull(); + + setReduce(false); + expect(video(container)?.style.opacity).toBe('0'); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + }); + + it('sends the poster without the embed from the server', () => { + const html = renderToString(); + expect(html).toContain('/product_v2.jpg'); + expect(html).not.toContain(' void) { + const mq = window.matchMedia(REDUCED_MOTION); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); +} +const getReducedMotion = () => window.matchMedia(REDUCED_MOTION).matches; +const getReducedMotionServer = () => true; + +// Mounted only while motion is welcome. Turning reduced motion off again +// mounts a fresh one, which hides for its first three seconds like the first. +function HeroVideo() { const [isVideoVisible, setIsVideoVisible] = useState(false); useEffect(() => { @@ -30,6 +45,31 @@ export default function Hero() { }; }, []); + return ( +