diff --git a/web/src/components/3d/ViewerPanel.test.tsx b/web/src/components/3d/ViewerPanel.test.tsx
index d9aaeb1d0..267cd2281 100644
--- a/web/src/components/3d/ViewerPanel.test.tsx
+++ b/web/src/components/3d/ViewerPanel.test.tsx
@@ -2,7 +2,7 @@ import { hydrateRoot } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { useAppStore } from '@/store/useAppStore';
+import { useAppStore, type HomeTabType } from '@/store/useAppStore';
import ViewerPanel from './ViewerPanel';
// The home page's 3D view is the build globe (hero, Inside) or the product
@@ -45,11 +45,12 @@ afterEach(() => {
vi.useRealTimers();
});
-// The tabs beside the view, which must outlive it.
-function Page() {
+// The tabs beside the view, which must outlive it. The page passes the tab
+// it was made for, as HomeView does.
+function Page({ tab = 'hero' }: { tab?: HomeTabType }) {
return (
<>
-
+
>
);
@@ -71,7 +72,7 @@ describe('ViewerPanel', () => {
(tab) => {
webgl.answer = false;
useAppStore.setState({ homeTab: tab });
- render();
+ render();
expect(scenes.made).toEqual([]);
expect(screen.getByText(LINE)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument();
@@ -82,7 +83,7 @@ describe('ViewerPanel', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
scenes.throws.add('product');
useAppStore.setState({ homeTab: 'pattern' });
- render();
+ render();
expect(screen.queryByTestId('scene')).not.toBeInTheDocument();
expect(screen.getByText(LINE)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument();
@@ -92,7 +93,7 @@ describe('ViewerPanel', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
scenes.throws.add('product');
useAppStore.setState({ homeTab: 'build' });
- render();
+ render();
expect(screen.getByText(LINE)).toBeInTheDocument();
act(() => useAppStore.setState({ homeTab: 'inside' }));
act(() => vi.advanceTimersByTime(200));
@@ -100,9 +101,46 @@ describe('ViewerPanel', () => {
expect(screen.queryByText(LINE)).not.toBeInTheDocument();
});
- it('sends the page as it was from the server, and the browser without WebGL takes it up without a mismatch', async () => {
+ // The store says hero until RightPanel's effect tells it otherwise. Read
+ // from it, /build opened on the globe and kept it through the fade: long
+ // enough to mount it and send for every pin's photos.
+ it.each(['build', 'pattern'] as const)('opens /%s on the product and never makes the globe', (tab) => {
+ render();
+ expect(screen.getByTestId('scene')).toHaveTextContent('product');
+ // RightPanel's effect, telling the store the tab the page opened on.
+ act(() => useAppStore.getState().setHomeTab(tab));
+ act(() => vi.advanceTimersByTime(200));
+ expect(scenes.made).not.toContain('globe');
+ expect(screen.getByTestId('scene')).toHaveTextContent('product');
+ });
+
+ it('opens /pattern on the product when the store still holds Inside from an earlier visit', () => {
+ useAppStore.setState({ homeTab: 'inside' });
+ render();
+ act(() => useAppStore.getState().setHomeTab('pattern'));
+ act(() => vi.advanceTimersByTime(200));
+ expect(scenes.made).not.toContain('globe');
+ });
+
+ it('follows the store once it changes, from whichever tab the page opened on', () => {
+ render();
+ act(() => useAppStore.getState().setHomeTab('build'));
+ act(() => useAppStore.getState().setHomeTab('inside'));
+ act(() => vi.advanceTimersByTime(200));
+ expect(screen.getByTestId('scene')).toHaveTextContent('globe');
+ act(() => useAppStore.getState().setHomeTab('hero'));
+ expect(screen.getByTestId('scene')).toHaveTextContent('globe');
+ });
+
+ it('drops down on a phone from the first render of /build, as the panel beside it does', () => {
+ const html = renderToString();
+ expect(html).toContain('viewer-panel is-open');
+ expect(renderToString()).not.toContain('is-open');
+ });
+
+ it.each(['hero', 'build'] as const)('sends %s as it was from the server, and the browser without WebGL takes it up without a mismatch', async (tab) => {
webgl.answer = false;
- const html = renderToString();
+ const html = renderToString();
// The server cannot ask: it answers yes and never calls the probe.
expect(webgl.asked).toBe(0);
expect(html).not.toContain('draw the 3D view');
@@ -111,7 +149,7 @@ describe('ViewerPanel', () => {
container.innerHTML = html;
document.body.appendChild(container);
const recoverable = vi.fn();
- const root = await act(async () => hydrateRoot(container, , { onRecoverableError: recoverable }));
+ const root = await act(async () => hydrateRoot(container, , { onRecoverableError: recoverable }));
expect(recoverable).not.toHaveBeenCalled();
expect(container).toHaveTextContent(LINE);
expect(container.querySelector('[data-testid="scene"]')).toBeNull();
diff --git a/web/src/components/3d/ViewerPanel.tsx b/web/src/components/3d/ViewerPanel.tsx
index 792880b17..56382336c 100644
--- a/web/src/components/3d/ViewerPanel.tsx
+++ b/web/src/components/3d/ViewerPanel.tsx
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic';
import { Component, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import HeroScene from './HeroScene';
-import { useAppStore } from '@/store/useAppStore';
+import { useAppStore, type HomeTabType } from '@/store/useAppStore';
import { hasWebGL } from '@/lib/webgl';
// The hero and the Inside section show the build globe; Build and Pattern
@@ -41,8 +41,16 @@ class SceneGuard extends Component<{ children: ReactNode }, { failed: boolean }>
// On desktop the panel is always shown (sticky left column). On mobile it stays
// hidden while the hero is full-screen and only "drops down" once a section tab
// (Build / Pattern / Inside) is opened from the bottom nav.
-export default function ViewerPanel() {
- const homeTab = useAppStore((state) => state.homeTab);
+export default function ViewerPanel({ initialTab = 'hero' }: { initialTab?: HomeTabType }) {
+ // The store starts on the hero on every page and hears the page's tab from
+ // RightPanel's effect, after this panel has rendered once. Read from the
+ // store, /build and /pattern opened on the globe for the length of the
+ // fade, long enough to mount it and send for every pin's photos. So the
+ // panel starts on the tab the page was made for and follows the store from
+ // its first change. This effect runs before RightPanel's: siblings mount in
+ // order, and HomeView puts this panel first.
+ const [homeTab, setHomeTab] = useState(initialTab);
+ useEffect(() => useAppStore.subscribe((state) => setHomeTab(state.homeTab)), []);
const isOpen = homeTab !== 'hero';
// The server cannot ask for a context, so it answers yes and sends the page
// as it was. The client asks while hydrating, before either scene's canvas
diff --git a/web/src/components/HomeView.tsx b/web/src/components/HomeView.tsx
index 9f031874f..bf1505a57 100644
--- a/web/src/components/HomeView.tsx
+++ b/web/src/components/HomeView.tsx
@@ -23,7 +23,7 @@ export default function HomeView({ initialTab = 'hero', initialBuildId = null }:
-
+ ({ default: () => }));
+
+const media = vi.hoisted(() => ({ wide: false }));
+const TILES = builds.flatMap((build) => (build.images ?? []).slice(0, Math.max(STRIP_TILES, CARD_TILES)));
+
+beforeEach(() => {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ media: query,
+ matches: query === '(min-width: 901px)' && media.wide,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }));
+ useAppStore.setState({ homeTab: 'hero', selectedBuildId: null, insideFilter: 'all' });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+const warmed = (container: HTMLElement) => container.querySelectorAll('.preload img').length;
+
+describe('GlobeViewer', () => {
+ it('warms no photo for the hidden globe of a phone’s hero', () => {
+ media.wide = false;
+ const { container, getByTestId } = render();
+ expect(getByTestId('globe')).toBeInTheDocument();
+ expect(warmed(container)).toBe(0);
+ });
+
+ it('warms every pin’s opening photos once a phone opens Inside', () => {
+ media.wide = false;
+ const { container } = render();
+ act(() => useAppStore.getState().setHomeTab('inside'));
+ expect(TILES.length).toBeGreaterThan(0);
+ expect(warmed(container)).toBe(TILES.length);
+ });
+
+ it('warms them on the hero from 901px up, where the globe stands beside the page', () => {
+ media.wide = true;
+ const { container } = render();
+ expect(warmed(container)).toBe(TILES.length);
+ });
+});
diff --git a/web/src/components/sections/InsideGlobe/GlobeViewer.tsx b/web/src/components/sections/InsideGlobe/GlobeViewer.tsx
index 565c1518d..d3afc810c 100644
--- a/web/src/components/sections/InsideGlobe/GlobeViewer.tsx
+++ b/web/src/components/sections/InsideGlobe/GlobeViewer.tsx
@@ -9,6 +9,7 @@ import PinLink from './PinLink';
import { builds, formatBuildDate, CARD_TILES, STRIP_TILES } from './builds';
import { useBuildSelection } from './useBuildSelection';
import { useAppStore } from '@/store/useAppStore';
+import { useMediaQuery } from '@/lib/hooks/useMediaQuery';
import styles from './GlobeViewer.module.css';
// The Inside section's viewer: an interactive globe with the picked build's
@@ -22,7 +23,15 @@ export default function GlobeViewer() {
const { selectedId, select: selectBuild, visibleBuilds } = useBuildSelection();
// On the hero the globe stands in for the product shot: no filters, and a
// pin opens the Inside section rather than a detail card beside hero copy.
- const onHero = useAppStore((state) => state.homeTab) === 'hero';
+ const homeTab = useAppStore((state) => state.homeTab);
+ const onHero = homeTab === 'hero';
+ // Below 901px the 3D view is hidden on the hero (globals.css) and drops
+ // down only for a tab, so a phone shows the globe on Inside alone. The
+ // photos below are warmed only where the globe is seen: a phone's hidden
+ // hero globe sent for all of them, in the large variant a 3× screen picks.
+ // The query reads false until measured, so a phone never renders them.
+ const wide = useMediaQuery('(min-width: 901px)');
+ const warm = wide || homeTab === 'inside';
const [galleryIndex, setGalleryIndex] = useState(null);
const [hasSelected, setHasSelected] = useState(false);
@@ -70,17 +79,19 @@ export default function GlobeViewer() {
return (
{/* Offscreen preloader: fetch each pin's opening tiles (the same optimized
- variant the strip and card use) eagerly the moment the globe mounts, so
+ variant the strip and card use) eagerly the moment the globe is seen, so
opening a pin shows its photos instantly instead of lazy-loading. Low
fetch priority so they queue behind the globe itself rather than
competing with it for bandwidth on first paint. */}
-