Skip to content
Draft
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
9 changes: 6 additions & 3 deletions apps/docsite/scripts/template-image-loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import {existsSync} from 'node:fs';
import {join} from 'node:path';
import {mapFixtureRefs} from '../../../packages/cli/foundation/discovery/template-adapter.mjs';
import {
OPTIMIZED_IMAGE_QUALITY,
optimizedImageUrl,
} from '../src/lib/optimizedImageUrl.mjs';

// The optimizer does not enlarge, so this width keeps the pixel size of every
// image that is not wider. It must be a width the optimizer allows
// (`images.deviceSizes`); the optimizer answers 400 for any other.
export const TEMPLATE_IMAGE_WIDTH = 1920;
// Must be in `images.qualities`.
export const TEMPLATE_IMAGE_QUALITY = 75;
export const TEMPLATE_IMAGE_QUALITY = OPTIMIZED_IMAGE_QUALITY;

// The formats the optimizer converts. It refuses a vector image.
const OPTIMIZED_FORMAT = /\.(?:png|jpe?g)$/iu;
Expand All @@ -34,7 +37,7 @@ const OPTIMIZED_FORMAT = /\.(?:png|jpe?g)$/iu;
* @returns {string}
*/
export function templateImageUrl(path, width = TEMPLATE_IMAGE_WIDTH) {
return `/_next/image?url=${encodeURIComponent(path)}&w=${width}&q=${TEMPLATE_IMAGE_QUALITY}`;
return optimizedImageUrl(path, width);
}

/**
Expand Down
67 changes: 67 additions & 0 deletions apps/docsite/src/__tests__/theme-showcase-photos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import fs from 'node:fs';
import path from 'node:path';
import {describe, expect, it} from 'vitest';
import {imageConfigDefault} from 'next/dist/shared/lib/image-config';
import {
SHOWCASE_PHOTO_WIDTH,
THEME_SHOWCASE_PHOTOS,
getThemeShowcaseContent,
} from '../components/themeShowcaseContent';
import {
OPTIMIZED_IMAGE_QUALITY,
optimizedImageUrl,
} from '../lib/optimizedImageUrl.mjs';

const publicDir = path.resolve(__dirname, '../../public');
const THEMES = ['matcha', 'butter', 'stone', 'y2k', 'gothic'];

describe('optimizedImageUrl', () => {
it('names the image, the width, and the quality', () => {
expect(optimizedImageUrl('/images/matcha/preview-watch.png', 828)).toBe(
'/_next/image?url=%2Fimages%2Fmatcha%2Fpreview-watch.png&w=828&q=75',
);
});
});

describe('theme showcase photos', () => {
it.each(THEMES)('loads the six %s photos through the optimizer', theme => {
const photos = Object.values(getThemeShowcaseContent(theme)?.images ?? {});
expect(photos).toHaveLength(6);
for (const photo of photos) {
const url = new URL(photo, 'http://site');
expect(url.pathname).toBe('/_next/image');
expect(url.searchParams.get('w')).toBe(String(SHOWCASE_PHOTO_WIDTH));
const file = url.searchParams.get('url') ?? '';
expect(file).toMatch(
new RegExp(`^/images/${theme}/preview-[a-z]+\\.png$`),
);
expect(fs.existsSync(path.join(publicDir, file))).toBe(true);
}
});

it('requests a width and a quality the optimizer allows', async () => {
const {default: nextConfig} = await import('../../next.config.mjs');
const images = {
...imageConfigDefault,
...(nextConfig as {images?: Partial<typeof imageConfigDefault>}).images,
};
expect(images.deviceSizes).toContain(SHOWCASE_PHOTO_WIDTH);
expect(images.qualities).toContain(OPTIMIZED_IMAGE_QUALITY);
});

it('lists every photo once for the warm-up', () => {
expect(THEME_SHOWCASE_PHOTOS).toHaveLength(THEMES.length * 6);
expect(new Set(THEME_SHOWCASE_PHOTOS).size).toBe(
THEME_SHOWCASE_PHOTOS.length,
);
for (const theme of THEMES) {
expect(THEME_SHOWCASE_PHOTOS).toEqual(
expect.arrayContaining(
Object.values(getThemeShowcaseContent(theme)?.images ?? {}),
),
);
}
});
});
101 changes: 101 additions & 0 deletions apps/docsite/src/__tests__/theme-showcase-warmup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

// @vitest-environment jsdom

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {THEME_SHOWCASE_PHOTOS} from '../components/themeShowcaseContent';
import {warmThemeShowcasePhotos} from '../components/themeShowcaseWarmup';

/** The image sources the warm-up has requested. */
function requested(images: HTMLImageElement[]): string[] {
return images.map(image => image.getAttribute('src') ?? '');
}

