CogniPrep's public pages list practice tests as a grid of cards. Until recently
each card led with an icon from a lookup table, which is an honest way of saying
the card told you nothing about what you were about to open. Now the public
cards lead with a screenshot of the real thing.
372 games, 372 screenshots, and the interesting part is everything around the
images rather than the images.
The capture route exists only in next dev
export const metadata: Metadata = { robots: { index: false, follow: false } };
export default async function GamePreviewPage({ params }) {
if (process.env.NODE_ENV !== 'development') notFound();
const { 'game-name': gameName } = await params;
const game = GAME_LIBRARY.find((g) => g.id === gameName);
if (!game) notFound();
// ...render the runner
}
Capturing a screenshot of something that normally needs a session is a chore:
sign in, navigate, get past whatever the app wants to show you first. A route
that renders the runner directly removes all of that, and the NODE_ENV guard
plus the noindex metadata keep it from existing anywhere a stranger can reach.
You can verify the guard from here: /dev/game-preview/aon-switch on the live
site answers 404, while the same path against a local pnpm dev renders.
The crop script is mostly validation
The capture is a 1280x800 tab in dark mode at device pixel ratio 2, so the PNG
arrives at 2560x1600. The script's job is to turn that into a card image, and
most of its code is refusing to do that when the input is wrong:
const size = pngSize(file);
const scale = size.width / VIEWPORT.width;
if (Math.abs(size.height / scale - VIEWPORT.height) > 1) {
throw new Error(
`${file} is ${size.width}x${size.height}; resize the tab to 1280x800 and capture again.`
);
}
It derives the device pixel ratio from the width rather than assuming 2, then
checks the height agrees with it. A tab that was 1280x742 because a download bar
appeared would otherwise produce one card at a slightly different crop than its
371 neighbours, and nobody would notice until the grid looked subtly wrong.
Everything below the 64px game header is the game area. The default crop takes a
16:10 box from the centre of it. A second mode keeps the whole area and pads it
out to 16:10 instead, for layouts that run edge to edge, using the dark theme's
own background colour so the padding is invisible:
/** Dark theme --background, hsl(0 0% 9%). */
const BACKGROUND = '171717';
Pads and crops are split across two tools for an annoying reason that is worth
writing down: sips ignores --cropOffset, so it cannot crop at an offset,
while cwebp can crop but cannot pad. So cwebp does a lossless crop, dwebp
turns it back into a PNG, and sips pads that, centred, before the final encode.
The third mode takes three CSS pixel numbers for a tighter box, and checks them
against the game area before doing anything:
if (x < 0 || y < HEADER_HEIGHT || x + width > VIEWPORT.width || y + height > VIEWPORT.height) {
throw new Error(`Crop ${x},${y} ... leaves the game area`);
}
Height is never an argument. It is always width / ASPECT, so a hand-typed crop
cannot produce a card with the wrong aspect ratio.
The encode is cwebp -q 80 -resize 960 0. The result across all 372 files:
5,817,306 bytes, a median of 12,538 bytes, smallest 2,552, largest 73,278.
The manifest is generated, not maintained
/**
* Games with a card screenshot at public/games/screenshots/<id>.webp.
* Generated by `pnpm tsx scripts/game-screenshots.ts manifest`; do not edit by hand.
*/
export const GAME_SCREENSHOTS: ReadonlySet<string> = new Set([
'acr-abstract',
// ...370 more
'wlc-personality',
]);
The manifest command reads the output directory, sorts the ids and rewrites
that file. It is a Set of strings and the card does one thing with it:
const screenshot =
redirectToSignup && GAME_SCREENSHOTS.has(game.id)
? `/games/screenshots/${game.id}.webp`
: null;
A hand-maintained list is a list that eventually names a file that was renamed,
and the failure mode is a broken image on a marketing page. Generating it from
disk makes that state unrepresentable: the set cannot contain an id whose file
is missing, because the set is the filenames.
The other half of the guard is that a game with no screenshot yet is not a bug.
It falls back to the icon layout, which is still what every dashboard card uses.
Right now the two lists happen to be identical: 372 games in the library, 372
ids in the set, nothing missing and nothing orphaned.
The card frames it like a handheld
<div className="shrink-0 p-2 pb-0">
<div className="bg-muted ring-border relative aspect-[16/10] overflow-hidden rounded-[12px] ring-1">
<Image src={screenshot} alt={`${game.name} practice game in progress`} fill
sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw"
className="scale-[1.03] object-cover" />
Two radii that have to agree: the card is 20px, the frame around the screen is
8px, so the screen is 12px and the curves run parallel instead of drifting. The
box is aspect-[16/10] because that is the capture ratio, so nothing is cropped
at any card width. A shorter box looked better on phones and cut off the answer
controls that most of these screens keep at the bottom.
scale-[1.03] is the kind of line that deserves a comment and has one. At some
card widths the image rounded a pixel short of its container and left a grey
sliver down the right edge, so it sits 3% oversize.
Nine srcset widths for 960 real pixels
The source images are 960 wide. next/image with fill generates the full
width ladder anyway, and you can watch what that costs. From the console on a
provider page:
const img = [...document.querySelectorAll('img')].find(i => /aon-ix/.test(i.src));
img.srcset.split(', ').length; // 9
img.currentSrc; // ...&w=828&q=75
Nine candidates, from 384w to 3840w. The browser picked 828w for a 400px slot at
device pixel ratio 2, which is correct. The four largest candidates are not:
w=384 2,857 bytes
w=640 5,308 bytes
w=828 7,014 bytes
w=1080 7,014 bytes
w=1920 7,014 bytes
w=3840 7,014 bytes
The optimizer will not upscale, so every width at or above the source returns
the same file. Four of the nine srcset entries are the same bytes under
different URLs, and the src attribute, which is the no-srcset fallback, points
at the 3840 one. It costs nothing in practice because no browser without srcset
support is going to load this page, but it is a good reminder that sizes is
advice to the browser and the ladder is generated without ever looking at how
big the file actually is.
See it
-
cogniprep.app/games/aon and
cogniprep.app/games/shl are two of the
provider pages where the cards lead with a screenshot. Every image on them is
loading="lazy"with the samesizesstring, and the alt text is generated from the game name. - Paste the two console snippets above on either page to get the srcset ladder and the width your own screen picked.
-
cogniprep.app/dev/game-preview/aon-switch
answers 404, which is the capture route refusing to exist outside
next dev.
Top comments (0)