DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A 3% zoom hid a one-pixel rounding gap for 372 cards, then cut the first letters off the 373rd

A while ago I wrote about
putting a real screenshot at the top of 372 practice test cards:
a dev-only capture route, a crop script, and a manifest the build generates so a
card can never point at a file that is not there.

This post is what happened when a second kind of content needed the same
treatment, which is the point at which you find out which of your decisions were
decisions and which were coincidences.

The frame, and the arithmetic in it

The screenshot does not sit flush at the top of the card. It sits inset, like
the screen of a handheld console, with the card acting as the case around it:

<div className="shrink-0 p-2 pb-0">
  <div className="bg-muted ring-border relative aspect-[16/10] overflow-hidden rounded-[12px] ring-1">
    <div className="absolute -inset-px">
      <Image src={src} alt={alt} fill sizes={sizes} className="object-cover" />
    </div>
    {badge && <div className="absolute top-2.5 right-2.5 shadow-sm">{badge}</div>}
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The radius is not a taste choice, it is subtraction. The card is
rounded-[20px]. The frame around the screen is p-2, so 8px. A rounded corner
inset by 8px only stays parallel to its parent if its own radius is 8px smaller:
20 - 8 = 12, hence rounded-[12px]. Use the same radius for both and the inner
corner looks tighter than the outer one; use a bigger one and the gap between
the curves visibly fattens at 45 degrees. This is the single most common
giveaway in nested rounded rectangles and it costs one subtraction to get right.

The aspect ratio is 16/10 because that is the capture ratio, so the box and
the image agree and object-cover has nothing to crop. I did try a shorter box
on phones, on the theory that a card is mostly text. It cut off the bottom strip
of the screen, which in most of our tests is where the answer controls live, so
the screenshot stopped showing the thing it was there to show.

The bug the extraction exposed

Look at that inner wrapper again:

<div className="absolute -inset-px">
Enter fullscreen mode Exit fullscreen mode

A negative one pixel inset on every side, so the image overhangs its box by a
pixel all round and gets clipped by overflow-hidden. It used to be this:

className="scale-[1.03] object-cover"
Enter fullscreen mode Exit fullscreen mode

Both exist to solve the same real problem. At some card widths the browser
rounds the image's layout box a fraction short of its container and leaves a
one-pixel grey sliver down the right edge. A 3% scale buries the sliver under
overflow, and for 372 game screenshots it was invisible and fine.

Then the same component rendered an assessment centre exercise, whose brief
panel starts hard against the left edge of the screen, and 3% of 1280 pixels is
about 19 pixels of horizontal zoom. The first letters of the panel went missing.

The two fixes look equivalent and are not. A scale is proportional, so it costs
you a percentage of the content at every edge, and the percentage you need is a
guess about rendering you cannot measure. A one-pixel bleed is absolute: it
covers exactly the sub-pixel rounding that caused the problem and nothing else.
I had reached for the proportional fix because it was one class, and it survived
only because no content was near enough to the edge to notice.

The general version, which I keep relearning: a fix sized in percent for a
problem measured in pixels works until the content changes.

Extracting it

Both surfaces now render the same component:

export function CardScreenshot({ src, alt, sizes, badge }: {
  src: string;
  alt: string;
  sizes: string;
  /** Pinned to the screen's top right corner. */
  badge?: ReactNode;
}) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Four props, and the choice of which four is the whole design. src and alt
are obviously the caller's. sizes is too, and it has to be, because
next/image needs a sizes string that describes the card's width at each
breakpoint and the two grids are different:

// game cards, three columns from lg
sizes="(min-width: 1024px) 400px, (min-width: 768px) 50vw, 100vw"
// exercise format cards, two columns from sm
sizes="(min-width: 896px) 440px, (min-width: 640px) 50vw, 100vw"
Enter fullscreen mode Exit fullscreen mode

A shared component that guessed sizes for its callers would silently serve one
of them the wrong image width forever, and next/image gives you no warning
because it has no idea how wide your card is.

What is deliberately not a prop: the padding, the ratio, the radius, the ring,
the bleed, the badge position. Those are the frame. If a caller needed a
different one of those, it would not be this component.

