Twenty-four photos were competing with the map for the first three seconds

rega measured a cold load of Simple Research pulling 24 people-group photos, 5.3 MB,
DURING BOOT — alongside the map engine and the API data, on one connection. Nothing was
wrong with any single image. There was no instruction about WHEN to fetch them, so the
browser fetched them all at once, which is the correct behaviour for markup that says
nothing.

Past the first row the cards now load lazily, at low fetch priority, decoded off the main
thread.

★ THE FIRST ROW STAYS EAGER, and that is the half a blanket loading="lazy" gets wrong.
Lazy-loading what the reader is already looking at trades a visible flash of empty cards
for bytes nobody saves. EAGER_IMAGES is a COLUMN COUNT, not a taste: the grid is
repeat(auto-fill, minmax(240px, 1fr)) unless a host overrides cards-per-row, so the first
row is never more than five cards at any width. It is a constant rather than a read of
perRow because the override is the host's state and this only needs a safe CEILING —
generous by a card costs a few KB, short by one lazy-loads something in view.

The size is stated on the element as well as in CSS. .uupg__image already carries
aspect-ratio 1/1, which is what actually reserves the box; width/height repeat the same
1:1 as an intrinsic ratio for the moment before the stylesheet applies. The same truth
twice on purpose, not a second different one.

check:grid-image-loading exists because this component is being MERGED with the regions
list right now, and a merge rewrites markup: four attributes on one <img> survive a review
and not a rewrite, and losing them is invisible — the page still works and costs 5.3 MB
again. It also fails if it cannot find the element, and asserts the first row is eager,
which a bare loading="lazy" would otherwise pass. Negative-tested four ways.

Coordinated with regions-plugin before touching a file they are actively reshaping: their
six hunks bracket this element without touching it, so neither of us collides.
This commit is contained in:
Doxa Maps 2026-09-28 18:51:21 +00:00
parent a45f1914a6
commit 4d4e12ef09
3 changed files with 105 additions and 2 deletions

View file

