@joycostudio/iris 0.0.0-stage → 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 JOYCO Studio
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,138 @@
1
- # Temporary Holding Version
1
+ # Iris
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Iris turns image sources and usage context into a loading plan. It has three parts:
4
+
5
+ - **Prepare assets:** Find image files and hardcoded URLs in your code, inspect their metadata, then generate a catalog and small placeholders.
6
+ - **Define a policy:** Recipes describe loading stages. Rules choose a recipe using image metadata and how the image is used.
7
+ - **Render the plan:** The Next.js adapter applies the policy with Next Image. The core policy can also drive other renderers.
8
+
9
+ Next.js remains responsible for responsive sizes, optimization, and the final image request.
10
+
11
+ ## Get started
12
+
13
+ Requires Node.js 22.18+, Next.js 16+, and Sharp for image generation.
14
+
15
+ ### 1. Install and initialize
16
+
17
+ ```sh
18
+ npm i @joycostudio/iris
19
+ npm i -D sharp
20
+ npx iris init
21
+ ```
22
+
23
+ ### 2. Set up `iris.config.ts`
24
+
25
+ ```ts
26
+ import { createIris } from "@joycostudio/iris"
27
+
28
+ export default createIris({
29
+ assets: ["public/images/**/*.{png,jpg,jpeg,webp,avif,gif}"],
30
+ recipes: {
31
+ direct: { preview: "none" },
32
+ blur: { placeholder: "blur" },
33
+ staged: { placeholder: "blur", preview: { ratio: 0.5 } },
34
+ },
35
+ rules: [
36
+ { when: { priority: true }, recipe: "direct" },
37
+ { include: "/images/gallery/**", recipe: "staged" },
38
+ ],
39
+ fallback: "blur",
40
+ })
41
+
42
+ export const generate = {
43
+ output: "src/generated/iris",
44
+ scan: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"],
45
+ }
46
+ ```
47
+
48
+ `assets` catalogs files in `public/images`, including images no page uses yet. `scan` finds image URLs already written in your JS/TS files, including JSX like `<Image src="/images/hero.png" />`. The first matching rule chooses a recipe; `fallback` covers everything else.
49
+
50
+ ### 3. Generate and use it
51
+
52
+ ```sh
53
+ npx iris generate
54
+ ```
55
+
56
+ ```tsx
57
+ import { IrisImage } from "@/generated/iris/image"
58
+
59
+ <IrisImage src="/images/hero.png" alt="Hero" fill sizes="100vw" priority />
60
+ <IrisImage src="/images/gallery/photo.jpg" alt="Photo" width={1200} height={800} />
61
+ ```
62
+
63
+ Use regular Next Image props. Run `iris generate` again when image sources or placeholder encoding settings change. An image missing from the catalog renders as ordinary Next Image.
64
+
65
+ ## Recipe and rules
66
+
67
+ ### Recipes
68
+
69
+ A **recipe** is a named loading behavior. It controls what appears while the final image loads; Next.js still chooses and requests the final image.
70
+
71
+ | Recipe in the example | Definition | Result |
72
+ | --------------------- | -------------------------------------------------- | ------------------------------------------------------- |
73
+ | `direct` | `{ preview: "none" }` | Final image, without an Iris preview |
74
+ | `blur` | `{ placeholder: "blur" }` | Tiny blurred placeholder, then final image |
75
+ | `staged` | `{ placeholder: "blur", preview: { ratio: 0.5 } }` | Placeholder, optional smaller preview, then final image |
76
+
77
+ Define each recipe under `recipes` and refer to it by name in a rule or `fallback`. A recipe can also be a synchronous function when its behavior depends on the image:
78
+
79
+ ```ts
80
+ recipes: {
81
+ adaptive: ({ asset, usage }) => ({
82
+ placeholder: "blur",
83
+ preview: asset.bytes > 500_000 && !usage.priority ? { ratio: 0.5 } : "none",
84
+ }),
85
+ }
86
+ ```
87
+
88
+ `placeholder` accepts `"none"`, `"blur"`, or blur settings. `preview` accepts `"none"` or settings such as `ratio`, `maxWidth`, and `quality`.
89
+
90
+ ### Rules
91
+
92
+ A **rule** says _when_ to use a named recipe. When `IrisImage` renders, Iris tests `rules` using catalog metadata and image props. It checks from top to bottom and uses the first match. If none match, it uses `fallback`. The `recipe` prop on one `IrisImage` overrides this selection.
93
+
94
+ ```ts
95
+ rules: [
96
+ { when: { priority: true }, recipe: "direct" },
97
+ {
98
+ include: "/images/gallery/**",
99
+ when: { sourceBytes: { min: 500_000 } },
100
+ recipe: "staged",
101
+ },
102
+ { when: ({ asset }) => asset.alpha === true, recipe: "direct" },
103
+ ]
104
+ ```
105
+
106
+ Here, a priority gallery image uses `direct` because that rule comes first. A nonpriority gallery image over 500 KB uses `staged`; an image matching no rule uses `fallback`.
107
+
108
+ Each rule can have `include` and `exclude` filters for the source URL, plus a `when` condition. They must all pass for the rule to match. `when` can be an object of conditions, as above, or a synchronous function. `include` and `exclude` accept strings/globs, regular expressions, functions, and arrays. Recipe callbacks and rule predicates receive the same context:
109
+
110
+ | Field | What it contains |
111
+ | ---------- | ------------------------------------------------------------------------------------------- |
112
+ | `src` | The image URL |
113
+ | `asset` | Catalog metadata for the original image, such as `width`, `bytes`, `format`, and `alpha` |
114
+ | `variants` | Known image variants plus the original |
115
+ | `usage` | Render context, such as `width` in CSS pixels, `dpr`, `priority`, `tags`, and `custom` data |
116
+
117
+ Object conditions include `priority`, `sourceBytes`, `sourceWidth`, `renderedWidth`, `targetWidth`, `format`, and `alpha`. Size conditions use inclusive `{ min?, max? }` ranges. `renderedWidth` comes from `usage.width` (or a fixed `width` prop); `targetWidth` is that width multiplied by `usage.dpr` (default `1`). For a `fill` image, pass `usage={{ width: 800 }}` if a rule needs its rendered width.
118
+
119
+ ## Common project needs
120
+
121
+ | Need | Use |
122
+ | -------------------------------------------------------- | ---------------------------------------------------- |
123
+ | Find hardcoded image URLs in app code | `generate.scan` |
124
+ | Include all images in a folder | `assets` with a `public/**` glob |
125
+ | Skip tests, fixtures, or specific URLs | `generate.exclude` |
126
+ | Add CMS or remote image URLs | `generate.sources` or an async `generate()` function |
127
+ | Choose a recipe by priority, image size, format, or path | `rules` with `when` or `include` |
128
+ | Inspect generated assets and policy results | `generate.report: true` |
129
+
130
+ `scan` recognizes literal PNG, JPEG, WebP, AVIF, and GIF URLs, including full remote URLs. It cannot evaluate variables, imported assets, or interpolated paths; add those through `assets` or `generate.sources`. Remote images must be reachable when you run `iris generate`. Scanning does not rewrite your code: use `IrisImage` where you want the policy applied.
131
+
132
+ ## Reference
133
+
134
+ See the [full reference](https://github.com/joyco-studio/iris/blob/main/docs/reference.md) for [configuration options](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#configuration-reference), [loading behavior](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#how-it-works), [other renderers](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#other-renderers), and [debugging](https://github.com/joyco-studio/iris/blob/main/docs/reference.md#debugging).
135
+
136
+ ## Maintainers
137
+
138
+ See [releasing Iris to npm](https://github.com/joyco-studio/iris/blob/main/docs/releasing.md).
@@ -0,0 +1,466 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // packages/browser/index.ts
21
+ var browser_exports = {};
22
+ __export(browser_exports, {
23
+ createIrisDebugger: () => createIrisDebugger,
24
+ diagnoseRuntime: () => diagnoseRuntime,
25
+ inspectImageElement: () => inspectImageElement
26
+ });
27
+ module.exports = __toCommonJS(browser_exports);
28
+ function inspectImageElement(image, manifest = {}, lcpElements = /* @__PURE__ */ new Set(), timing = null) {
29
+ const rect = image.getBoundingClientRect();
30
+ const renderedWidth = image.clientWidth || rect.width;
31
+ const renderedHeight = image.clientHeight || rect.height;
32
+ const id = image.dataset.iris || image.currentSrc || image.src;
33
+ const source = manifest[image.dataset.irisSrc || id] ?? manifest[id] ?? manifest[image.currentSrc] ?? manifest[image.src] ?? null;
34
+ const candidates = parseSrcSet(image.srcset, renderedWidth);
35
+ const selectedUrl = image.currentSrc || image.src;
36
+ const selectedWidth = selectedCandidateWidth(selectedUrl, candidates);
37
+ const dpr = window.devicePixelRatio || 1;
38
+ const idealWidth = Math.ceil(renderedWidth * dpr);
39
+ const declaredSlotWidth = resolveSizes(image.sizes);
40
+ const resource = resourceTiming(selectedUrl);
41
+ const diagnostics = diagnoseRuntime({
42
+ source,
43
+ renderedWidth,
44
+ renderedHeight,
45
+ visualWidth: rect.width,
46
+ visualHeight: rect.height,
47
+ idealWidth,
48
+ selectedWidth,
49
+ availableWidths: candidates.map((candidate) => candidate.width),
50
+ declaredSlotWidth,
51
+ objectFit: getComputedStyle(image).objectFit
52
+ });
53
+ return {
54
+ id,
55
+ source,
56
+ renderedWidth,
57
+ renderedHeight,
58
+ visualWidth: rect.width,
59
+ visualHeight: rect.height,
60
+ naturalWidth: image.naturalWidth,
61
+ dpr,
62
+ idealWidth,
63
+ selectedWidth,
64
+ selectedUrl,
65
+ availableWidths: candidates.map((candidate) => candidate.width),
66
+ declaredSizes: image.sizes,
67
+ declaredSlotWidth,
68
+ loading: image.loading,
69
+ objectFit: getComputedStyle(image).objectFit,
70
+ transferBytes: resource?.transferSize ?? null,
71
+ duration: resource?.duration ?? null,
72
+ timing,
73
+ isLcp: lcpElements.has(image),
74
+ diagnostics
75
+ };
76
+ }
77
+ function createIrisDebugger(options = {}) {
78
+ const reports = /* @__PURE__ */ new Map();
79
+ const lcpElements = /* @__PURE__ */ new Set();
80
+ let mutationObserver = null;
81
+ let resizeObserver = null;
82
+ let performanceObserver = null;
83
+ let frame = 0;
84
+ let tableTimer = null;
85
+ let lastTableSignature = "";
86
+ const pending = /* @__PURE__ */ new Set();
87
+ const timelines = /* @__PURE__ */ new WeakMap();
88
+ const selector = options.scope === "document" ? "img" : "img[data-iris]";
89
+ const measure = (image) => {
90
+ const report2 = inspectImageElement(
91
+ image,
92
+ options.manifest,
93
+ lcpElements,
94
+ timelines.get(image) ?? null
95
+ );
96
+ reports.set(image, report2);
97
+ scheduleTable();
98
+ };
99
+ const table = () => logReportTable(report(), options.console ?? "warnings");
100
+ const scheduleTable = () => {
101
+ if (options.console === "silent") return;
102
+ if (tableTimer) clearTimeout(tableTimer);
103
+ tableTimer = setTimeout(() => {
104
+ tableTimer = null;
105
+ const signature = reportSignature(report());
106
+ if (signature === lastTableSignature) return;
107
+ lastTableSignature = signature;
108
+ table();
109
+ }, 250);
110
+ };
111
+ const schedule = (image) => {
112
+ pending.add(image);
113
+ if (frame) return;
114
+ frame = requestAnimationFrame(() => {
115
+ frame = 0;
116
+ for (const pendingImage of pending) measure(pendingImage);
117
+ pending.clear();
118
+ });
119
+ };
120
+ const observe = (root = document) => {
121
+ resizeObserver = new ResizeObserver((entries) => {
122
+ for (const entry of entries) {
123
+ if (entry.target instanceof HTMLImageElement) schedule(entry.target);
124
+ }
125
+ });
126
+ const register = (image) => {
127
+ resizeObserver?.observe(image);
128
+ image.addEventListener("load", () => schedule(image), { once: true });
129
+ if (image.complete) schedule(image);
130
+ };
131
+ root.querySelectorAll(selector).forEach(register);
132
+ mutationObserver = new MutationObserver((records) => {
133
+ for (const record of records) {
134
+ for (const node of record.addedNodes) {
135
+ if (!(node instanceof Element)) continue;
136
+ if (node.matches(selector)) register(node);
137
+ node.querySelectorAll(selector).forEach(register);
138
+ }
139
+ }
140
+ });
141
+ mutationObserver.observe(root, { childList: true, subtree: true });
142
+ if (typeof PerformanceObserver !== "undefined") {
143
+ performanceObserver = new PerformanceObserver((list) => {
144
+ for (const entry of list.getEntries()) {
145
+ const element = entry.element;
146
+ if (element) {
147
+ lcpElements.add(element);
148
+ if (element instanceof HTMLImageElement) schedule(element);
149
+ }
150
+ }
151
+ });
152
+ try {
153
+ performanceObserver.observe({
154
+ type: "largest-contentful-paint",
155
+ buffered: true
156
+ });
157
+ } catch {
158
+ performanceObserver = null;
159
+ }
160
+ }
161
+ return api;
162
+ };
163
+ const disconnect = () => {
164
+ mutationObserver?.disconnect();
165
+ resizeObserver?.disconnect();
166
+ performanceObserver?.disconnect();
167
+ cancelAnimationFrame(frame);
168
+ if (tableTimer) clearTimeout(tableTimer);
169
+ tableTimer = null;
170
+ frame = 0;
171
+ pending.clear();
172
+ };
173
+ const report = () => [...reports.values()];
174
+ const startTimeline = (image) => {
175
+ timelines.set(image, {
176
+ blurMs: null,
177
+ previewMs: null,
178
+ finalMs: null,
179
+ startedAt: null,
180
+ previewAt: null,
181
+ finalAt: null
182
+ });
183
+ };
184
+ const markPreview = (image, preview, at = performance.now()) => {
185
+ const timing = timelines.get(image);
186
+ if (!timing) return;
187
+ timing.startedAt = resourceTiming(preview.currentSrc || preview.src)?.startTime ?? at;
188
+ timing.previewAt = at;
189
+ timing.blurMs = roundDuration(at - timing.startedAt);
190
+ schedule(image);
191
+ };
192
+ const markFinal = (image, at = performance.now()) => {
193
+ const timing = timelines.get(image);
194
+ if (!timing) return;
195
+ const finalStartedAt = resourceTiming(image.currentSrc || image.src)?.startTime ?? at;
196
+ timing.finalAt = at;
197
+ timing.finalMs = roundDuration(at - finalStartedAt);
198
+ if (timing.previewAt === null) {
199
+ timing.startedAt = finalStartedAt;
200
+ timing.blurMs = timing.finalMs;
201
+ } else timing.previewMs = roundDuration(at - timing.previewAt);
202
+ schedule(image);
203
+ };
204
+ const summary = () => ({
205
+ inspected: reports.size,
206
+ healthy: report().filter((item) => item.diagnostics.length === 0).length,
207
+ warnings: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "warning").length,
208
+ errors: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "error").length
209
+ });
210
+ const api = {
211
+ observe,
212
+ disconnect,
213
+ report,
214
+ table,
215
+ summary,
216
+ startTimeline,
217
+ markPreview,
218
+ markFinal
219
+ };
220
+ return api;
221
+ }
222
+ function roundDuration(value) {
223
+ return Math.round(value * 10) / 10;
224
+ }
225
+ function resourceTiming(url) {
226
+ const normalized = normalizeUrl(url);
227
+ const entries = performance.getEntriesByType(
228
+ "resource"
229
+ );
230
+ for (let index = entries.length - 1; index >= 0; index--) {
231
+ if (normalizeUrl(entries[index].name) === normalized) return entries[index];
232
+ }
233
+ return void 0;
234
+ }
235
+ function parseSrcSet(srcset, renderedWidth) {
236
+ return srcset.split(",").map((value) => value.trim().split(/\s+/)).map(([url, descriptor]) => ({
237
+ url,
238
+ width: descriptor?.endsWith("w") ? Number.parseInt(descriptor, 10) : descriptor?.endsWith("x") ? Math.round(Number.parseFloat(descriptor) * renderedWidth) : 0
239
+ })).filter((candidate) => candidate.url && candidate.width > 0);
240
+ }
241
+ function selectedCandidateWidth(selectedUrl, candidates) {
242
+ const selected = normalizeUrl(selectedUrl);
243
+ const candidate = candidates.find(
244
+ (item) => normalizeUrl(item.url) === selected
245
+ );
246
+ if (candidate) return candidate.width;
247
+ const queryWidth = new URL(
248
+ selectedUrl,
249
+ window.location.href
250
+ ).searchParams.get("w");
251
+ return queryWidth ? Number(queryWidth) : null;
252
+ }
253
+ function normalizeUrl(url) {
254
+ try {
255
+ return new URL(url, window.location.href).href;
256
+ } catch {
257
+ return url;
258
+ }
259
+ }
260
+ function diagnoseRuntime(input) {
261
+ const diagnostics = [];
262
+ if (input.visualWidth !== void 0 && input.renderedWidth > 0 && Math.abs(input.visualWidth - input.renderedWidth) / input.renderedWidth > 0.02) {
263
+ diagnostics.push(
264
+ runtimeDiagnostic(
265
+ "css-transform-affects-visual-size",
266
+ "info",
267
+ "high",
268
+ "A CSS transform changes the visual width; responsive selection still uses the untransformed layout slot.",
269
+ {
270
+ renderedWidth: input.renderedWidth,
271
+ visualWidth: input.visualWidth
272
+ }
273
+ )
274
+ );
275
+ }
276
+ if (input.declaredSlotWidth && input.renderedWidth > 0) {
277
+ const pixelDifference = input.declaredSlotWidth - input.renderedWidth;
278
+ const difference = pixelDifference / input.renderedWidth;
279
+ const isMaterialMismatch = Math.abs(pixelDifference) >= 32;
280
+ if (difference < -0.1 && isMaterialMismatch) {
281
+ diagnostics.push(
282
+ runtimeDiagnostic(
283
+ "sizes-understates-layout",
284
+ "warning",
285
+ "high",
286
+ "The active sizes slot is smaller than the rendered CSS width.",
287
+ {
288
+ declaredSlotWidth: input.declaredSlotWidth,
289
+ renderedWidth: input.renderedWidth,
290
+ pixelDifference,
291
+ difference
292
+ }
293
+ )
294
+ );
295
+ } else if (difference > 0.25 && isMaterialMismatch) {
296
+ diagnostics.push(
297
+ runtimeDiagnostic(
298
+ "sizes-overstates-layout",
299
+ "warning",
300
+ "high",
301
+ "The active sizes slot is substantially larger than the rendered CSS width.",
302
+ {
303
+ declaredSlotWidth: input.declaredSlotWidth,
304
+ renderedWidth: input.renderedWidth,
305
+ pixelDifference,
306
+ difference
307
+ }
308
+ )
309
+ );
310
+ }
311
+ }
312
+ if (input.source && input.source.width < input.idealWidth) {
313
+ diagnostics.push(
314
+ runtimeDiagnostic(
315
+ "source-too-small",
316
+ "error",
317
+ "high",
318
+ "The source is smaller than the rendered width \xD7 DPR target.",
319
+ {
320
+ sourceWidth: input.source.width,
321
+ idealWidth: input.idealWidth
322
+ }
323
+ )
324
+ );
325
+ }
326
+ if (input.selectedWidth && input.selectedWidth < input.idealWidth) {
327
+ const hasLarger = input.availableWidths.some(
328
+ (width) => width >= input.idealWidth
329
+ );
330
+ diagnostics.push(
331
+ runtimeDiagnostic(
332
+ hasLarger ? "browser-downselected" : "missing-useful-candidate",
333
+ hasLarger ? "info" : "warning",
334
+ "high",
335
+ hasLarger ? "The browser selected below the theoretical target although a larger candidate exists." : "No available candidate reaches the theoretical target.",
336
+ { selectedWidth: input.selectedWidth, idealWidth: input.idealWidth }
337
+ )
338
+ );
339
+ }
340
+ if (input.source && input.objectFit === "cover") {
341
+ const sourceRatio = input.source.width / input.source.height;
342
+ const renderedRatio = input.renderedWidth / input.renderedHeight;
343
+ if (Math.abs(sourceRatio - renderedRatio) / sourceRatio > 0.02) {
344
+ diagnostics.push(
345
+ runtimeDiagnostic(
346
+ "cropped-image",
347
+ "info",
348
+ "high",
349
+ "object-fit: cover crops part of the downloaded source.",
350
+ {
351
+ sourceAspectRatio: sourceRatio,
352
+ renderedAspectRatio: renderedRatio
353
+ }
354
+ )
355
+ );
356
+ }
357
+ }
358
+ return diagnostics;
359
+ }
360
+ function resolveSizes(sizes) {
361
+ if (!sizes || sizes.trim() === "auto") return null;
362
+ for (const rule of splitTopLevel(sizes)) {
363
+ const value = rule.trim();
364
+ let length = value;
365
+ if (value.startsWith("(")) {
366
+ const end = matchingParenthesis(value);
367
+ if (end < 0) continue;
368
+ if (!matchMedia(value.slice(0, end + 1)).matches) continue;
369
+ length = value.slice(end + 1).trim();
370
+ }
371
+ if (!length || length === "auto") continue;
372
+ const probe = document.createElement("div");
373
+ probe.style.cssText = `position:fixed;visibility:hidden;pointer-events:none;width:${length};height:0`;
374
+ document.body.append(probe);
375
+ const width = probe.getBoundingClientRect().width;
376
+ probe.remove();
377
+ return Number.isFinite(width) && width > 0 ? width : null;
378
+ }
379
+ return null;
380
+ }
381
+ function splitTopLevel(value) {
382
+ const output = [];
383
+ let depth = 0;
384
+ let start = 0;
385
+ for (let index = 0; index < value.length; index++) {
386
+ if (value[index] === "(") depth++;
387
+ if (value[index] === ")") depth--;
388
+ if (value[index] === "," && depth === 0) {
389
+ output.push(value.slice(start, index));
390
+ start = index + 1;
391
+ }
392
+ }
393
+ output.push(value.slice(start));
394
+ return output;
395
+ }
396
+ function matchingParenthesis(value) {
397
+ let depth = 0;
398
+ for (let index = 0; index < value.length; index++) {
399
+ if (value[index] === "(") depth++;
400
+ if (value[index] === ")" && --depth === 0) return index;
401
+ }
402
+ return -1;
403
+ }
404
+ function runtimeDiagnostic(code, severity, confidence, message, evidence) {
405
+ return { code, severity, confidence, message, evidence };
406
+ }
407
+ function logReportTable(reports, mode) {
408
+ if (mode === "silent") return;
409
+ const visible = reports.filter(
410
+ (report) => mode === "all" || report.diagnostics.some((diagnostic) => diagnostic.severity !== "info")
411
+ );
412
+ if (visible.length === 0) return;
413
+ console.table(
414
+ visible.map((report) => ({
415
+ asset: report.id,
416
+ stage: imageStage(report),
417
+ rendered: `${Math.round(report.renderedWidth)}\xD7${Math.round(report.renderedHeight)}`,
418
+ selected: report.selectedWidth,
419
+ ideal: report.idealWidth,
420
+ bytes: report.transferBytes,
421
+ blurMs: report.timing?.blurMs ?? null,
422
+ previewMs: report.timing?.previewMs ?? null,
423
+ finalMs: report.timing?.finalMs ?? null,
424
+ lazy: report.loading === "lazy",
425
+ lcp: report.isLcp,
426
+ status: diagnosticStatus(report),
427
+ diagnostics: report.diagnostics.map((diagnostic) => diagnostic.code).join(", ")
428
+ }))
429
+ );
430
+ }
431
+ function imageStage(report) {
432
+ if (report.timing?.finalAt !== null && report.timing?.finalAt !== void 0)
433
+ return "final";
434
+ if (report.timing?.previewAt) return "preview";
435
+ if (report.timing) return "blur";
436
+ return report.naturalWidth > 0 ? "final" : "loading";
437
+ }
438
+ function diagnosticStatus(report) {
439
+ if (report.diagnostics.some((item) => item.severity === "error"))
440
+ return "error";
441
+ if (report.diagnostics.some((item) => item.severity === "warning"))
442
+ return "warning";
443
+ if (report.diagnostics.some((item) => item.severity === "info")) return "info";
444
+ return "healthy";
445
+ }
446
+ function reportSignature(reports) {
447
+ return reports.map(
448
+ (report) => [
449
+ report.id,
450
+ imageStage(report),
451
+ report.selectedWidth,
452
+ report.transferBytes,
453
+ report.timing?.blurMs,
454
+ report.timing?.previewMs,
455
+ report.timing?.finalMs,
456
+ diagnosticStatus(report)
457
+ ].join(":")
458
+ ).join("|");
459
+ }
460
+ // Annotate the CommonJS export names for ESM import in node:
461
+ 0 && (module.exports = {
462
+ createIrisDebugger,
463
+ diagnoseRuntime,
464
+ inspectImageElement
465
+ });
466
+ //# sourceMappingURL=browser.cjs.map