function setConnection(connection: unknown) {
Object.defineProperty(navigator, 'connection', {
configurable: true,
value: connection,
});
}

describe('warmThemeShowcasePhotos', () => {
let idleCallbacks: Array<() => void>;

beforeEach(() => {
idleCallbacks = [];
vi.stubGlobal(
'requestIdleCallback',
vi.fn((callback: () => void) => idleCallbacks.push(callback)),
);
vi.stubGlobal(
'cancelIdleCallback',
vi.fn((handle: number) => {
idleCallbacks[handle - 1] = () => {};
}),
);
setConnection(undefined);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
setConnection(undefined);
});

it('requests nothing before the page is idle', () => {
const {images} = warmThemeShowcasePhotos();
expect(images).toEqual([]);
expect(idleCallbacks).toHaveLength(1);
});

it('requests every showcase photo once the page is idle, and holds the images', () => {
const {images} = warmThemeShowcasePhotos();
idleCallbacks[0]();
expect(requested(images)).toEqual([...THEME_SHOWCASE_PHOTOS]);
});

it('requests nothing when it is cancelled before the page is idle', () => {
const {images, cancel} = warmThemeShowcasePhotos();
cancel();
idleCallbacks[0]();
expect(images).toEqual([]);
});

it.each([
['a data-saving mode', {saveData: true, effectiveType: '4g'}],
['a 2G link', {saveData: false, effectiveType: '2g'}],
['a slow 2G link', {saveData: false, effectiveType: 'slow-2g'}],
])('requests nothing on %s', (_label, connection) => {
setConnection(connection);
const {images} = warmThemeShowcasePhotos();
expect(idleCallbacks).toEqual([]);
expect(images).toEqual([]);
});

it('waits for idle time for two seconds at most', () => {
warmThemeShowcasePhotos();
expect(window.requestIdleCallback).toHaveBeenCalledWith(
expect.any(Function),
{timeout: 2000},
);
});

it('waits a second where the browser has no idle callback', () => {
vi.stubGlobal('requestIdleCallback', undefined);
vi.useFakeTimers();
const {images} = warmThemeShowcasePhotos();
vi.advanceTimersByTime(999);
expect(images).toEqual([]);
vi.advanceTimersByTime(1);
expect(requested(images)).toEqual([...THEME_SHOWCASE_PHOTOS]);
});

it('requests nothing when it is cancelled where the browser has no idle callback', () => {
vi.stubGlobal('requestIdleCallback', undefined);
vi.useFakeTimers();
const {images, cancel} = warmThemeShowcasePhotos();
cancel();
vi.advanceTimersByTime(5000);
expect(images).toEqual([]);
});
});
14 changes: 14 additions & 0 deletions apps/docsite/src/components/ThemePackagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {SelectableCard} from '@astryxdesign/core/SelectableCard';
import {Selector} from '@astryxdesign/core/Selector';
import {ThemeShowcaseStore} from '../../../../packages/cli/assets/templates/pages/theme-showcase/page';
import {getThemeShowcaseContent} from './themeShowcaseContent';
import {warmThemeShowcasePhotos} from './themeShowcaseWarmup';
import {buildPlaygroundHref} from './playgroundLink';
import {themeObjects} from '../generated/themeRegistry';
import type {ThemeExplorerPackage} from './themeExplorerData';
Expand Down Expand Up @@ -452,6 +453,19 @@ export function ThemePackagePage({
const router = useRouter();
const pathname = usePathname();

// Syncs the browser's image cache with the showcase photos of every theme,
// so a theme switch shows them at once. The ref holds the images while
// this page is mounted: a held image needs no second request.
const warmedPhotos = useRef<HTMLImageElement[]>([]);
useEffect(() => {
const {images, cancel} = warmThemeShowcasePhotos();
warmedPhotos.current = images;
return () => {
cancel();
warmedPhotos.current = [];
};
}, []);

// Ref for the mobile theme carousel — observed by IntersectionObserver
// to toggle the floating toolbar visibility.
const carouselRef = useRef<HTMLDivElement>(null);
Expand Down
82 changes: 51 additions & 31 deletions apps/docsite/src/components/themeShowcaseContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,40 @@
* falls back to its own neutral defaults for any theme not listed here.
*
* Keyed by theme slug (the `<slug>` in `@astryxdesign/theme-<slug>` / /themes?theme=<slug>).
*
* The photos load through the image optimizer. As PNG files they are 0.5 to
* 1.2 MB each, and a theme switch loads six of them.
*/

import type {
ProductSpec,
InventoryRow,
} from '../../../../packages/cli/assets/templates/pages/theme-showcase/page';

import {optimizedImageUrl} from '../lib/optimizedImageUrl.mjs';

// A product card shows its photo up to about 400 CSS pixels wide. Most of the
// photos are 700 pixels wide, so this width keeps their pixel size.
export const SHOWCASE_PHOTO_WIDTH = 828;

function photo(path: string): string {
return optimizedImageUrl(path, SHOWCASE_PHOTO_WIDTH);
}

export interface ThemeShowcaseContent {
images: Record<string, string>;
products: ProductSpec[];
inventory: InventoryRow[];
}


// Matcha café — matcha-drink photos.
const MATCHA_IMAGES: Record<string, string> = {
watch: '/images/matcha/preview-watch.png',
headphones: '/images/matcha/preview-headphones.png',
backpack: '/images/matcha/preview-backpack.png',
wallet: '/images/matcha/preview-wallet.png',
tumbler: '/images/matcha/preview-tumbler.png',
throw_: '/images/matcha/preview-throw.png',
watch: photo('/images/matcha/preview-watch.png'),
headphones: photo('/images/matcha/preview-headphones.png'),
backpack: photo('/images/matcha/preview-backpack.png'),
wallet: photo('/images/matcha/preview-wallet.png'),
tumbler: photo('/images/matcha/preview-tumbler.png'),
throw_: photo('/images/matcha/preview-throw.png'),
};

const MATCHA_CONTENT: ThemeShowcaseContent = {
Expand Down Expand Up @@ -130,12 +142,12 @@ const MATCHA_CONTENT: ThemeShowcaseContent = {
// Butter bakery — butter/breakfast photos. Only five Butter assets exist, so
// the croissant is reused for the sixth slot.
const BUTTER_IMAGES: Record<string, string> = {
watch: '/images/butter/preview-watch.png',
headphones: '/images/butter/preview-headphones.png',
backpack: '/images/butter/preview-backpack.png',
wallet: '/images/butter/preview-wallet.png',
tumbler: '/images/butter/preview-tumbler.png',
throw_: '/images/butter/preview-throw.png',
watch: photo('/images/butter/preview-watch.png'),
headphones: photo('/images/butter/preview-headphones.png'),
backpack: photo('/images/butter/preview-backpack.png'),
wallet: photo('/images/butter/preview-wallet.png'),
tumbler: photo('/images/butter/preview-tumbler.png'),
throw_: photo('/images/butter/preview-throw.png'),
};

const BUTTER_CONTENT: ThemeShowcaseContent = {
Expand Down Expand Up @@ -232,12 +244,12 @@ const BUTTER_CONTENT: ThemeShowcaseContent = {

// Stone homeware — stoneware photos on stone plinths.
const STONE_IMAGES: Record<string, string> = {
watch: '/images/stone/preview-watch.png',
headphones: '/images/stone/preview-headphones.png',
backpack: '/images/stone/preview-backpack.png',
wallet: '/images/stone/preview-wallet.png',
tumbler: '/images/stone/preview-tumbler.png',
throw_: '/images/stone/preview-throw.png',
watch: photo('/images/stone/preview-watch.png'),
headphones: photo('/images/stone/preview-headphones.png'),
backpack: photo('/images/stone/preview-backpack.png'),
wallet: photo('/images/stone/preview-wallet.png'),
tumbler: photo('/images/stone/preview-tumbler.png'),
throw_: photo('/images/stone/preview-throw.png'),
};

const STONE_CONTENT: ThemeShowcaseContent = {
Expand Down Expand Up @@ -334,12 +346,12 @@ const STONE_CONTENT: ThemeShowcaseContent = {

// Y2K trinkets — iridescent/holo objects.
const Y2K_IMAGES: Record<string, string> = {
watch: '/images/y2k/preview-watch.png',
headphones: '/images/y2k/preview-headphones.png',
backpack: '/images/y2k/preview-backpack.png',
wallet: '/images/y2k/preview-wallet.png',
tumbler: '/images/y2k/preview-tumbler.png',
throw_: '/images/y2k/preview-throw.png',
watch: photo('/images/y2k/preview-watch.png'),
headphones: photo('/images/y2k/preview-headphones.png'),
backpack: photo('/images/y2k/preview-backpack.png'),
wallet: photo('/images/y2k/preview-wallet.png'),
tumbler: photo('/images/y2k/preview-tumbler.png'),
throw_: photo('/images/y2k/preview-throw.png'),
};

const Y2K_CONTENT: ThemeShowcaseContent = {
Expand Down Expand Up @@ -436,12 +448,12 @@ const Y2K_CONTENT: ThemeShowcaseContent = {

// Gothic botanicals — moody single-stem floral photos.
const GOTHIC_IMAGES: Record<string, string> = {
watch: '/images/gothic/preview-watch.png',
headphones: '/images/gothic/preview-headphones.png',
backpack: '/images/gothic/preview-backpack.png',
wallet: '/images/gothic/preview-wallet.png',
tumbler: '/images/gothic/preview-tumbler.png',
throw_: '/images/gothic/preview-throw.png',
watch: photo('/images/gothic/preview-watch.png'),
headphones: photo('/images/gothic/preview-headphones.png'),
backpack: photo('/images/gothic/preview-backpack.png'),
wallet: photo('/images/gothic/preview-wallet.png'),
tumbler: photo('/images/gothic/preview-tumbler.png'),
throw_: photo('/images/gothic/preview-throw.png'),
};

const GOTHIC_CONTENT: ThemeShowcaseContent = {
Expand Down Expand Up @@ -544,6 +556,14 @@ const CONTENT_BY_THEME: Record<string, ThemeShowcaseContent> = {
gothic: GOTHIC_CONTENT,
};

/**
* Every bespoke showcase photo. The /themes page loads them ahead of a theme
* switch (themeShowcaseWarmup.ts).
*/
export const THEME_SHOWCASE_PHOTOS: ReadonlyArray<string> = Object.values(
CONTENT_BY_THEME,
).flatMap(content => Object.values(content.images));

/**
* Resolve bespoke showcase content for a theme slug, or `undefined` when the
* theme has no override (the template then renders its neutral defaults).
Expand Down
Loading