Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions web/src/components/sections/Hero.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAnchorElement> & { href: string; children: ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

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(<Hero />);
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(<Hero />);
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(<Hero />);
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(<Hero />);
expect(html).toContain('/product_v2.jpg');
expect(html).not.toContain('<iframe');
});
});
63 changes: 43 additions & 20 deletions web/src/components/sections/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useSyncExternalStore } from 'react';
import HeroJournalLink from "@/components/journal/HeroJournalLink";
import { captureEvent } from "@/lib/posthogEvents";
import { CROWD_SUPPLY_URL } from "@/lib/crowdSupply";
Expand All @@ -10,7 +10,22 @@ import { CROWD_SUPPLY_URL } from "@/lib/crowdSupply";
// said as "around", because it is an estimate.
const PEOPLE_WHO_MADE_ONE = 40;

export default function Hero() {
// The embed starts by itself and has no control to stop it, so someone who has
// asked their system for less motion gets the poster and no embed at all. The
// server cannot know, so it sends the poster alone and the client adds the
// video after hydration.
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
function subscribeReducedMotion(onChange: () => 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(() => {
Expand All @@ -30,6 +45,31 @@ export default function Hero() {
};
}, []);

return (
<iframe
src="https://www.youtube.com/embed/OXt-yg_7qdk?autoplay=1&mute=1&controls=0&modestbranding=1&disablekb=1&playsinline=1&rel=0"
title="Patternflow Demo Video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'block',
pointerEvents: 'none',
opacity: isVideoVisible ? 1 : 0,
transition: 'opacity 0.6s ease-in-out',
}}
/>
);
}

export default function Hero() {
const reducedMotion = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, getReducedMotionServer);

return (
<section className="hero">
<HeroJournalLink />
Expand Down Expand Up @@ -66,24 +106,7 @@ export default function Hero() {
display: 'block'
}}
/>
<iframe
src="https://www.youtube.com/embed/OXt-yg_7qdk?autoplay=1&mute=1&controls=0&modestbranding=1&disablekb=1&playsinline=1&rel=0"
title="Patternflow Demo Video"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'block',
pointerEvents: 'none',
opacity: isVideoVisible ? 1 : 0,
transition: 'opacity 0.6s ease-in-out',
}}
/>
{!reducedMotion && <HeroVideo />}
</div>
{/* L3 — the strongest line we have, per the manifesto: a description of
how the system works, with nothing in it to disbelieve. */}
Expand Down
93 changes: 93 additions & 0 deletions web/src/components/sections/RightPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { SectionContent } from '@/lib/content';
import RightPanel from './RightPanel';

// The section tabs on /, /build, /pattern and /inside. The desktop column and
// the phone's bar are two navs with one label, and CSS shows one at a time;
// either way the open tab reads as pressed, its ✕ stays out of its name, and
// the panel under them is the page's main landmark.

vi.mock('./Hero', () => ({ default: () => <section>hero</section> }));
vi.mock('./Milestones', () => ({ default: () => null }));
vi.mock('./Sponsor', () => ({ default: () => null }));
vi.mock('../layout/Footer', () => ({ default: () => null }));
vi.mock('./BuildPanel', () => ({ default: () => <section>build panel</section> }));
vi.mock('./PatternPanel', () => ({ default: () => <section>pattern panel</section> }));
vi.mock('./InsidePanel', () => ({ default: () => <section>inside panel</section> }));
vi.mock('@/lib/posthogEvents', () => ({ captureEvent: () => {} }));

const content: SectionContent = { title: '', subtitle: '', content: '' };

beforeEach(() => {
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}));
vi.stubGlobal('scrollTo', () => {});
});

afterEach(() => {
vi.unstubAllGlobals();
});

function renderAt(path: string, tab: 'hero' | 'build' | 'pattern' | 'inside') {
window.history.replaceState(null, '', path);
return render(
<RightPanel initialTab={tab} buildContent={content} patternContent={content} insideContent={content} />,
);
}

const pressed = (nav: HTMLElement) =>
within(nav)
.getAllByRole('button')
.filter((b) => b.getAttribute('aria-pressed') === 'true')
.map((b) => b.textContent?.replace('✕', ''));