@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* check-grid-image-loading — the people-group cards must not fetch 24 photos during boot.
*
* THE MEASUREMENT (rega, SR-LOAD on 21ebb4f26): a cold load of Simple Research downloaded 24
* people-group photos, 5.3 MB, DURING BOOT — competing with the map engine and the API data on the
* same connection. Nothing was wrong with any single image; there was simply no instruction about
* WHEN to fetch them, so the browser fetched them all at once.
*
* ★ WHY A GATE AND NOT JUST THE FIX. This component is being MERGED with the regions list right now
* (the Driver's ruling, regions-plugin building it), and a merge rewrites markup. Four attributes on
* one <img> are exactly the kind of thing that survives a review and not a rewrite — and losing them
* is invisible: the page still works, it just costs 5.3 MB again on every cold load.
*
* ★ AND IT ASSERTS THE FIRST ROW IS EAGER, which is the half a naive "add loading=lazy everywhere"
* would get wrong. Lazy-loading what the reader is already looking at trades a visible flash of empty
* cards for bytes nobody saves.
*
* Exit 0 pass · 1 fail.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const TAG = 'GRID-IMAGE-LOADING'
const F = 'plugin-designs/people-groups-grid/people-groups-grid.vue'
const fails = []
const src = readFileSync(join(ROOT, F), 'utf8')
const m = src.match(/<img\b[^>]*class="uupg__image"[^>]*>/s)
if (!m) {
fails.push(`${F}: the card image element was not found — it was renamed or restructured, and this `
+ 'gate would pass while checking nothing. Re-point it before trusting a green run.')
} else {
const tag = m[0]
if (!/:loading=/.test(tag)) {
fails.push('the card image has no loading attribute, so every photo is fetched during boot — '
+ 'rega measured 24 of them, 5.3 MB, competing with the map and the API data.')
}
if (!/decoding="async"/.test(tag)) fails.push('the card image does not decode off the main thread (decoding="async").')
if (!/\bwidth=/.test(tag) || !/\bheight=/.test(tag)) {
fails.push('the card image states no intrinsic size, so the box is unreserved before the '
+ 'stylesheet applies. (.uupg__image\'s aspect-ratio holds it afterwards; these cover the gap.)')
}
if (!/:fetchpriority=/.test(tag)) fails.push('off-screen images are not de-prioritised (fetchpriority).')
// ★ the first row must be EAGER — a bare loading="lazy" would pass every check above and be wrong.
if (/loading="lazy"/.test(tag) && !/:loading=/.test(tag)) {
fails.push('the card image is UNCONDITIONALLY lazy, so the row the reader is already looking at '
+ 'loads late. The first row must stay eager.')
}
if (!/EAGER_IMAGES/.test(tag)) {
fails.push('the eager/lazy split is not keyed on EAGER_IMAGES, so nothing ties it to the column count.')
}
}
// and the constant must exist and be a plausible first-row ceiling
const c = src.match(/const EAGER_IMAGES\s*=\s*(\d+)/)
if (!c) fails.push('EAGER_IMAGES is gone; the first-row exemption has no source.')
else if (Number(c[1]) < 3 || Number(c[1]) > 8) {
fails.push(`EAGER_IMAGES is ${c[1]} — the grid is repeat(auto-fill, minmax(240px, 1fr)), 1 column on a `
+ 'phone and 3-5 on a desktop, so a first-row ceiling outside 3..8 is either short (a visible '
+ 'lazy-load in view) or so generous it undoes the saving.')
}
if (fails.length) {
console.error(`${TAG}: FAIL`)
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`${TAG}: PASS — card photos past the first row load lazily, at low priority, decoded off the main `
+ 'thread, with their box reserved; the first row stays eager.')

View file

@ -239,6 +239,7 @@
"check:legend-top-offset": "node internal/check-legend-top-offset.mjs",
"check:mercator-slice": "node internal/check-mercator-slice.mjs",
"check:embed-url-absolute": "node internal/check-embed-url-absolute.mjs",
"check:grid-image-loading": "node internal/check-grid-image-loading.mjs",
"check:setup-order": "node internal/check-setup-order.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:shadow-css-parses": "node internal/check-shadow-css-parses.mjs",

View file

@ -356,6 +356,13 @@ const t = computed(() => {
})
// ── Cat-2 show/hide (accept the DOM-attribute string 'false') ──────────────────
// How many cards can sit in the FIRST ROW at any supported width. The grid is
// repeat(auto-fill, minmax(240px, 1fr)) unless a host overrides cards-per-row, giving 1 column on a
// phone and 3-5 on a desktop — so five is the upper bound of "already on screen", not a preference.
// Deliberately a constant rather than a read of perRow: the override is the host's state and this
// only needs a SAFE CEILING. Being generous by a card or two costs a few KB; being short by one
// lazy-loads something the reader is looking at, which is the visible failure.
const EAGER_IMAGES = 5
const truthy = (v) => !(v === false || v === 'false' || v === '0' || v === 0)
const useSearch = computed(() => truthy(props.showSearch))
const useFilters = computed(() => truthy(props.showFilters))
@ -1054,9 +1061,33 @@ onMounted(() => {
</label>
</div>
<div id="results" class="grid uupgs-list" :style="gridStyle">
<template v-for="uupg in displayedUUPGs" :key="uupg.slug">
<template v-for="(uupg, ui) in displayedUUPGs" :key="uupg.slug">
<div class="card uupg__card">
<img v-if="uupg.image_url" class="uupg__image" :src="uupg.image_url" :alt="uupg.name">
<!-- ★ THE PHOTOS WERE COMPETING WITH THE MAP FOR THE FIRST THREE SECONDS. rega measured a
cold load of Simple Research pulling 24 people-group photos, 5.3 MB, DURING BOOT —
alongside the map engine and the API data, on the same connection. Nothing was wrong
with any one image; there was simply no instruction about WHEN to fetch them.
EAGER_IMAGES is a column count, not a taste: the grid is
repeat(auto-fill, minmax(240px, 1fr)) by default, which is 1 column on a phone and
3-5 on a desktop, so the FIRST ROW is never more than five cards at any width. Those
five keep their normal priority — lazy-loading what the reader is already looking at
trades a real flash of empty cards for bytes nobody saves. Everything past them waits
until it approaches the viewport.
The size is declared so the reservation survives BEFORE the stylesheet applies: .uupg__image
already carries aspect-ratio 1/1, which is what actually holds the box, and width/height
repeat it as an intrinsic ratio for the moments that CSS has not reached yet. Same 1:1,
stated twice on purpose rather than a second, different truth. -->
<img
v-if="uupg.image_url"
class="uupg__image"
:src="uupg.image_url"
:alt="uupg.name"
width="240"
height="240"
decoding="async"
:loading="ui < EAGER_IMAGES ? 'eager' : 'lazy'"
:fetchpriority="ui < EAGER_IMAGES ? 'auto' : 'low'"
>
<div class="uupg__header">
<h3 class="uupg__name" v-html="uupg.name" />
<p v-if="uupg.country_code" class="uupg__country">