Masking CSS-Selector Regions for Map Popups

A single open popup can flag an otherwise-identical map as regressed: it carries a live timestamp, a queried feature value, or a close button whose focus ring paints differently between runs, and its tail is anchored to a floating-point screen coordinate that never rounds to the same pixel twice. The fix is not to loosen the diff threshold until the noise disappears — that blinds the gate to real cartographic faults — but to exclude exactly the volatile region and compare everything around it at full sensitivity. This page is the procedure for doing that by CSS selector: enumerate the volatile nodes, paint a stable mask over each one, and prove the mask still covers the element after the viewport changes.

This is one rule set inside Interactive Overlay Masking Rules, the guide that defines how a masking target binds to a strategy and a lifecycle anchor. It sits under the parent reference on the tension between deterministic rendering and non-deterministic chrome, Dynamic Element Masking & UI Stability. Where the sibling recipe on how to mask dynamic user cursors and tooltips in map tests handles pointer-driven overlays, this one focuses on selector-addressable boxes — popups, attribution, and live readouts — and the geometry problem their coordinate anchoring creates.

Prerequisites

Step-by-step procedure

1. Enumerate the volatile selectors

Before masking anything, inventory every node that legitimately varies between two correct runs. Do not mask stable chrome — a masked region is a region you stop testing. The recurring offenders across libraries are the popup container, the tooltip box, the attribution control, and any element that renders a clock or a live coordinate. Record them in the manifest with the reason each is volatile, so a reviewer can audit what the gate no longer sees.

// masking-manifest.js — volatile selectors, grouped by why they vary.
export const volatileSelectors = {
  popups:      ['.maplibregl-popup', '.mapboxgl-popup', '.leaflet-popup'],
  tooltips:    ['.maplibregl-tooltip', '.leaflet-tooltip', '.ol-tooltip'],
  attribution: ['.maplibregl-ctrl-attrib', '.leaflet-control-attribution', '.ol-attribution'],
  liveReadout: ['[data-testid="timestamp"]', '.coordinate-readout', '.feature-query-value'],
};

Keep attribution on the list: its text reflows when a tile source rotates a data-vintage string, and the control resizes, shifting its own bounding box by a few pixels.

2. Prefer the runner’s native mask with a stable color

Playwright’s page.screenshot({ mask }) paints an opaque box over each matched locator before the image is encoded, so the volatile content never reaches the comparator. Always pin maskColor to a fixed, fully opaque value — the default is a semi-transparent magenta that composites differently against whatever is beneath it, reintroducing the variance you are removing. A flat dark fill diffs to zero against itself on every run.

await page.screenshot({
  path: 'current/map-with-popup.png',
  animations: 'disabled',
  mask: [
    page.locator('.maplibregl-popup'),
    page.locator('.maplibregl-ctrl-attrib'),
    page.locator('[data-testid="timestamp"]'),
  ],
  maskColor: '#101820', // opaque; identical bytes under the box every run
});

The native mask is the right default because it measures each locator’s live bounding box at capture time — it tracks an element that moved without you recomputing anything.

3. Fall back to injected CSS when the tool lacks masking

Puppeteer, jest-image-snapshot, and most self-hosted comparators have no native mask. There, blank the region with an injected stylesheet instead. Use visibility: hidden with pointer-events: none, never display: none — collapsing the box reflows neighbouring nodes and can nudge the map container itself, manufacturing the drift you set out to remove. Where you need the region positively painted rather than merely hidden (so a stray child cannot leak through), overlay an absolutely-positioned block pinned to the element’s box.

await page.addStyleTag({
  content: `
    .maplibregl-popup, .mapboxgl-popup, .leaflet-popup,
    .maplibregl-ctrl-attrib, .leaflet-control-attribution,
    [data-testid="timestamp"] {
      visibility: hidden !important;
      pointer-events: none !important;
    }
  `,
});

Inject this only after the map has settled (step 5), or a late relayout re-shows the element after the rule ran.

4. Compute a stable bounding box for coordinate-anchored popups

A GL popup is positioned by projecting a geographic anchor to a screen pixel, so its box origin carries sub-pixel jitter and its size depends on content that may itself vary. When you must mask by rectangle — a canvas-drawn callout, or a comparator that takes coordinates rather than selectors — do not read the live DOM box, which inherits that jitter. Project the anchor yourself, snap to integer pixels, and pad by a couple of device pixels on every side so anti-aliased edges fall inside the mask.

