Skip to content

Commit 7e59a29

Browse files
committed
Take jQuery and the oversized banner plates off the LCP path
Search Console reports 12 129 mobile URLs over the 2.5s LCP threshold, in three groups. Measured against the live sites on a throttled Moto G4 (Slow 4G, 4x CPU, cold cache), they had three different causes, and the sustained TTFB (~220ms) was not one of them. The compact inner-page strip was an LCP element nobody had counted. It renders 375x72 CSS px but was served the hero's 768x432 derivative, so object-fit:cover discarded ~85% of 57 KiB — and it carried fetchpriority="low", on the reasoning that a decorative image should not compete with content fetches. The browser was painting it above the fold regardless, so the only effect was to hold it in flight for 3.5-4.9s. It paints 25 920 px², which beats any item title shorter than about two and a half lines, and on those pages it WAS the LCP, at 3.5s. "Decorative" is not a property the LCP algorithm can read. It now has its own pre-cropped ~6.25:1 band assets at normal priority. Deliberately not preloaded: a preload is emitted before the stylesheet, so it competes with the render-blocking CSS, and the preload scanner already starts a plain <img src> near the top of <body>. jQuery and global.js are now deferred. They were render-blocking because deferring them in v2.6.14 threw "Omeka is not defined" — true, but never scoped: across all twelve live page types, core's inline `Omeka.jsTranslate = …` block is the ONLY parse-time reference to `Omeka` or `$` on the public site, and nothing inline calls `$(…)` at all. It is one problem, and the theme owns it, because the theme is what calls jsTranslate(). A three-block bridge creates the namespace before that assignment, captures what was attached, and merges it back after global.js — whose `var Omeka = {…}` replaces the object rather than extending it. citation.js, vanilla enhancement for a below-the-fold panel, is deferred too. Every served banner derivative is now grayscale. _banner.scss paints the plate through `filter: grayscale(1)` in both schemes and hides it in print, so the colour channels could never reach a screen; they were 21-28% of each file, fetched to be discarded at paint. This is what made the defer safe: on its own, deferring jQuery cost the homepage ~300ms, because a 112 KiB hero (what a DPR-3 phone picks) shared bandwidth with it for longer. The master stays in colour — it is the source of truth and gen-pwa-icons.js replays the duotone from it — so the srcset's top candidate moves to a served banner-1400.webp. Medians of 3 runs, same rig, six page types (as-is -> after): item/15850 LCP 3532 -> 1584 FCP -408 page/hajj-bf LCP 1780 -> 1244 FCP -532 item/11729 LCP 2136 -> 1924 FCP -256 item/74601 LCP 2392 -> 2204 FCP -184 overview LCP 1840 -> 1396 FCP -312 homepage LCP 2492 -> 2192 FCP -68 Omeka.jsTranslate resolves and works on all six, with no page or console errors. Recorded as a negative result: the 472 KiB inline transcript on item/15850 is NOT the problem it looks like. Stripping the <details> remainder buys ~50ms, inside run-to-run noise, because HTML streams and the LCP element sits at the top of the body. The progressive-disclosure design keeps the full text in the DOM for crawlers, Ctrl+F and print at essentially no cost, so it is unchanged. That group was failing for the banner strip, like the big one.
1 parent 0f645b3 commit 7e59a29

12 files changed

Lines changed: 214 additions & 60 deletions

asset/img/banner-1024.webp

-19.2 KB
Loading

asset/img/banner-1280.webp

-27 KB
Loading

asset/img/banner-1400.webp

113 KB
Loading

asset/img/banner-480.webp

-5.83 KB
Loading

asset/img/banner-768.webp

-12.4 KB
Loading

asset/img/banner-strip-1400.webp

26.4 KB
Loading

asset/img/banner-strip-768.webp

10.4 KB
Loading

language/fr.po

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,19 +54,19 @@ msgid_plural "Archived web pages"
5454
msgstr[0] "Page web archivée"
5555
msgstr[1] "Pages web archivées"
5656

57-
#: view/common/banner.phtml:202
57+
#: view/common/banner.phtml:222
5858
msgid "Archives consulted"
5959
msgstr "Archives consultées"
6060

61-
#: view/common/banner.phtml:204
61+
#: view/common/banner.phtml:224
6262
msgid "Audiovisual minutes"
6363
msgstr "Minutes audiovisuelles"
6464