describe('RightPanel', () => {
it('names both tab rows Sections and marks the open tab pressed in each', () => {
renderAt('/build', 'build');
const navs = screen.getAllByRole('navigation', { name: 'Sections' });
expect(navs).toHaveLength(2);
for (const nav of navs) {
expect(pressed(nav)).toEqual(['Build']);
for (const name of ['Pattern', 'Inside']) {
expect(within(nav).getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false');
}
}
});

it('keeps the ✕ on the open desktop tab out of its name', () => {
renderAt('/build', 'build');
const [desktop] = screen.getAllByRole('navigation', { name: 'Sections' });
const build = within(desktop).getByRole('button', { name: 'Build' });
expect(build).toHaveTextContent('✕');
expect(build).toHaveAttribute('aria-pressed', 'true');
});

it('moves pressed with a click, and a second click on the open tab closes it', () => {
renderAt('/', 'hero');
const [desktop, phone] = screen.getAllByRole('navigation', { name: 'Sections' });
expect(pressed(desktop)).toEqual([]);

fireEvent.click(within(desktop).getByRole('button', { name: 'Pattern' }));
expect(pressed(desktop)).toEqual(['Pattern']);
expect(pressed(phone)).toEqual(['Pattern']);
expect(window.location.pathname).toBe('/pattern');

fireEvent.click(within(desktop).getByRole('button', { name: 'Pattern' }));
expect(pressed(desktop)).toEqual([]);
expect(window.location.pathname).toBe('/');
});

it('puts the panels in one main, outside the tabs', () => {
renderAt('/inside', 'inside');
const main = screen.getByRole('main');
expect(main).toHaveTextContent('inside panel');
for (const nav of screen.getAllByRole('navigation', { name: 'Sections' })) {
expect(main.contains(nav)).toBe(false);
}
});
});
22 changes: 14 additions & 8 deletions web/src/components/sections/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function tabFromPath(pathname: string): TabType {
export default function RightPanel({ initialTab = 'hero', buildContent, patternContent, insideContent }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
const [buildPanelKey, setBuildPanelKey] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLElement>(null);
const activeTabRef = useRef<TabType>(initialTab);

// Apply a tab change to local + global state (no URL update).
Expand Down Expand Up @@ -130,32 +130,38 @@ export default function RightPanel({ initialTab = 'hero', buildContent, patternC

return (
<div className="right-panel-layout">
<div className="vertical-nav">
{/* The desktop column. It carries the phone bar's label: CSS shows one
of the two, so a screen reader meets one Sections nav either way. The
open tab is pressed, as on the phone, and its ✕ is only drawn. */}
<nav className="vertical-nav" aria-label="Sections">
<button
className={`v-tab-btn ${activeTab === 'build' ? 'active' : ''}`}
aria-pressed={activeTab === 'build'}
onClick={() => handleTabClick('build')}
>
{activeTab === 'build' && <span className="close-icon">✕</span>}
{activeTab === 'build' && <span className="close-icon" aria-hidden="true">✕</span>}
<span className="tab-text" data-mobile-label="1. Build">Build</span>
</button>

<button
className={`v-tab-btn ${activeTab === 'pattern' ? 'active' : ''}`}
aria-pressed={activeTab === 'pattern'}
onClick={() => handleTabClick('pattern')}
>
{activeTab === 'pattern' && <span className="close-icon">✕</span>}
{activeTab === 'pattern' && <span className="close-icon" aria-hidden="true">✕</span>}
<span className="tab-text" data-mobile-label="2. Pattern">Pattern</span>
</button>
<button
className={`v-tab-btn ${activeTab === 'inside' ? 'active' : ''}`}
aria-pressed={activeTab === 'inside'}
onClick={() => handleTabClick('inside')}
>
{activeTab === 'inside' && <span className="close-icon">✕</span>}
{activeTab === 'inside' && <span className="close-icon" aria-hidden="true">✕</span>}
<span className="tab-text" data-mobile-label="3. Inside">Inside</span>
</button>
</div>
</nav>

<div className={`content-panel ${activeTab !== 'hero' ? 'bg-white viewer-open' : ''}`} ref={contentRef}>
<main className={`content-panel ${activeTab !== 'hero' ? 'bg-white viewer-open' : ''}`} ref={contentRef}>
<div className="deck-content">
{/* The panels not on screen are only faded and folded away, so they
are inert too: out of the tab order and the accessibility tree.
Expand All @@ -177,7 +183,7 @@ export default function RightPanel({ initialTab = 'hero', buildContent, patternC
<InsidePanel content={insideContent} />
</div>
</div>
</div>
</main>

{/* Mobile-only bottom nav. Tapping a section drops the 3D viewer down from
the top; tapping the active one again returns to the full-screen hero. */}
Expand Down
Loading