const box = await page.evaluate(() => {
  const map = window.map;
  const p = map.project([-0.1278, 51.5074]); // WGS84 anchor → screen px
  const w = 240, h = 120, pad = 2;           // popup nominal size + AA guard
  return {
    x: Math.floor(p.x - w / 2 - pad),
    y: Math.floor(p.y - h - pad),            // popup sits above its anchor
    width: w + pad * 2,
    height: h + pad * 2,
  };
});

For a rectangle expressed as a fraction of the frame, the padded mask width relative to viewport width is

which lets you keep the same relative exclusion across viewports even when the pixel dimensions change.

5. Gate masking on the map idle anchor

Apply every mask — native locators, injected CSS, or rectangle — only after the map’s terminal idle event. Masking mid-flight either catches a popup still easing into place or lets the library re-inject the node after your rule ran. This is the same synchronization contract enforced across the capture pipeline; a popup that opens on moveend is not present until the camera settles.

await page.evaluate(
  () => new Promise((resolve) => window.map.once('idle', resolve))
);
// now inject CSS / compute boxes / take the masked screenshot
Selector-targeted mask rectangles over a map frame A map frame holds three volatile regions covered by flat mask rectangles. A coordinate-anchored popup near the center, matched by the maplibregl-popup selector, is covered by a dark rectangle whose tail points to its geographic anchor. The attribution control in the bottom-right corner, matched by the attribution selector, is covered by a second rectangle. A live timestamp readout in the top-left, matched by a data-testid selector, is covered by a third rectangle. The rest of the basemap is left unmasked so the comparator sees it at full sensitivity. map frame · unmasked basemap compared at full sensitivity .maplibregl-popup masked · live feature value .maplibregl-ctrl-attrib [data-testid=timestamp]

6. Verify the mask covers the element across viewports

A mask sized for one viewport can uncover an edge at another: text wraps differently, the popup grows taller, attribution reflows to two lines. Loop the mask-and-capture across your DPR and viewport matrix and assert that no volatile pixel survives. Read the element’s live box after each resize and confirm the mask still contains it with margin to spare.

for (const vp of [{ width: 1024, height: 768 }, { width: 1440, height: 900 }]) {
  await page.setViewportSize(vp);
  await page.evaluate(() => new Promise((r) => window.map.once('idle', r)));
  const el = await page.locator('.maplibregl-popup').boundingBox();
  // assert the planned mask rectangle fully contains `el` before capturing
}

Verification

A correct setup blanks only the enumerated selectors, leaves the container box untouched, and still catches a genuine fault a few pixels outside the mask edge.

Troubleshooting

Symptom Likely cause Fix
Popup edge or tail still shows past the mask Mask sized from a stale box, or the popup grew taller at this DPR Pad the rectangle per step 4 and re-measure the live box per viewport in step 6 before capturing
Whole map shifted a few pixels once the mask applied An injected rule used display: none, reflowing the container Switch to visibility: hidden plus pointer-events: none, or overlay an absolutely-positioned block that does not affect layout
Masked region diffs non-zero between identical runs maskColor left at the default semi-transparent fill, composited over varying pixels Pin an opaque maskColor (for example #101820) so the box is byte-identical every run

Frequently asked questions

Should I mask the popup or just prevent it from opening?

Prevent it when a clean basemap is what you are testing — launch the map with interactive: false and never trigger the popup, which gives a real, fully-compared frame with nothing excluded. Mask by selector only when the popup must stay open for the scenario but its contents (a live timestamp, a queried feature value) carry no cartographic meaning. Every masked region is one the gate no longer inspects, so keep the exclusion as tight as the volatile content.

Why use visibility: hidden instead of display: none on a popup?

display: none removes the box from layout, which reflows sibling nodes and can nudge the map container itself, so two runs differ by a few pixels across the whole frame — the exact drift you are trying to remove. visibility: hidden keeps the element’s geometry intact and simply stops it painting, preserving every surrounding pixel. Add pointer-events: none so a synthetic pointer cannot re-trigger it during capture.

How do I mask a popup whose position depends on map coordinates?

Do not read the live DOM box, which inherits the sub-pixel jitter of projecting a floating-point geographic anchor to a screen pixel. Project the anchor yourself with map.project(), snap the origin to integer pixels, and pad a couple of device pixels on each side so anti-aliased edges fall inside the mask. Recompute the box after any camera change, and gate the whole thing on the map idle event so the anchor has settled first.

Can I rely on Playwright's native mask option alone?

For selector-addressable DOM popups, tooltips, and attribution, yes — the native mask measures each locator’s live box and paints an opaque rectangle before encoding, which is why step 2 makes it the default. It cannot reach a callout drawn straight into the WebGL context; for that, compute a projected rectangle per step 4. On runners without a native mask, the injected-CSS fallback in step 3 does the same job.