The docstring carries the constraint the component cannot enforce:

The card must be overflow-hidden rounded-[20px]: the screen's corners are the
card's 20px less the 8px frame, so the curves run parallel.

That is a contract a type cannot express, so it is written where whoever breaks
it will be looking.

916 is not a round number

Generalising the capture script meant turning its constants into a record with
one entry per content type:

const TARGETS: Record<'game' | 'exercise', Target> = {
  game: {
    viewportHeight: 800,
    headerHeight: 64,
    footerHeight: 0,
    // ...
  },
  exercise: {
    // The e-tray's three panes run the full width, so a 16:10 trim would cut
    // text off both edges. 916 is 800 plus ExerciseContainer's h-14 header and
    // the workspace's 60px "Finish & score" bar, which leaves an area of exactly
    // 1280x800 to keep whole between them.
    viewportHeight: 916,
    headerHeight: 56,
    footerHeight: 60,
  },
};
Enter fullscreen mode Exit fullscreen mode

Games capture at 1280x800 and the default crop takes a 16:10 box from the middle
of the play area. That works because most games are roughly centred.

An e-tray exercise is three panes side by side across the full width. Trimming
it to 16:10 from the centre would cut text off both edges, so these captures use
the mode that keeps the whole area and pads it instead. But "the whole area" has
to actually be 16:10 to avoid padding bars, and the area is the viewport minus
the chrome. So the viewport height is derived backwards from the area you want
to keep: 800 for the content, plus 56 for the header, plus 60 for the bottom
action bar, is 916.

Which means footerHeight had to exist as a concept at all. The game target has
footerHeight: 0 and the crop maths is the same for both:

const bottom = viewport.height - target.footerHeight;
const areaHeight = bottom - header;
Enter fullscreen mode Exit fullscreen mode

Three fields and two subtractions where there used to be two constants. Adding a
second target to a script written for one is almost always this: the constants
you hardcoded were not wrong, they were just unnamed.

The ids are not the filenames

One detail worth a paragraph because it bit me. The generated manifest lists the
captures that exist:

/**
 * Exercises with a card screenshot at public/exercises/screenshots/<id>.webp.
 * Generated by `pnpm tsx scripts/game-screenshots.ts manifest`; do not edit by hand.
 */
export const EXERCISE_SCREENSHOTS: ReadonlySet<string> = new Set([
  'etray-product-launch',
  'group-discussion-scenarios',
  'intray-office-manager',
  'presentation-board-strategy',
  'roleplay-difficult-client',
  'written-recommendation-report',
]);
Enter fullscreen mode Exit fullscreen mode

Four of those six do not match the source file they are defined in.
etray-marketing-launch.ts exports an exercise whose id is
etray-product-launch; written-consulting-report.ts is
written-recommendation-report. The files were named during drafting and the
ids were named for the reader.

Generating the manifest from the output directory rather than from the content
library is what makes that harmless. The capture is written as <id>.webp, the
manifest is read back from the filenames, and the card looks up its own id. At
no point does anything have to know what the file on my disk is called. Had I
written the list by hand from the content folder, four of the six would have
pointed at nothing.

And one line of selection logic

The public page shows one captured scenario per format:

/**
 * One captured scenario per format for the top of its card, the first in
 * library order, so the e-tray card shows the free sample. A format with no
 * capture keeps its icon.
 */
Enter fullscreen mode Exit fullscreen mode

"The first in library order" is doing real work there. The library is ordered
with the free sample first, so the card for the format you can try without an
account shows the scenario you would actually get. And a format with no capture
is not broken, it keeps the icon treatment, which is also why the card's radius
is conditional: rounded-[20px] with a screenshot, rounded-xl without, because
the nested radius arithmetic only applies when there is something nested.

The game cards are live now, so you can see the frame on any provider page, for
example Aon's, where the cards lead with a
capture of the test they open. The exercise cards on the
assessment centre practice page
still show icons at the time of writing; the six captures above go out with the
next deploy. The free e-tray scenario on that page runs in full without an
account either way.

Top comments (0)