6565
#: view/common/language-switcher.phtml:51
6666
msgid "Available languages"
6767
msgstr "Langues disponibles"
6868

69-
#: view/layout/layout.phtml:248
69+
#: view/layout/layout.phtml:339
7070
msgid "Back to top"
7171
msgstr "Haut de page"
7272

@@ -110,7 +110,7 @@ msgstr "Fermer la visionneuse"
110110
msgid "Collapse the full text"
111111
msgstr "Réduire le texte intégral"
112112

113-
#: view/common/banner.phtml:233
113+
#: view/common/banner.phtml:253
114114
msgid "Collection at a glance"
115115
msgstr "La collection en un coup d’œil"
116116

@@ -126,11 +126,11 @@ msgstr "Copié"
126126
msgid "Copy"
127127
msgstr "Copier"
128128

129-
#: view/common/banner.phtml:249
129+
#: view/common/banner.phtml:269
130130
msgid "Corpus in depth"
131131
msgstr "Le corpus en détail"
132132

133-
#: view/common/banner.phtml:186
133+
#: view/common/banner.phtml:206
134134
msgid "Countries"
135135
msgstr "Pays"
136136

@@ -150,7 +150,7 @@ msgstr ""
150150
msgid "Default"
151151
msgstr "Par défaut"
152152

153-
#: view/common/banner.phtml:203
153+
#: view/common/banner.phtml:223
154154
msgid "Document types"
155155
msgstr "Types de documents"
156156

@@ -222,7 +222,7 @@ msgstr "Image %1 sur %2"
222222
msgid "Image viewer"
223223
msgstr "Visionneuse d’images"
224224

225-
#: view/common/banner.phtml:177
225+
#: view/common/banner.phtml:197
226226
msgid "Index"
227227
msgstr "Index"
228228

@@ -246,11 +246,11 @@ msgstr ""
246246
msgid "Item: %s"
247247
msgstr "Document : %s"
248248

249-
#: view/common/banner.phtml:176 view/common/linked-resources.phtml:19 view/omeka/site/index/search.phtml:27 view/omeka/site/item/browse.phtml:38 view/omeka/site/item/browse.phtml:40
249+
#: view/common/banner.phtml:196 view/common/linked-resources.phtml:19 view/omeka/site/index/search.phtml:27 view/omeka/site/item/browse.phtml:38 view/omeka/site/item/browse.phtml:40
250250
msgid "Items"
251251
msgstr ""
252252

253-
#: view/common/banner.phtml:205
253+
#: view/common/banner.phtml:225
254254
msgid "Languages"
255255
msgstr "Langues"
256256

@@ -294,7 +294,7 @@ msgstr "Plus récents d’abord"
294294
msgid "Newspaper covers and archival documents from the collection"
295295
msgstr "Unes de journaux et documents d’archives de la collection"
296296

297-
#: view/common/banner.phtml:184
297+
#: view/common/banner.phtml:204
298298
msgid "Newspapers"
299299
msgstr "Journaux"
300300

@@ -378,7 +378,7 @@ msgstr ""
378378
msgid "Read the full text — %s more words"
379379
msgstr "Lire le texte intégral — %s mots restants"
380380

381-
#: view/common/banner.phtml:198
381+
#: view/common/banner.phtml:218
382382
msgid "References"
383383
msgstr "Références"
384384

@@ -418,7 +418,7 @@ msgstr "Menu du site"
418418
msgid "Site pages"
419419
msgstr ""
420420

421-
#: view/layout/layout.phtml:229
421+
#: view/layout/layout.phtml:315
422422
msgid "Skip to main content"
423423
msgstr "Aller au contenu principal"
424424

@@ -474,11 +474,11 @@ msgstr "Titre (Z-A)"
474474
msgid "Toggle submenu"
475475
msgstr "Afficher ou masquer le sous-menu"
476476

477-
#: view/common/banner.phtml:194
477+
#: view/common/banner.phtml:214
478478
msgid "Total pages"
479479
msgstr "Pages totales"
480480

481-
#: view/common/banner.phtml:193
481+
#: view/common/banner.phtml:213
482482
msgid "Total words"
483483
msgstr "Mots totaux"
484484

language/template.pot

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,19 @@ msgid_plural "Archived web pages"
5656
msgstr[0] ""
5757
msgstr[1] ""
5858

