@sumaq/site-kit 0.4.1 → 0.5.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/README.md CHANGED
@@ -69,13 +69,15 @@ Nothing serves `/media/portrait-a3f9c1d2.webp`. `Media` looks the key up in a bu
69
69
 
70
70
  | `src` | What happens |
71
71
  |---|---|
72
- | `/media/x.webp` present in `./media/` | `<Image>` with `srcset` at 640/960/1280/2000 (clamped to the original) plus the manifest's LQIP as a background |
72
+ | `/media/x.webp` present in `./media/` | `<Image>` with `srcset` at 640/960/1280/1600/1920 (clamped to the original) plus the manifest's LQIP as a background |
73
73
  | `/media/x.svg` | `<img>` with the emitted URL — imported SVGs become components in Astro 7, and this keeps them images |
74
74
  | `https://…` or any `public/` path | plain `<img>`, untouched |
75
75
  | an `ImageMetadata` import | `<Image>`, as before |
76
76
  | empty | nothing — unless `fallback` is set |
77
77
 
78
- `media/manifest.json`, written by the CMS, carries `{ alt, width, height, lqip }` per key. A site without one simply renders without LQIP.
78
+ `media/manifest.json`, written by the CMS, carries `{ alt, width, height, lqip, alpha }` per key. A site without one simply renders without LQIP.
79
+
80
+ The LQIP is painted as the `<img>`'s own background and **removed as soon as the real image loads**. It has to be: the blur sits behind the picture, so anything the foreground does not cover keeps showing it — through the transparent pixels of a cut-out, or beside the picture when the CSS box and the file disagree on aspect ratio, where it reads as a shadow under the subject. An image the manifest marks `alpha: true` gets no LQIP at all.
79
81
 
80
82
  ### Placeholder
81
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sumaq/site-kit",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Layouts, blocks, helpers and Astro config factory for Sumaq client sites.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "elingan",
@@ -105,13 +105,30 @@ const asset: ImageMetadata | null = usePlaceholder
105
105
  /** Content images get the full srcset; a hand-passed import keeps its own props. */
106
106
  const assetWidths = widths ?? (localAsset || usePlaceholder ? DEFAULT_WIDTHS : undefined);
107
107
 
108
- /** LQIP from the manifest, shown behind the image while it loads. */
109
- const lqipStyle = meta?.lqip
110
- ? `background-image:url(${meta.lqip});background-size:cover;background-position:center`
108
+ /**
109
+ * LQIP from the manifest, painted as the image's own background while it loads
110
+ * and dropped by the inline script below once it has.
111
+ *
112
+ * It has to go once: the blur sits *behind* the picture, so on anything the
113
+ * foreground does not cover completely it stays visible for the life of the
114
+ * page — through the transparent pixels of a cut-out, and beside the picture
115
+ * whenever the CSS box and the file disagree on aspect ratio (there
116
+ * `background-position` and `object-position` drift apart and the leftover blur
117
+ * reads as a shadow under the subject).
118
+ *
119
+ * An image with an alpha channel gets no LQIP at all: for a cut-out the blurred
120
+ * rectangle is wrong even while it loads, since the real image will never fill
121
+ * that shape.
122
+ */
123
+ const showLqip = Boolean(meta?.lqip) && meta?.alpha !== true;
124
+ const lqipStyle = showLqip
125
+ ? `background-image:url(${meta!.lqip});background-size:cover;background-position:center;background-repeat:no-repeat`
111
126
  : undefined;
112
127
  const composedStyle = [style, usePlaceholder ? undefined : lqipStyle]
113
128
  .filter(Boolean)
114
129
  .join(";") || undefined;
130
+ /** Marks the images the cleanup script owns. */
131
+ const lqipFlag = !usePlaceholder && showLqip ? "" : undefined;
115
132
 
