@smi-digital/create-smi-app 2.14.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,780 @@
1
+ # Performance rules
2
+
3
+ Rules for every SMI website project (Astro + Strapi). Written for both humans and
4
+ agents. Ordered by **decision point** — the question you are actually facing —
5
+ rather than by topic.
6
+
7
+ **This is the long form — every rule with the reason it exists and the mistake it
8
+ prevents.** The rules themselves, one line each, are in
9
+ **[PERFORMANCE.md](./PERFORMANCE.md)**. Read that one every session; come here when a
10
+ rule bites, when you want to argue with one, or when you need to apply one to a case it
11
+ does not literally cover. Rule numbers match exactly in both directions.
12
+
13
+ Enforcement tags: `[scaffold]` `[web-kit]` `[eslint]` `[ci]` `[review]`
14
+ A rule tagged `[review]` is currently unenforced and depends on a human noticing.
15
+
16
+ **The summary:** weight is almost all of it, and almost every fix below already
17
+ exists somewhere in the codebase. The knowledge is rarely missing. It just is not
18
+ the default.
19
+
20
+ ---
21
+
22
+ ## 0. Budgets
23
+
24
+ Numbers, so the rules can be checked rather than argued about. Measured on a
25
+ mobile Lighthouse run, per page.
26
+
27
+ | Budget | Limit |
28
+ |---|---|
29
+ | Total page weight | **≤ 1 MB** |
30
+ | All fonts on one page | **≤ 400 KB** |
31
+ | Any single font file | **≤ 100 KB** |
32
+ | Any single image | **≤ 200 KB** |
33
+ | JS on a marketing page | **0 framework bytes** |
34
+ | CSS per page | **≤ 15 KB** |
35
+ | `priority` images per page | **exactly 1** |
36
+ | LCP · TBT · CLS | **< 2.5 s · < 200 ms · < 0.1** |
37
+
38
+ ---
39
+
40
+ ## 1. Images
41
+
42
+ ### The decision: "I need to put an image on the page"
43
+
44
+ There are three answers, and the third needs a reason in writing.
45
+
46
+ | Where the image comes from | What you use |
47
+ |---|---|
48
+ | **Strapi** (any CMS media object) | `<CmsImage media sizes />` `[web-kit]` |
49
+ | **Strapi + a local fallback** | `<CmsImage media fallback sizes />` `[web-kit]` |
50
+ | **A local file you `import`** | `<Image />` from `astro:assets` |
51
+ | An icon or vector shape | inline SVG — not an image at all |
52
+ | CMS rich-text content | the renderer maps `img` → `<CmsImage>` |
53
+ | **Genuinely none of the above** | raw `<img>` **+ a comment saying why** |
54
+
55
+ **1.1 Every CMS image goes through `<CmsImage>`. Every local image goes through
56
+ `<Image>`.** `[web-kit]` `[eslint]`
57
+
58
+ Not "should", and not "unless the props are awkward". These two components are the
59
+ only places where the eight things an image needs are correct by construction:
60
+ format negotiation, a complete derivative ladder, `sizes`, `width`/`height`,
61
+ loading strategy, decoding, priority, and the CMS origin.
62
+
63
+ If `<CmsImage>`'s props do not fit a call site, that is a signal to **extend the
64
+ shared component** (§7), not to write your own. A second implementation of image
65
+ selection inside a site repo is a defect even when it is correct today, because it
66
+ will diverge.
67
+
68
+ **1.2 A raw `<img>` requires a comment explaining why nothing else was possible.**
69
+ `[eslint]` `[review]`
70
+
71
+ The comment is the point. It makes the exception reviewable, and it forces you to
72
+ articulate a reason — which is usually where you discover there is not one.
73
+
74
+ ```astro
75
+ <!-- Raw <img>: <reason nothing else works> -->
76
+ <img src={…} width={…} height={…} sizes={…} srcset={…} loading="lazy" decoding="async" />
77
+ ```
78
+
79
+ A raw `<img>` is banned not because it is wrong but because it is **incomplete by
80
+ default**. It renders perfectly with zero attributes and never fails loudly, so it
81
+ is only ever correct if someone remembers everything, every time.
82
+
83
+ **1.3 `<Image src={x} />`, never `<img src={x.src} />`.** `[eslint]`
84
+
85
+ These look nearly identical. The first is Astro's optimiser: resize, modern format,
86
+ `width`/`height`, hashed filename. The second is the raw file with a hashed name and
87
+ no processing at all.
88
+
89
+ **1.4 `sizes` is required, and it describes the CSS box — not the image.**
90
+ `[web-kit]`
91
+
92
+ The browser chooses which file to download *before* it has laid out the page, so it
93
+ cannot measure the box. You measure it for it. Omit `sizes` and the browser assumes
94
+ `100vw` and takes the largest candidate — a perfect `srcset`, silently defeated.
95
+
96
+ **1.5 Always emit `width` and `height`** from the media metadata. `[web-kit]`
97
+ Free layout-shift protection; the numbers are already in the object.
98
+
99
+ **1.6 Lazy by default. Exactly one `priority` image per page, and it must be the
100
+ LCP.** `[web-kit]` `[review]`
101
+
102
+ `loading="lazy"` + `decoding="async"` are the defaults. The single exception gets
103
+ `priority`, which sets `loading="eager"` + `fetchpriority="high"`. One prop, so it is
104
+ greppable and countable. Three separate attributes are not.
105
+
106
+ The budget is on `priority`, not on eagerness: eager loading and high fetch priority
107
+ are separate decisions, and 1.8 is the case for taking one without the other.
108
+
109
+ **1.7 `fetchpriority="high"` is for the LCP element only. Never a logo.** `[review]`
110
+ Prioritising the wrong image actively delays the right one.
111
+
112
+ **1.8 There is a third state: eager but not urgent.** `[review]`
113
+
114
+ An image that must be ready before the visitor reaches it, but must not compete with
115
+ the LCP, is `loading="eager"` + `fetchpriority="low"`. The usual case is the first
116
+ frame of a sticky or morphing section: pre-loaded so it never starts blank, explicitly
117
+ deprioritised so it does not race the hero.
118
+
119
+ This is not `priority` and does not spend the budget in 1.6. Eager answers *when* the
120
+ fetch starts; fetch priority answers *what it is worth* once started. There is exactly
121
+ one image on the page worth more than the LCP — none.
122
+
123
+ **1.9 `loading="lazy"` is about layout position, not visibility.** `[review]`
124
+
125
+ **Any layout that shows one of N things in the same box owns its own loading
126
+ schedule.** Carousels, sliders, tabs, before/after comparisons, morphing sections.
127
+ Hide an element with `clip-path`, `opacity` or `visibility` and it has not moved —
128
+ the browser still counts it as on-screen and fetches it. Several full-screen images
129
+ stacked in one sticky box all download at once, however they are marked.
130
+
131
+ Load them yourself, one step ahead of when each becomes visible, from the scroll
132
+ handler you already have.
133
+
134
+ **1.10 Never upscale.** `[review]`
135
+ If the display size exceeds the source, get a bigger source — or ask whether it
136
+ should be a raster image at all. A "curve background" is an SVG or a CSS shape.
137
+
138
+ **1.11 Optimise before committing. Source files are not build artifacts.** `[ci]`
139
+
140
+ **1.12 Rich-text renderers must map `img` → `<CmsImage>`.** `[review]`
141
+ CMS-authored markup arrives at runtime, so `[eslint]` cannot see it. `react-markdown`
142
+ takes a `components` override. `set:html` has no such hook — know that gap exists
143
+ rather than assuming lint covers it.
144
+
145
+ ### `<picture>` selection semantics
146
+
147
+ **1.13 A browser commits to the first `<source>` type it supports and never falls
148
+ through.** `[web-kit]`
149
+
150
+ There is no "try AVIF, fall back to WebP if the file is missing". The browser picks a
151
+ *format group* on the first supported `type`, then selects a width **within that
152
+ group only**. The `<img>` is reached solely by browsers that support none of the
153
+ sources.
154
+
155
+ Three consequences:
156
+
157
+ - **Every format needs a COMPLETE width ladder.** A partial AVIF ladder hands a phone
158
+ a desktop-sized file rather than falling through to a small WebP.
159
+ - **Every ladder needs a rung at source resolution** (a `full` rung). Without it, a
160
+ browser that committed to AVIF can never reach full quality however large the
161
+ original is, while the original sits unread on the `<img>`.
162
+ - **`sizes` goes on every `<source>`, not just the `<img>`.** A `<source>` without it
163
+ ignores its own srcset widths.
164
+
165
+ **1.14 Group derivatives by their `mime`, not by key name.** `[web-kit]`
166
+ Strapi stores a mime per derivative. Grouping on it means the upload pipeline can name
167
+ its keys however it likes, and stock-Strapi media and pipeline media resolve through
168
+ one code path with no branching.
169
+
170
+ **1.15 `picture { display: contents }` in the global reset.** `[scaffold]`
171
+ Wrapping an `<img>` in a `<picture>` inserts a box into layouts that expected the
172
+ `<img>` to be the direct child — absolutely-positioned images, flex and grid children.
173
+ `display: contents` keeps the wrapper out of layout entirely.
174
+
175
+ ### Strapi upload pipeline
176
+
177
+ **1.16 The derivative ladder must reach your largest display size.** `[scaffold]`
178
+
179
+ Stock Strapi tops out at **1000px**. A full-bleed hero on a 1440px 2× screen needs
180
+ ~2880px. With no rung that high the only candidate is the untouched original, which is
181
+ how camera JPEGs end up in hero slots.
182
+
183
+ ```ts
184
+ // backend/config/plugins.ts
185
+ upload: {
186
+ config: {
187
+ breakpoints: { xsmall: 320, small: 640, medium: 1024, large: 1600, xlarge: 2400 },
188
+ },
189
+ },
190
+ ```
191
+
192
+ **1.17 Convert format at upload, not at request.** `[scaffold]`
193
+
194
+ Strapi resizes but does **not** convert — JPEG in, JPEG out. Without a conversion step
195
+ the delivered format is decided by whatever the editor happened to drag in.
196
+
197
+ Add a sharp-based override on the upload plugin's `image-manipulation` service that
198
+ emits a complete AVIF + WebP ladder (1.13) and caps the stored original. Never ask a
199
+ client to export WebP.
200
+
201
+ **1.18 Re-processing existing media is a required step, not a caveat.**
202
+ `[scaffold]` `[review]`
203
+
204
+ Strapi only runs the pipeline **on upload**. Adding it improves nothing already in the
205
+ library; until existing media is re-processed the change is inert.
206
+
207
+ Ship the pipeline together with a re-processing script, and make that script **walk
208
+ the whole library by default**. Requiring the caller to advance an `offset` is a trap:
209
+ a re-run over already-processed files reports them as `skipped` and exits
210
+ successfully, so a partial conversion is indistinguishable from a complete one.
211
+
212
+ The script must be dry-run by default, idempotent, batched (image encoding is
213
+ CPU-heavy and runs inside the live container), and preceded by a database **and**
214
+ uploads backup.
215
+
216
+ **1.19 Do not compensate in the frontend for media the pipeline has not processed.**
217
+ `[review]`
218
+
219
+ The temptation is to withhold an oversized original from the srcset so the browser
220
+ picks a smaller derivative. It buys nothing and costs quality:
221
+
222
+ - On a **processed** image, modern-format sources exist, so the original is never
223
+ downloaded anyway (1.13) — withholding it changes nothing.
224
+ - On an **unprocessed** image, the original is the only path to full resolution.
225
+ Withholding it caps the image at the stock 1000px ladder, visibly soft on any
226
+ screen wider than that.
227
+
228
+ Dead weight in the good case, a regression in the bad one. Process the image.
229
+
230
+ ### Format guide
231
+
232
+ | Format | Use for |
233
+ |---|---|
234
+ | **AVIF** | photos, gradients (~50% under JPEG) |
235
+ | **WebP** | same, safer fallback (~30% under JPEG) |
236
+ | **JPEG** | photos, no transparency |
237
+ | **PNG** | flat-colour graphics only — often *larger* than JPEG for photos |
238
+ | **SVG** | shapes, logos, icons |
239
+
240
+ ---
241
+
242
+ ## 2. Fonts
243
+
244
+ **2.1 No icon fonts. Ever. Inline SVG.** `[scaffold]` `[review]`
245
+
246
+ This single rule is worth more than everything else in this document. A full icon
247
+ font runs from hundreds of KB to several MB; the handful of glyphs a site actually
248
+ uses are ~2 KB as inline SVG.
249
+
250
+ Icon fonts also fail visibly, not just slowly. Ligature-based sets render the
251
+ **literal ligature text** while the font is pending under `font-display: swap`, so
252
+ visitors see the words "download" or "expand_more" at icon size for the whole load.
253
+
254
+ If you find a workaround mapping icon names to literal glyphs, that is the symptom.
255
+ Remove the font.
256
+
257
+ **2.2 Never download a font file by hand when Fontsource has it.** `[scaffold]`
258
+
259
+ ```
260
+ npm i @fontsource-variable/<family>
261
+ ```
262
+
263
+ Self-hosting is correct — it is the GDPR position and it does not change. But Google
264
+ Fonts was never only a CDN; it was also a **subsetting service**. Take the source file
265
+ from a repo and you inherit every script on earth: a full-charset weight is ~60 KB
266
+ where the Latin slice is ~16 KB, and that multiplies by every weight you declare.
267
+
268
+ Fontsource files install into `node_modules`, are bundled by Vite, and are served from
269
+ your own domain. Renovate version-manages them. Nothing reaches Google.
270
+
271
+ **2.3 Import font CSS in `Layout.astro` frontmatter, not in SCSS.** `[review]`
272
+ Sass runs before Vite and cannot resolve bare package names.
273
+
274
+ **2.4 Subsetting: prefer `unicode-range` slices.** `[review]`
275
+
276
+ `unicode-range` is the safe default because content is editor-controlled: the extended
277
+ slice is fetched only when such a character appears, so a pasted Polish name or a `€`
278
+ still renders. A single hard-subset file renders tofu instead.
279
+
280
+ Hard-subsetting a licensed face that ships far more than the site needs is legitimate,
281
+ but it has a procedure and it is one-way — see **Appendix B**.
282
+
283
+ **Check the licence first.** Many commercial EULAs forbid modification, and subsetting
284
+ *is* modification.
285
+
286
+ **2.5 Declare only the weights you use.** `[review]`
287
+ A declared-but-unused `@font-face` does not download, but it is a standing invitation
288
+ — and it is weight in git, slowing every clone and Docker build.
289
+
290
+ **2.6 Five or more weights → variable font. Two or three → statics.** `[review]`
291
+ Pick one strategy per family.
292
+
293
+ **2.7 Preload only the weights that render above the fold — verified by looking.**
294
+ `[review]`
295
+ Preloading a weight you do not use above the fold is strictly negative: it competes
296
+ with the ones you do. A variable font dissolves this problem — one file covers every
297
+ weight, so there is exactly one thing to preload and it is always right.
298
+
299
+ **2.8 woff2 only. `font-display: swap` always.**
300
+
301
+ **2.9 `font-display: swap` protects rendering, not bandwidth.** `[review]`
302
+
303
+ This is why fonts go unnoticed. `swap` stops a font blocking first paint — it does
304
+ **not** stop the download competing for the connection. A healthy FCP alongside a
305
+ terrible LCP is the signature: the font never blocked the paint, it ate the pipe, and
306
+ the images behind it arrived late.
307
+
308
+ **2.10 A custom or licensed face has failure modes a Fontsource package does not.**
309
+ `[review]`
310
+
311
+ If you are subsetting one, trimming baselines against its metrics, or rasterising its
312
+ text into a canvas, read **Appendix B** before starting. Each of those has a way to
313
+ look correct and be silently wrong, and none of them is reachable by lint.
314
+
315
+ ---
316
+
317
+ ## 3. JavaScript & hydration
318
+
319
+ ### Before creating an island
320
+
321
+ **3.1 Prefer the platform.** `[review]`
322
+ `<details>`/`<summary>` for accordions. `<dialog>` for modals. CSS `:hover` /
323
+ `:focus-within` for dropdowns. CSS transitions for reveals. Platform features work
324
+ *before* hydration, are keyboard-accessible by default, and cost nothing.
325
+
326
+ **3.2 Never re-implement in JavaScript what CSS does natively.** `[review]`
327
+ Especially breakpoints. It duplicates a media query, adds a resize listener, and causes
328
+ a flash — the server renders one state and JS corrects it after hydration.
329
+
330
+ **3.3 The three-question test.** `[review]`
331
+ - Does it need state that survives across interactions?
332
+ - Does it need to re-render from changing data?
333
+ - Would writing it without a framework genuinely be worse?
334
+
335
+ **All three "no" means it is not an island.**
336
+
337
+ ### Where a framework is allowed
338
+
339
+ **3.4 Marketing pages target zero islands.** `[review]`
340
+ Astro ships framework JS **per page**. A page with no islands ships no framework at
341
+ all — so the first trivial island is the most expensive thing on a marketing page: the
342
+ entire client runtime arrives before a line of your own code does. A menu toggle is
343
+ enough to pay it in full.
344
+
345
+ **The number, and what it is attached to.** ~184 KB raw / ~67 KB brotli is
346
+ `react` + `react-dom` **19.2** via `@astrojs/react` **6** on **Astro 7** — the fleet
347
+ default as of August 2026.
348
+
349
+ That figure is not portable. It does not hold for Preact, Svelte or Solid, it will not
350
+ hold across a React major, and quoting it after either has changed is how a true rule
351
+ acquires a false number. Re-measure in the project you are actually in:
352
+
353
+ ```bash
354
+ npm run build
355
+ ls -lS dist/_astro/*.js | head # the framework chunks are the large ones
356
+ ```
357
+
358
+ What survives the re-measure is the shape of the rule, which does not depend on the
359
+ figure: **the first island pays a fixed runtime cost that the second one does not.**
360
+
361
+ **3.5 Tool and app pages may use a framework freely.**
362
+ Multi-step forms, generators, anything where the page *is* the application.
363
+
364
+ **3.6 Adding an island to a page that had none is a bigger change than it looks.**
365
+ Say so in the PR description. `[review]`
366
+
367
+ ### How it hydrates
368
+
369
+ **3.7 `client:visible` is the default. `client:load` needs a written justification.**
370
+ `[review]`
371
+
372
+ **3.8 Use `client:media` for anything that exists at only one breakpoint or
373
+ capability.** `[review]`
374
+ `client:media="(max-width: 767px)"` is never downloaded, parsed or executed on desktop.
375
+ The same applies to capability: an effect requiring `(hover: hover) and (pointer: fine)`
376
+ should be gated on it rather than hydrating on a phone to discover it has nothing to do.
377
+
378
+ **3.9 `client:only` is a last resort, and never above the fold.** `[review]`
379
+ It skips server rendering entirely: a hole in the HTML, a jump when it fills, nothing
380
+ for crawlers.
381
+
382
+ **3.10 An island can only be as lazy as its most urgent job.** `[review]`
383
+ One island holding several pieces of state doing several jobs must hydrate for the most
384
+ urgent of them, and everything else ships as collateral.
385
+
386
+ ### Shape
387
+
388
+ **3.11 Islands are leaves, not trunks.** `[review]`
389
+ If an island wraps a large subtree, *all of it* becomes JavaScript — including the
390
+ static text. Keep the section as `.astro`; make the interactive control the island.
391
+
392
+ **3.12 One island, one job.** A component that needs splitting to be lazy was always
393
+ two components.
394
+
395
+ ### Never
396
+
397
+ **3.13 Never gate visible content behind an async operation.** `[review]`
398
+ Server-render the markup; let JavaScript decide only whether to *show* it. An element
399
+ that cannot paint until a promise resolves cannot be the LCP, so something else — often
400
+ something you did not want — becomes it instead.
401
+
402
+ **3.14 Never duplicate a decision already made server-side or in an inline script.**
403
+ `[review]`
404
+ The later copy loses a race it should not be running.
405
+
406
+ ### Dependencies
407
+
408
+ **3.15 No unused or duplicate dependencies.** `[ci]`
409
+ Two markdown renderers, or a component library imported in zero files, are both common
410
+ and both invisible without a check.
411
+
412
+ **3.16 Delete dead islands.** `[ci]`
413
+
414
+ ---
415
+
416
+ ## 4. CSS
417
+
418
+ ### What ships
419
+
420
+ **4.1 A dynamic block renderer must import blocks dynamically.** `[review]`
421
+ CSS follows the **import graph**, not the render output. A static import bundles the
422
+ styles whether or not the component renders, so a page rendering three sections
423
+ downloads the CSS for all of them.
424
+
425
+ **4.2 Adding a section type must not make every existing page heavier.** `[review]`
426
+ If it does, the architecture is wrong regardless of today's numbers.
427
+
428
+ **4.3 Co-located CSS modules only pay off if the imports are per-page.** `[review]`
429
+
430
+ ### Delivery
431
+
432
+ **4.4 Below-the-fold CSS must never block the first paint.** `[review]`
433
+
434
+ **4.5 Choose the inlining threshold deliberately. `"auto"` is a default, not a
435
+ decision.** `[scaffold]`
436
+ Astro's `"auto"` inlines under 4 KB. Most real stylesheets clear it, so `"auto"` quietly
437
+ resolves to *"inline nothing"* — extra round trips before paint.
438
+
439
+ **4.6 Shrink before you inline.** `[review]`
440
+ Inlining CSS you have not first made small moves the waste out of a cacheable file into
441
+ every HTML response. Worse for anyone who visits a second page.
442
+
443
+ **4.7 Match the strategy to the visit pattern, and measure it.** `[review]`
444
+ Small CSS + single-page arrivals → inline. Large CSS + multi-page → external.
445
+
446
+ Measure both through **brotli**, counting bytes *and* requests *and* render-blocking
447
+ resources; localhost serves uncompressed and will mislead you. Inlined CSS compresses
448
+ better than an external file because it shares a dictionary with the HTML, but it is
449
+ only cheaper on the *first* page — the same measurement will show a multi-page session
450
+ getting worse. Record the numbers in the commit message so the trade is not
451
+ re-litigated blind.
452
+
453
+ **4.8 If CSS is too big to inline, make it smaller — do not load it more cleverly.**
454
+ Critical-CSS extraction is for codebases you cannot restructure. You can: see 4.1.
455
+
456
+ ### Tooling
457
+
458
+ **4.9 PurgeCSS is a safety net, not a fix.** `[review]`
459
+ Find where the dead CSS comes from first. "Unused CSS" is usually 4.1, and PurgeCSS
460
+ would hide that rather than solve it.
461
+
462
+ **4.10 If you run PurgeCSS, the safelist must cover every class name the scanner cannot
463
+ see as a literal string.** `[review]`
464
+ Runtime-constructed names (`` `btn-${variant}` ``), `classList.add()`, and
465
+ framework-generated names all vanish silently — no error, no warning, missing styles in
466
+ production. Hashed CSS-module names need a `greedy` pattern; data-attribute state
467
+ selectors need explicit entries.
468
+
469
+ **4.11 Verify against the built output, never the dev server.** `[review]`
470
+ Code splitting, inlining thresholds and PurgeCSS only take effect at build. None of this
471
+ section is visible in `astro dev`.
472
+
473
+ **4.12 Every site runs the same CSS pipeline.** `[scaffold]`
474
+
475
+ ---
476
+
477
+ ## 5. Animation & the main thread
478
+
479
+ Every rule here follows from one fact. To draw a frame the browser does:
480
+
481
+ ```
482
+ Style → Layout → Paint → Composite
483
+ ```
484
+
485
+ Each frame has 16 ms. What matters is **how far back your animated property reaches**:
486
+
487
+ | Property | Reaches | Cost |
488
+ |---|---|---|
489
+ | `transform`, `opacity` | Composite only | GPU. Main thread free. |
490
+ | `filter`, `box-shadow`, `background`, `border-radius` | Paint | Repaint every frame |
491
+ | `width`, `height`, `top`, `left`, `margin` | Layout | Recompute geometry every frame |
492
+
493
+ **5.1 Animate `transform` and `opacity`. Nothing else.** `[review]`
494
+
495
+ **5.2 For a shadow, glow or colour transition, move it to a pseudo-element and
496
+ transition that element's `opacity`.** `[review]`
497
+ Visually identical, compositor-only.
498
+
499
+ **5.3 Never transition a layout property.** Use `transform: scale()` / `translate()`.
500
+
501
+ **5.4 In any scroll or resize handler: all reads first, then all writes.** `[review]`
502
+
503
+ Reading a geometric property after a write forces the browser to compute layout
504
+ *immediately*. Interleave them and every read forces a layout the next write
505
+ invalidates — one handler can cost several forced layouts per scroll frame.
506
+
507
+ ```js
508
+ // read phase — no writes
509
+ const h = el.offsetHeight, rect = el.getBoundingClientRect();
510
+ // compute phase — no DOM access
511
+ // write phase — no reads
512
+ el.style.setProperty("--p", …);
513
+ ```
514
+
515
+ **5.5 Nothing above the fold may depend on JavaScript to become visible.** `[review]`
516
+ Reveal animations start below the fold. The first screen renders from HTML and CSS
517
+ alone. A hero that starts at `opacity: 0` is disqualified from being the LCP element,
518
+ so something further down the page becomes it instead.
519
+
520
+ **5.6 Nothing may animate *into position* on load.** `[review]`
521
+
522
+ A scroll-linked effect must **place** on its first reading, not ease into it. Easing
523
+ exists to smooth *scrolling*; on load there is nothing to smooth, because the element
524
+ has never been anywhere else.
525
+
526
+ An eased follow that starts at `0` and converges on a scroll-derived target runs for
527
+ `ln(threshold) / ln(1 − factor)` frames — with a typical 0.12 factor and a 0.0004
528
+ threshold that is **≈61 frames**, about a second. Every one of those frames mutates a
529
+ transform, forcing layer updates and raster work **while the browser is still trying to
530
+ produce its first paint**. The result is dropped frames and a first paint that slips by
531
+ one to two seconds, intermittently.
532
+
533
+ This is hard to find because it presents as Speed Index varying between runs with no
534
+ code change, and the cause is usually below the fold in a component nobody suspects.
535
+
536
+ The fix is a `primed` flag: snap on the first reading, animate on every one after.
537
+
538
+ **5.7 `will-change` is temporary.** Add before, remove after. A permanent `will-change`
539
+ is a permanent GPU layer, and too many layers cost more than compositing saves.
540
+ `[review]`
541
+
542
+ **5.8 One animation system per site.** `[review]`
543
+ An animation library alongside a hand-written IntersectionObserver system means every
544
+ future component picks the wrong one half the time.
545
+
546
+ **5.9 Every animation has a `prefers-reduced-motion` path.**
547
+
548
+ ---
549
+
550
+ ## 6. Delivery
551
+
552
+ Cache headers, compression config and TLS live in **`webhosting-infra`**. What follows
553
+ is the per-repo slice — and the two ways a correct edge config gets silently defeated
554
+ by the site itself.
555
+
556
+ **6.1 Every site's router must include `compress@file`.** `[scaffold]`
557
+
558
+ ```yaml
559
+ # docker-compose.yml
560
+ - "traefik.http.routers.<app>-astro.middlewares=secure-headers@file,rate-limit@file,compress@file"
561
+ ```
562
+
563
+ Traefik's compress middleware provides **Brotli** and sets `Vary: Accept-Encoding`.
564
+
565
+ **6.2 The site's own nginx must not compress.** `[scaffold]`
566
+
567
+ ```nginx
568
+ gzip off; # compression is Traefik's job
569
+ ```
570
+
571
+ This is the other half of 6.1 and neither works alone. The compress middleware **skips
572
+ any response that already carries a `Content-Encoding`**, so a gzipping origin disables
573
+ Brotli at the edge entirely — for every asset and every document, with no error
574
+ anywhere. Brotli is worth roughly 20% over gzip on HTML.
575
+
576
+ **6.3 `add_header` appends; it does not replace.** `[scaffold]`
577
+
578
+ ```nginx
579
+ location /uploads/ {
580
+ proxy_hide_header Cache-Control; # required
581
+ add_header Cache-Control "public, max-age=31536000, immutable";
582
+ }
583
+ ```
584
+
585
+ `proxy_ignore_headers` governs only what **nginx itself caches**; the upstream header
586
+ still reaches the client. Without `proxy_hide_header` the response carries two
587
+ contradictory `Cache-Control` values, browsers resolve conservatively, and every upload
588
+ is re-fetched on every visit. This is entirely invisible in Lighthouse, which only ever
589
+ measures a cold load.
590
+
591
+ Strapi hashes upload filenames, so uploads are genuinely immutable — `max-age` should be
592
+ a year, with `immutable`.
593
+
594
+ **6.4 Preconnect to the CMS origin in the base layout.** `[scaffold]`
595
+
596
+ ```astro
597
+ <link rel="preconnect" href={new URL(import.meta.env.PUBLIC_STRAPI_URL).origin} />
598
+ ```
599
+
600
+ Must sit in `<head>` **before** the stylesheets — a preconnect after 15 KB of CSS links
601
+ has already lost its head start. Maximum four preconnects; an unused socket is dropped
602
+ after ~10 s.
603
+
604
+ **6.5 Preload the LCP image — once per page, and only if it is late-discovered.**
605
+ `[review]`
606
+
607
+ Both conditions must hold: it **is** the LCP element, **and** the preload scanner cannot
608
+ find it early (CSS `background-image`, JS-injected, deep in the document). A plain
609
+ `<img>` near the top of the HTML is already found.
610
+
611
+ With a responsive image use `imagesrcset` / `imagesizes` and match the `<img>`
612
+ **exactly** — a plain `href` hardcodes one candidate and you download two files.
613
+
614
+ **Preload fixes discovery, never weight.** If a resource is late because the page is
615
+ heavy, preloading it just reorders the queue.
616
+
617
+ ---
618
+
619
+ ## 7. Shared code
620
+
621
+ Image selection, icons and other cross-site primitives live in
622
+ **`@smi-digital/web-kit`**.
623
+
624
+ **7.1 Never copy code out of web-kit into a site.** `[review]`
625
+
626
+ A copy is correct exactly once. It has no version, nothing points at it, and the first
627
+ local tweak forks the fleet silently — two behaviours, one name, and no way to tell
628
+ which site has which.
629
+
630
+ If the shared version does not do what you need, that is the signal to change the shared
631
+ version.
632
+
633
+ **7.2 Extend upstream with an optional parameter that defaults to current behaviour.**
634
+ `[review]`
635
+
636
+ A new option that is `undefined` by default cannot change any existing consumer, so the
637
+ change is safe to land before anyone has adopted it. Add the tests in web-kit, where
638
+ every site inherits them.
639
+
640
+ **7.3 A site may use the helper instead of the component — but never its own copy of
641
+ the logic.** `[review]`
642
+
643
+ `<CmsImage>` is the default (1.1). Where a site genuinely needs its own wrapper, it
644
+ imports the shared selection function and wraps *that*. The distinction that matters is
645
+ not component-vs-helper; it is **shared-vs-forked**.
646
+
647
+ **7.4 web-kit ships source, not a build.** `[scaffold]`
648
+
649
+ ```js
650
+ // astro.config.mjs
651
+ vite: { ssr: { noExternal: ['@smi-digital/web-kit'] } }
652
+ ```
653
+
654
+ Without this the build fails in a way that does not mention the cause.
655
+
656
+ ---
657
+
658
+ ## 8. Verification
659
+
660
+ Rules you cannot test are rules people guess at.
661
+
662
+ | Check | How | Expect |
663
+ |---|---|---|
664
+ | Fonts | DevTools → Network, filter `font` | **1** file on a German page |
665
+ | Font metrics *(custom faces only)* | read the binary — Appendix B.2 | table matches what the browser uses |
666
+ | Brotli | `curl -sI -H "Accept-Encoding: br" <url>` | `content-encoding: br` |
667
+ | Cache headers | `curl -sI <cms>/uploads/<file>` | **one** `Cache-Control`, `immutable` |
668
+ | Preconnect | Lighthouse → "Preconnected origins" | your CMS origin listed |
669
+ | Modern formats | page HTML | `<source type="image/avif">` before `image/webp` before `<img>` |
670
+ | Image sizing | Lighthouse → "Improve image delivery" | no entries |
671
+ | `priority` images | grep `priority` per page | **1**, and it is the LCP |
672
+ | Eager, low-priority images | grep `fetchpriority="low"` | each one justified by 1.8 |
673
+ | Render-blocking | Lighthouse | 0 stylesheets |
674
+ | Forced reflow | DevTools → Performance, 4× CPU throttle | no purple layout bars in scroll |
675
+ | Layout-defeated lazy | Network tab while **not** scrolling | only above-fold images |
676
+ | Load-time animation | trace → count `DroppedFrame` before first paint | **0** |
677
+
678
+ ### How to measure
679
+
680
+ **8.1 Verify on the built output, not `astro dev`.** Splitting, inlining, PurgeCSS and
681
+ asset optimisation only exist at build.
682
+
683
+ **8.2 Verify on a throttled device profile.** Every cost in this document is invisible on
684
+ a laptop and dominates on a low-end phone.
685
+
686
+ **8.3 Speed Index is bimodal. Take the median of five runs.** `[review]`
687
+ A single run cannot distinguish noise from a regression, and two runs cannot either.
688
+ Expect a wide spread whenever anything animates on load (5.6).
689
+
690
+ **8.4 Measure the rendered output, not the intended arithmetic.** `[review]`
691
+ The most expensive debugging mistake available is to recompute what the code *should*
692
+ produce from live values, find it correct, and conclude the code is correct. That
693
+ verifies the formula, not the pixels. Screenshot it, read the actual pixels, or overlay
694
+ a reference — then compare.
695
+
696
+ **8.5 When first paint is late, check whether anything was still loading.** `[review]`
697
+
698
+ ```
699
+ observedLoad 370 ms
700
+ observedFirstPaint 2300 ms ← everything had finished; this is not network
701
+ ```
702
+
703
+ If load completes and paint does not follow, it is not bandwidth — it is the main thread
704
+ or the compositor. Save the trace and count `DroppedFrame`, `UpdateLayer` and `Commit` in
705
+ the pre-paint window. Dropped frames before first paint mean something is animating that
706
+ should not be.
707
+
708
+ ---
709
+
710
+ ## Appendix A — the sentences worth remembering
711
+
712
+ 1. **Weight is 95% of the problem.** Fonts and images.
713
+ 2. **Icons are graphics, not typography.**
714
+ 3. **A raw `<img>` is banned because it is incomplete by default**, not because it is
715
+ wrong. `<CmsImage>` for CMS media, `<Image>` for local files, a written reason for
716
+ anything else.
717
+ 4. **The first island costs a whole framework runtime** (§3.4 — measure it, do not
718
+ quote it). Astro will happily sell you zero.
719
+ 5. **`transform` and `opacity` are the only two free properties** — and nothing should be
720
+ using them at all during the first paint.
721
+ 6. **`<picture>` never falls through.** Every format needs a complete ladder.
722
+ 7. **A shared component copied into a site is a fork**, however correct the copy is.
723
+ 8. **Measure the pixels, not the formula**, and take the median of five.
724
+
725
+ ---
726
+
727
+ ## Appendix B — Custom font work
728
+
729
+ Only relevant when a site uses a licensed face rather than a Fontsource package. Skip
730
+ this unless you are subsetting a font, using baseline trimming, or drawing text into a
731
+ canvas.
732
+
733
+ ### B.1 Hard-subsetting
734
+
735
+ A full-charset face is typically 3–4× the size a European-language site needs.
736
+ Subsetting one is legitimate, but only with all four of these:
737
+
738
+ 1. **A script in the repo**, so it is reproducible when the font is next rebuilt.
739
+ 2. **A coverage check against real content** — extract every distinct character from
740
+ the rendered pages and assert none is missing. Do not eyeball the range; the
741
+ characters that get dropped are the ones nobody thinks of, like an arrow
742
+ affordance or a close control.
743
+ 3. **Metrics verified unchanged** before and after (B.2).
744
+ 4. **A note that the operation is one-way**: a subset cannot be widened from its own
745
+ output, so pre-subset binaries must stay recoverable from git history.
746
+
747
+ ### B.2 Verify metrics against the binary, and check `USE_TYPO_METRICS`
748
+
749
+ Any baseline-trimming system (Capsize and equivalents) does arithmetic from a table of
750
+ the font's ascent, descent, line gap and cap height. If that table does not match what
751
+ the **browser** uses, every trimmed element is silently offset.
752
+
753
+ The trap: metric tables are usually transcribed from `hhea`. But if the font sets
754
+ `OS/2.fsSelection` bit 7 — `USE_TYPO_METRICS` — browsers build the line box from
755
+ **`sTypo`** instead. Foundry spec sheets and font-info sites will not tell you which
756
+ applies.
757
+
758
+ ```
759
+ # read the binary, not the spec sheet
760
+ hhea.ascent / hhea.descent / hhea.lineGap
761
+ OS/2.sTypoAscender / sTypoDescender / sTypoLineGap
762
+ OS/2.sCapHeight
763
+ OS/2.fsSelection bit 7 ← if set, sTypo wins
764
+ ```
765
+
766
+ The error is a fraction of an em, so it scales with font size: invisible in body copy,
767
+ and tens of pixels in display type, where it reads as text sitting in the wrong place.
768
+
769
+ ### B.3 Rasterising text into a canvas
770
+
771
+ If an effect draws text into a `<canvas>` and swaps it in for the DOM text:
772
+
773
+ - **Wait for `document.fonts.ready` before drawing.** Rasterise early and you bake in
774
+ the fallback face, then repaint when the real font arrives — a visible jump.
775
+ - **Measure the baseline; do not derive it.** A zero-height inline-block with
776
+ `vertical-align: baseline` reports where the baseline actually is, whatever the
777
+ metric tables say (B.2).
778
+ - **Accept that it will never be pixel-identical.** GPU resampling, alpha blending and
779
+ a fractional `devicePixelRatio` all differ from native text rendering. If the swap
780
+ must be invisible, do not swap at rest.