59-
#: view/common/banner.phtml:202
59+
#: view/common/banner.phtml:222
6060
msgid "Archives consulted"
6161
msgstr ""
6262

63-
#: view/common/banner.phtml:204
63+
#: view/common/banner.phtml:224
6464
msgid "Audiovisual minutes"
6565
msgstr ""
6666

6767
#: view/common/language-switcher.phtml:51
6868
msgid "Available languages"
6969
msgstr ""
7070

71-
#: view/layout/layout.phtml:248
71+
#: view/layout/layout.phtml:339
7272
msgid "Back to top"
7373
msgstr ""
7474

@@ -112,7 +112,7 @@ msgstr ""
112112
msgid "Collapse the full text"
113113
msgstr ""
114114

115-
#: view/common/banner.phtml:233
115+
#: view/common/banner.phtml:253
116116
msgid "Collection at a glance"
117117
msgstr ""
118118

@@ -128,11 +128,11 @@ msgstr ""
128128
msgid "Copy"
129129
msgstr ""
130130

131-
#: view/common/banner.phtml:249
131+
#: view/common/banner.phtml:269
132132
msgid "Corpus in depth"
133133
msgstr ""
134134

135-
#: view/common/banner.phtml:186
135+
#: view/common/banner.phtml:206
136136
msgid "Countries"
137137
msgstr ""
138138

@@ -152,7 +152,7 @@ msgstr ""
152152
msgid "Default"
153153
msgstr ""
154154

155-
#: view/common/banner.phtml:203
155+
#: view/common/banner.phtml:223
156156
msgid "Document types"
157157
msgstr ""
158158

@@ -224,7 +224,7 @@ msgstr ""
224224
msgid "Image viewer"
225225
msgstr ""
226226

227-
#: view/common/banner.phtml:177
227+
#: view/common/banner.phtml:197
228228
msgid "Index"
229229
msgstr ""
230230

@@ -248,11 +248,11 @@ msgstr ""
248248
msgid "Item: %s"
249249
msgstr ""
250250

251-
#: view/common/banner.phtml:176 view/common/linked-resources.phtml:19 view/omeka/site/index/search.phtml:27 view/omeka/site/item/browse.phtml:38 view/omeka/site/item/browse.phtml:40
251+
#: view/common/banner.phtml:196 view/common/linked-resources.phtml:19 view/omeka/site/index/search.phtml:27 view/omeka/site/item/browse.phtml:38 view/omeka/site/item/browse.phtml:40
252252
msgid "Items"
253253
msgstr ""
254254

255-
#: view/common/banner.phtml:205
255+
#: view/common/banner.phtml:225
256256
msgid "Languages"
257257
msgstr ""
258258

@@ -296,7 +296,7 @@ msgstr ""
296296
msgid "Newspaper covers and archival documents from the collection"
297297
msgstr ""
298298

299-
#: view/common/banner.phtml:184
299+
#: view/common/banner.phtml:204
300300
msgid "Newspapers"
301301
msgstr ""
302302

@@ -380,7 +380,7 @@ msgstr ""
380380
msgid "Read the full text — %s more words"
381381
msgstr ""
382382

383-
#: view/common/banner.phtml:198
383+
#: view/common/banner.phtml:218
384384
msgid "References"
385385
msgstr ""
386386

@@ -420,7 +420,7 @@ msgstr ""
420420
msgid "Site pages"
421421
msgstr ""
422422

423-
#: view/layout/layout.phtml:229
423+
#: view/layout/layout.phtml:315
424424
msgid "Skip to main content"
425425
msgstr ""
426426

@@ -476,11 +476,11 @@ msgstr ""
476476
msgid "Toggle submenu"
477477
msgstr ""
478478

479-
#: view/common/banner.phtml:194
479+
#: view/common/banner.phtml:214
480480
msgid "Total pages"
481481
msgstr ""
482482

483-
#: view/common/banner.phtml:193
483+
#: view/common/banner.phtml:213
484484
msgid "Total words"
485485
msgstr ""
486486

