@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.
- package/dist/index.js +11 -1
- package/package.json +9 -1
- package/templates/base/gitignore.template +4 -1
- package/templates/integrations/strapi-astro/AGENTS.md.template +73 -10
- package/templates/integrations/strapi-astro/Dockerfile.backend.template +20 -0
- package/templates/integrations/strapi-astro/backend/config/plugins.ts.template +44 -0
- package/templates/integrations/strapi-astro/backend/src/extensions/upload/strapi-server.ts.template +253 -0
- package/templates/integrations/strapi-astro/backend/src/scripts/reprocess-media.ts.template +226 -0
- package/templates/integrations/strapi-astro/docker-compose.yml.template +2 -2
- package/templates/integrations/strapi-astro/docs/design.md.template +78 -0
- package/templates/integrations/strapi-astro/docs/performance-why.md.template +780 -0
- package/templates/integrations/strapi-astro/docs/performance.md.template +191 -0
- package/templates/integrations/strapi-astro/integration.config.json +20 -0
- package/templates/integrations/strapi-astro/nginx-cache.conf.template +13 -3
|
@@ -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.
|