116
133
  /**
117
134
  * An SVG gets its dimensions written out by hand (it skips `<Image>`), so a
@@ -145,6 +162,7 @@ const passthrough = !asset && !missing;
145
162
  decoding={decoding}
146
163
  fetchpriority={fetchpriority}
147
164
  data-cms-src={cms}
165
+ data-lqip={lqipFlag}
148
166
  />
149
167
  ) : (
150
168
  <Image
@@ -161,6 +179,7 @@ const passthrough = !asset && !missing;
161
179
  decoding={decoding}
162
180
  fetchpriority={fetchpriority}
163
181
  data-cms-src={cms}
182
+ data-lqip={lqipFlag}
164
183
  />
165
184
  ))
166
185
  }
@@ -179,6 +198,40 @@ const passthrough = !asset && !missing;
179
198
  decoding={decoding}
180
199
  fetchpriority={fetchpriority}
181
200
  data-cms-src={cms}
201
+ data-lqip={lqipFlag}
182
202
  />
183
203
  )
184
204
  }
205
+
206
+ <script>
207
+ /**
208
+ * Removes the LQIP once the real image is on screen — see `lqipStyle` above
209
+ * for why leaving it in place is not harmless.
210
+ *
211
+ * `astro:page-load` fires on the first load and after every navigation made
212
+ * by `<ClientRouter />`, which does not re-execute this module on its own.
213
+ * Clearing the attribute makes a second pass over the same image a no-op.
214
+ */
215
+ const clear = (img) => {
216
+ img.style.backgroundImage = "none";
217
+ img.removeAttribute("data-lqip");
218
+ };
219
+
220
+ const settle = (img) => {
221
+ if (img.complete && img.naturalWidth > 0) {
222
+ clear(img);
223
+ return;
224
+ }
225
+ // One frame later: the browser has painted the decoded image by then, so
226
+ // dropping the background underneath it is invisible.
227
+ img.addEventListener("load", () => requestAnimationFrame(() => clear(img)), { once: true });
228
+ img.addEventListener("error", () => clear(img), { once: true });
229
+ };
230
+
231
+ const initLqip = () => {
232
+ for (const img of document.querySelectorAll("img[data-lqip]")) settle(img);
233
+ };
234
+
235
+ initLqip();
236
+ document.addEventListener("astro:page-load", initLqip);
237
+ </script>
package/src/lib/media.ts CHANGED
@@ -20,8 +20,16 @@
20
20
  */
21
21
  import type { ImageMetadata } from "astro";
22
22
 
23
- /** Widths the CMS master (2000px) is resized to. Astro clamps to the original. */
24
- export const DEFAULT_WIDTHS = [640, 960, 1280, 2000];
23
+ /**
24
+ * Widths the CMS master (2000px) is resized to. Astro clamps every entry to the
25
+ * original, so a small upload still emits only the sizes it really has.
26
+ *
27
+ * The steps are ~25% apart on purpose: below that a browser saves too little
28
+ * bandwidth to pay for one more file in the build. 640 covers a phone at 1x and
29
+ * a 320px column at 2x, 960 a 480px column at 2x, 1280/1600 laptops, and 1920
30
+ * the full-bleed desktop case.
31
+ */
32
+ export const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920];
25
33
 
26
34
  /** The prefix that marks a string as a local key instead of a URL. */
27
35
  export const MEDIA_PREFIX = "/media/";
@@ -33,6 +41,12 @@ export interface MediaManifestEntry {
33
41
  height?: number;
34
42
  /** Base64 data URI shown while the real image loads. */
35
43
  lqip?: string;
44
+ /**
45
+ * The image is see-through. A cut-out gets no LQIP: the blur is painted
46
+ * *behind* the picture, so transparent pixels would show it instead of the
47
+ * page. Absent on entries written before the flag existed.
48
+ */
49
+ alpha?: boolean;
36
50
  }
37
51
 
38
52
  /**
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "Generated by scripts/build-class-inventory.mjs — do not edit by hand. `sq-*` is the kit's namespace; site-local components must use their own prefix.",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "classes": [
5
5
  "sq-about",
6
6
  "sq-about__body",