scripts/gen-banner-images.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,81 @@
77
*
88
* npm run build:images
99
*
10-
* banner.webp itself is the hand-optimized master and is left untouched (it is
11-
* already smaller than a fresh re-encode). Only the smaller variants are emitted:
10+
* Every emitted variant is GRAYSCALE. _banner.scss paints the plate with
11+
* `filter: grayscale(1)` in both colour schemes, and the banner is hidden
12+
* entirely in print, so the colour channels in these files can never reach a
13+
* screen — they were 21-28% of each file being downloaded to be discarded at
14+
* paint. The master stays in colour: it is the source of truth for regeneration
15+
* AND the input scripts/gen-pwa-icons.js replays the duotone from, and it is
16+
* never served.
1217
*
13-
* Outputs (asset/img/): banner-{480,768,1024,1280}.webp
18+
* Outputs (asset/img/): banner-{480,768,1024,1280,1400}.webp
19+
*
20+
* Plus the COMPACT STRIP variants used by the inner-page band
21+
* (.banner--compact, a full-bleed 72-112px tall strip). Those pages used to
22+
* reuse banner-768.webp -- a 768x432, 58 KiB image for a box rendered at
23+
* 375x72 CSS px, of which object-fit:cover throws away ~85% of the pixels.
24+
* On mobile that strip is an LCP *candidate* (it outranks any item title
25+
* shorter than ~2.5 lines), so those wasted bytes were landing directly on
26+
* Core Web Vitals. A pre-cropped centre band at the strip's own aspect ratio
27+
* carries the same texture in roughly a fifth of the bytes.
28+
*
29+
* Outputs (asset/img/): banner-strip-{768,1400}.webp
1430
*/
1531
const fs = require('fs');
1632
const path = require('path');
1733
const sharp = require('sharp');
1834

1935
const imgDir = path.resolve(__dirname, '..', 'asset', 'img');
2036
const master = path.join(imgDir, 'banner.webp');
21-
const WIDTHS = [480, 768, 1024, 1280];
37+
const WIDTHS = [480, 768, 1024, 1280, 1400];
2238
// The hero is a duotone background (aria-hidden, grayscale plate multiplied over
2339
// the primary ground in CSS), so it tolerates aggressive WebP compression with no
2440
// perceptible loss — q62 roughly halves each variant vs q80 and shrinks the LCP.
2541
const QUALITY = 62;
2642

43+
// Compact inner-page strip: a centre band cropped to ~6.25:1, close enough to
44+
// the rendered box that object-fit:cover discards almost nothing. 768w covers a
45+
// 375px phone at 2x DPR; 1400w (the master's full width -- never upscale) covers
46+
// desktop.
47+
const STRIP_WIDTHS = [768, 1400];
48+
const STRIP_ASPECT = 6.25;
49+
2750
(async () => {
2851
const source = await fs.promises.readFile(master);
2952
const meta = await sharp(source).metadata();
3053
console.log(`Master banner.webp: ${meta.width}x${meta.height}, ${(source.length / 1024).toFixed(1)} KiB (left as-is)`);
3154

3255
for (const w of WIDTHS) {
33-
if (w >= meta.width) continue; // never upscale; the master covers the top width
56+
if (w > meta.width) continue; // never upscale
3457
const out = path.join(imgDir, `banner-${w}.webp`);
3558
const buf = await sharp(source)
3659
.resize({ width: w })
60+
.grayscale()
3761
.webp({ quality: QUALITY, effort: 6 })
3862
.toBuffer();
3963
await fs.promises.writeFile(out, buf);
4064
console.log(` ${path.basename(out)}: ${w}px, ${(buf.length / 1024).toFixed(1)} KiB`);
4165
}
66+
// Centre band for the compact strip.
67+
const bandHeight = Math.round(meta.width / STRIP_ASPECT);
68+
const bandTop = Math.round((meta.height - bandHeight) / 2);
69+
const band = await sharp(source)
70+
.extract({ left: 0, top: bandTop, width: meta.width, height: bandHeight })
71+
.toBuffer();
72+
73+
for (const w of STRIP_WIDTHS) {
74+
if (w > meta.width) continue;
75+
const out = path.join(imgDir, `banner-strip-${w}.webp`);
76+
const buf = await sharp(band)
77+
.resize({ width: w })
78+
.grayscale()
79+
.webp({ quality: QUALITY, effort: 6 })
80+
.toBuffer();
81+
await fs.promises.writeFile(out, buf);
82+
console.log(` ${path.basename(out)}: ${w}x${Math.round(w / STRIP_ASPECT)}, ${(buf.length / 1024).toFixed(1)} KiB`);
83+
}
84+
4285
console.log('Done.');
4386
})().catch((err) => {
4487
console.error(err);

0 commit comments

Comments
 (0)