@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/dist/react.js ADDED
@@ -0,0 +1,611 @@
1
+ "use client";
2
+
3
+ // packages/react/index.tsx
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useState as useState2
9
+ } from "react";
10
+
11
+ // packages/browser/index.ts
12
+ function inspectImageElement(image, manifest = {}, lcpElements = /* @__PURE__ */ new Set(), timing = null) {
13
+ const rect = image.getBoundingClientRect();
14
+ const renderedWidth = image.clientWidth || rect.width;
15
+ const renderedHeight = image.clientHeight || rect.height;
16
+ const id = image.dataset.iris || image.currentSrc || image.src;
17
+ const source = manifest[image.dataset.irisSrc || id] ?? manifest[id] ?? manifest[image.currentSrc] ?? manifest[image.src] ?? null;
18
+ const candidates = parseSrcSet(image.srcset, renderedWidth);
19
+ const selectedUrl = image.currentSrc || image.src;
20
+ const selectedWidth = selectedCandidateWidth(selectedUrl, candidates);
21
+ const dpr = window.devicePixelRatio || 1;
22
+ const idealWidth = Math.ceil(renderedWidth * dpr);
23
+ const declaredSlotWidth = resolveSizes(image.sizes);
24
+ const resource = resourceTiming(selectedUrl);
25
+ const diagnostics = diagnoseRuntime({
26
+ source,
27
+ renderedWidth,
28
+ renderedHeight,
29
+ visualWidth: rect.width,
30
+ visualHeight: rect.height,
31
+ idealWidth,
32
+ selectedWidth,
33
+ availableWidths: candidates.map((candidate) => candidate.width),
34
+ declaredSlotWidth,
35
+ objectFit: getComputedStyle(image).objectFit
36
+ });
37
+ return {
38
+ id,
39
+ source,
40
+ renderedWidth,
41
+ renderedHeight,
42
+ visualWidth: rect.width,
43
+ visualHeight: rect.height,
44
+ naturalWidth: image.naturalWidth,
45
+ dpr,
46
+ idealWidth,
47
+ selectedWidth,
48
+ selectedUrl,
49
+ availableWidths: candidates.map((candidate) => candidate.width),
50
+ declaredSizes: image.sizes,
51
+ declaredSlotWidth,
52
+ loading: image.loading,
53
+ objectFit: getComputedStyle(image).objectFit,
54
+ transferBytes: resource?.transferSize ?? null,
55
+ duration: resource?.duration ?? null,
56
+ timing,
57
+ isLcp: lcpElements.has(image),
58
+ diagnostics
59
+ };
60
+ }
61
+ function createIrisDebugger(options = {}) {
62
+ const reports = /* @__PURE__ */ new Map();
63
+ const lcpElements = /* @__PURE__ */ new Set();
64
+ let mutationObserver = null;
65
+ let resizeObserver = null;
66
+ let performanceObserver = null;
67
+ let frame = 0;
68
+ let tableTimer = null;
69
+ let lastTableSignature = "";
70
+ const pending = /* @__PURE__ */ new Set();
71
+ const timelines = /* @__PURE__ */ new WeakMap();
72
+ const selector = options.scope === "document" ? "img" : "img[data-iris]";
73
+ const measure = (image) => {
74
+ const report2 = inspectImageElement(
75
+ image,
76
+ options.manifest,
77
+ lcpElements,
78
+ timelines.get(image) ?? null
79
+ );
80
+ reports.set(image, report2);
81
+ scheduleTable();
82
+ };
83
+ const table = () => logReportTable(report(), options.console ?? "warnings");
84
+ const scheduleTable = () => {
85
+ if (options.console === "silent") return;
86
+ if (tableTimer) clearTimeout(tableTimer);
87
+ tableTimer = setTimeout(() => {
88
+ tableTimer = null;
89
+ const signature = reportSignature(report());
90
+ if (signature === lastTableSignature) return;
91
+ lastTableSignature = signature;
92
+ table();
93
+ }, 250);
94
+ };
95
+ const schedule = (image) => {
96
+ pending.add(image);
97
+ if (frame) return;
98
+ frame = requestAnimationFrame(() => {
99
+ frame = 0;
100
+ for (const pendingImage of pending) measure(pendingImage);
101
+ pending.clear();
102
+ });
103
+ };
104
+ const observe = (root = document) => {
105
+ resizeObserver = new ResizeObserver((entries) => {
106
+ for (const entry of entries) {
107
+ if (entry.target instanceof HTMLImageElement) schedule(entry.target);
108
+ }
109
+ });
110
+ const register = (image) => {
111
+ resizeObserver?.observe(image);
112
+ image.addEventListener("load", () => schedule(image), { once: true });
113
+ if (image.complete) schedule(image);
114
+ };
115
+ root.querySelectorAll(selector).forEach(register);
116
+ mutationObserver = new MutationObserver((records) => {
117
+ for (const record of records) {
118
+ for (const node of record.addedNodes) {
119
+ if (!(node instanceof Element)) continue;
120
+ if (node.matches(selector)) register(node);
121
+ node.querySelectorAll(selector).forEach(register);
122
+ }
123
+ }
124
+ });
125
+ mutationObserver.observe(root, { childList: true, subtree: true });
126
+ if (typeof PerformanceObserver !== "undefined") {
127
+ performanceObserver = new PerformanceObserver((list) => {
128
+ for (const entry of list.getEntries()) {
129
+ const element = entry.element;
130
+ if (element) {
131
+ lcpElements.add(element);
132
+ if (element instanceof HTMLImageElement) schedule(element);
133
+ }
134
+ }
135
+ });
136
+ try {
137
+ performanceObserver.observe({
138
+ type: "largest-contentful-paint",
139
+ buffered: true
140
+ });
141
+ } catch {
142
+ performanceObserver = null;
143
+ }
144
+ }
145
+ return api;
146
+ };
147
+ const disconnect = () => {
148
+ mutationObserver?.disconnect();
149
+ resizeObserver?.disconnect();
150
+ performanceObserver?.disconnect();
151
+ cancelAnimationFrame(frame);
152
+ if (tableTimer) clearTimeout(tableTimer);
153
+ tableTimer = null;
154
+ frame = 0;
155
+ pending.clear();
156
+ };
157
+ const report = () => [...reports.values()];
158
+ const startTimeline = (image) => {
159
+ timelines.set(image, {
160
+ blurMs: null,
161
+ previewMs: null,
162
+ finalMs: null,
163
+ startedAt: null,
164
+ previewAt: null,
165
+ finalAt: null
166
+ });
167
+ };
168
+ const markPreview = (image, preview, at = performance.now()) => {
169
+ const timing = timelines.get(image);
170
+ if (!timing) return;
171
+ timing.startedAt = resourceTiming(preview.currentSrc || preview.src)?.startTime ?? at;
172
+ timing.previewAt = at;
173
+ timing.blurMs = roundDuration(at - timing.startedAt);
174
+ schedule(image);
175
+ };
176
+ const markFinal = (image, at = performance.now()) => {
177
+ const timing = timelines.get(image);
178
+ if (!timing) return;
179
+ const finalStartedAt = resourceTiming(image.currentSrc || image.src)?.startTime ?? at;
180
+ timing.finalAt = at;
181
+ timing.finalMs = roundDuration(at - finalStartedAt);
182
+ if (timing.previewAt === null) {
183
+ timing.startedAt = finalStartedAt;
184
+ timing.blurMs = timing.finalMs;
185
+ } else timing.previewMs = roundDuration(at - timing.previewAt);
186
+ schedule(image);
187
+ };
188
+ const summary = () => ({
189
+ inspected: reports.size,
190
+ healthy: report().filter((item) => item.diagnostics.length === 0).length,
191
+ warnings: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "warning").length,
192
+ errors: report().flatMap((item) => item.diagnostics).filter((item) => item.severity === "error").length
193
+ });
194
+ const api = {
195
+ observe,
196
+ disconnect,
197
+ report,
198
+ table,
199
+ summary,
200
+ startTimeline,
201
+ markPreview,
202
+ markFinal
203
+ };
204
+ return api;
205
+ }
206
+ function roundDuration(value) {
207
+ return Math.round(value * 10) / 10;
208
+ }
209
+ function resourceTiming(url) {
210
+ const normalized = normalizeUrl(url);
211
+ const entries = performance.getEntriesByType(
212
+ "resource"
213
+ );
214
+ for (let index = entries.length - 1; index >= 0; index--) {
215
+ if (normalizeUrl(entries[index].name) === normalized) return entries[index];
216
+ }
217
+ return void 0;
218
+ }
219
+ function parseSrcSet(srcset, renderedWidth) {
220
+ return srcset.split(",").map((value) => value.trim().split(/\s+/)).map(([url, descriptor]) => ({
221
+ url,
222
+ width: descriptor?.endsWith("w") ? Number.parseInt(descriptor, 10) : descriptor?.endsWith("x") ? Math.round(Number.parseFloat(descriptor) * renderedWidth) : 0
223
+ })).filter((candidate) => candidate.url && candidate.width > 0);
224
+ }
225
+ function selectedCandidateWidth(selectedUrl, candidates) {
226
+ const selected = normalizeUrl(selectedUrl);
227
+ const candidate = candidates.find(
228
+ (item) => normalizeUrl(item.url) === selected
229
+ );
230
+ if (candidate) return candidate.width;
231
+ const queryWidth = new URL(
232
+ selectedUrl,
233
+ window.location.href
234
+ ).searchParams.get("w");
235
+ return queryWidth ? Number(queryWidth) : null;
236
+ }
237
+ function normalizeUrl(url) {
238
+ try {
239
+ return new URL(url, window.location.href).href;
240
+ } catch {
241
+ return url;
242
+ }
243
+ }
244
+ function diagnoseRuntime(input) {
245
+ const diagnostics = [];
246
+ if (input.visualWidth !== void 0 && input.renderedWidth > 0 && Math.abs(input.visualWidth - input.renderedWidth) / input.renderedWidth > 0.02) {
247
+ diagnostics.push(
248
+ runtimeDiagnostic(
249
+ "css-transform-affects-visual-size",
250
+ "info",
251
+ "high",
252
+ "A CSS transform changes the visual width; responsive selection still uses the untransformed layout slot.",
253
+ {
254
+ renderedWidth: input.renderedWidth,
255
+ visualWidth: input.visualWidth
256
+ }
257
+ )
258
+ );
259
+ }
260
+ if (input.declaredSlotWidth && input.renderedWidth > 0) {
261
+ const pixelDifference = input.declaredSlotWidth - input.renderedWidth;
262
+ const difference = pixelDifference / input.renderedWidth;
263
+ const isMaterialMismatch = Math.abs(pixelDifference) >= 32;
264
+ if (difference < -0.1 && isMaterialMismatch) {
265
+ diagnostics.push(
266
+ runtimeDiagnostic(
267
+ "sizes-understates-layout",
268
+ "warning",
269
+ "high",
270
+ "The active sizes slot is smaller than the rendered CSS width.",
271
+ {
272
+ declaredSlotWidth: input.declaredSlotWidth,
273
+ renderedWidth: input.renderedWidth,
274
+ pixelDifference,
275
+ difference
276
+ }
277
+ )
278
+ );
279
+ } else if (difference > 0.25 && isMaterialMismatch) {
280
+ diagnostics.push(
281
+ runtimeDiagnostic(
282
+ "sizes-overstates-layout",
283
+ "warning",
284
+ "high",
285
+ "The active sizes slot is substantially larger than the rendered CSS width.",
286
+ {
287
+ declaredSlotWidth: input.declaredSlotWidth,
288
+ renderedWidth: input.renderedWidth,
289
+ pixelDifference,
290
+ difference
291
+ }
292
+ )
293
+ );
294
+ }
295
+ }
296
+ if (input.source && input.source.width < input.idealWidth) {
297
+ diagnostics.push(
298
+ runtimeDiagnostic(
299
+ "source-too-small",
300
+ "error",
301
+ "high",
302
+ "The source is smaller than the rendered width \xD7 DPR target.",
303
+ {
304
+ sourceWidth: input.source.width,
305
+ idealWidth: input.idealWidth
306
+ }
307
+ )
308
+ );
309
+ }
310
+ if (input.selectedWidth && input.selectedWidth < input.idealWidth) {
311
+ const hasLarger = input.availableWidths.some(
312
+ (width) => width >= input.idealWidth
313
+ );
314
+ diagnostics.push(
315
+ runtimeDiagnostic(
316
+ hasLarger ? "browser-downselected" : "missing-useful-candidate",
317
+ hasLarger ? "info" : "warning",
318
+ "high",
319
+ hasLarger ? "The browser selected below the theoretical target although a larger candidate exists." : "No available candidate reaches the theoretical target.",
320
+ { selectedWidth: input.selectedWidth, idealWidth: input.idealWidth }
321
+ )
322
+ );
323
+ }
324
+ if (input.source && input.objectFit === "cover") {
325
+ const sourceRatio = input.source.width / input.source.height;
326
+ const renderedRatio = input.renderedWidth / input.renderedHeight;
327
+ if (Math.abs(sourceRatio - renderedRatio) / sourceRatio > 0.02) {
328
+ diagnostics.push(
329
+ runtimeDiagnostic(
330
+ "cropped-image",
331
+ "info",
332
+ "high",
333
+ "object-fit: cover crops part of the downloaded source.",
334
+ {
335
+ sourceAspectRatio: sourceRatio,
336
+ renderedAspectRatio: renderedRatio
337
+ }
338
+ )
339
+ );
340
+ }
341
+ }
342
+ return diagnostics;
343
+ }
344
+ function resolveSizes(sizes) {
345
+ if (!sizes || sizes.trim() === "auto") return null;
346
+ for (const rule of splitTopLevel(sizes)) {
347
+ const value = rule.trim();
348
+ let length = value;
349
+ if (value.startsWith("(")) {
350
+ const end = matchingParenthesis(value);
351
+ if (end < 0) continue;
352
+ if (!matchMedia(value.slice(0, end + 1)).matches) continue;
353
+ length = value.slice(end + 1).trim();
354
+ }
355
+ if (!length || length === "auto") continue;
356
+ const probe = document.createElement("div");
357
+ probe.style.cssText = `position:fixed;visibility:hidden;pointer-events:none;width:${length};height:0`;
358
+ document.body.append(probe);
359
+ const width = probe.getBoundingClientRect().width;
360
+ probe.remove();
361
+ return Number.isFinite(width) && width > 0 ? width : null;
362
+ }
363
+ return null;
364
+ }
365
+ function splitTopLevel(value) {
366
+ const output = [];
367
+ let depth = 0;
368
+ let start = 0;
369
+ for (let index = 0; index < value.length; index++) {
370
+ if (value[index] === "(") depth++;
371
+ if (value[index] === ")") depth--;
372
+ if (value[index] === "," && depth === 0) {
373
+ output.push(value.slice(start, index));
374
+ start = index + 1;
375
+ }
376
+ }
377
+ output.push(value.slice(start));
378
+ return output;
379
+ }
380
+ function matchingParenthesis(value) {
381
+ let depth = 0;
382
+ for (let index = 0; index < value.length; index++) {
383
+ if (value[index] === "(") depth++;
384
+ if (value[index] === ")" && --depth === 0) return index;
385
+ }
386
+ return -1;
387
+ }
388
+ function runtimeDiagnostic(code, severity, confidence, message, evidence) {
389
+ return { code, severity, confidence, message, evidence };
390
+ }
391
+ function logReportTable(reports, mode) {
392
+ if (mode === "silent") return;
393
+ const visible = reports.filter(
394
+ (report) => mode === "all" || report.diagnostics.some((diagnostic) => diagnostic.severity !== "info")
395
+ );
396
+ if (visible.length === 0) return;
397
+ console.table(
398
+ visible.map((report) => ({
399
+ asset: report.id,
400
+ stage: imageStage(report),
401
+ rendered: `${Math.round(report.renderedWidth)}\xD7${Math.round(report.renderedHeight)}`,
402
+ selected: report.selectedWidth,
403
+ ideal: report.idealWidth,
404
+ bytes: report.transferBytes,
405
+ blurMs: report.timing?.blurMs ?? null,
406
+ previewMs: report.timing?.previewMs ?? null,
407
+ finalMs: report.timing?.finalMs ?? null,
408
+ lazy: report.loading === "lazy",
409
+ lcp: report.isLcp,
410
+ status: diagnosticStatus(report),
411
+ diagnostics: report.diagnostics.map((diagnostic) => diagnostic.code).join(", ")
412
+ }))
413
+ );
414
+ }
415
+ function imageStage(report) {
416
+ if (report.timing?.finalAt !== null && report.timing?.finalAt !== void 0)
417
+ return "final";
418
+ if (report.timing?.previewAt) return "preview";
419
+ if (report.timing) return "blur";
420
+ return report.naturalWidth > 0 ? "final" : "loading";
421
+ }
422
+ function diagnosticStatus(report) {
423
+ if (report.diagnostics.some((item) => item.severity === "error"))
424
+ return "error";
425
+ if (report.diagnostics.some((item) => item.severity === "warning"))
426
+ return "warning";
427
+ if (report.diagnostics.some((item) => item.severity === "info")) return "info";
428
+ return "healthy";
429
+ }
430
+ function reportSignature(reports) {
431
+ return reports.map(
432
+ (report) => [
433
+ report.id,
434
+ imageStage(report),
435
+ report.selectedWidth,
436
+ report.transferBytes,
437
+ report.timing?.blurMs,
438
+ report.timing?.previewMs,
439
+ report.timing?.finalMs,
440
+ diagnosticStatus(report)
441
+ ].join(":")
442
+ ).join("|");
443
+ }
444
+
445
+ // packages/react/plan-image.tsx
446
+ import {
447
+ useCallback,
448
+ useRef,
449
+ useState
450
+ } from "react";
451
+ import { jsx, jsxs } from "react/jsx-runtime";
452
+ function IrisPlanImage(props) {
453
+ const { plan } = props;
454
+ const key = JSON.stringify([
455
+ plan.src,
456
+ plan.final.url,
457
+ plan.preview?.url,
458
+ plan.placeholder?.url,
459
+ plan.placeholder?.blur
460
+ ]);
461
+ return /* @__PURE__ */ jsx(PlanImage, { ...props }, key);
462
+ }
463
+ function PlanImage({
464
+ plan,
465
+ alt,
466
+ className,
467
+ style,
468
+ onLoad,
469
+ onError,
470
+ loading = "lazy",
471
+ ...props
472
+ }) {
473
+ const [finalReady, setFinalReady] = useState(false);
474
+ const [previewReady, setPreviewReady] = useState(false);
475
+ const previewDecoding = useRef(false);
476
+ const previewShown = useRef(false);
477
+ const finalRef = useCallback((image) => {
478
+ if (image?.complete && image.naturalWidth) setFinalReady(true);
479
+ }, []);
480
+ const revealPreview = useCallback(async (image) => {
481
+ if (previewDecoding.current || previewShown.current) return;
482
+ previewDecoding.current = true;
483
+ try {
484
+ await image.decode?.();
485
+ } catch {
486
+ }
487
+ previewDecoding.current = false;
488
+ if (image.isConnected && image.naturalWidth && !previewShown.current) {
489
+ previewShown.current = true;
490
+ setPreviewReady(true);
491
+ }
492
+ }, []);
493
+ const previewRef = useCallback(
494
+ (image) => {
495
+ if (image?.complete && image.naturalWidth) void revealPreview(image);
496
+ },
497
+ [revealPreview]
498
+ );
499
+ const progressive = Boolean(plan.final.progressive);
500
+ const surface = {
501
+ position: "relative",
502
+ display: "block",
503
+ ...style
504
+ };
505
+ return /* @__PURE__ */ jsxs("span", { className, "data-slot": "iris-image", style: surface, children: [
506
+ /* @__PURE__ */ jsx(
507
+ "img",
508
+ {
509
+ ...props,
510
+ ref: finalRef,
511
+ src: plan.final.url,
512
+ alt,
513
+ width: plan.final.width,
514
+ height: plan.final.height,
515
+ "data-slot": "iris-final",
516
+ "data-iris-src": plan.src,
517
+ loading,
518
+ style: {
519
+ position: "relative",
520
+ display: "block",
521
+ width: "100%",
522
+ height: "auto"
523
+ },
524
+ onLoad: (event) => {
525
+ setFinalReady(true);
526
+ onLoad?.(event);
527
+ },
528
+ onError
529
+ }
530
+ ),
531
+ !progressive && !finalReady && !previewReady && plan.placeholder && /* @__PURE__ */ jsx(
532
+ "span",
533
+ {
534
+ "aria-hidden": "true",
535
+ "data-slot": "iris-placeholder",
536
+ "data-stage": "blur",
537
+ style: {
538
+ position: "absolute",
539
+ inset: 0,
540
+ backgroundImage: `url(${JSON.stringify(plan.placeholder.url)})`,
541
+ backgroundSize: "100% 100%",
542
+ filter: `blur(${plan.placeholder.blur}px)`,
543
+ clipPath: "inset(0)",
544
+ pointerEvents: "none",
545
+ zIndex: 2
546
+ }
547
+ }
548
+ ),
549
+ !progressive && plan.preview && !finalReady && /* @__PURE__ */ jsx(
550
+ "img",
551
+ {
552
+ ref: previewRef,
553
+ src: plan.preview.url,
554
+ alt: "",
555
+ "aria-hidden": "true",
556
+ "data-slot": "iris-preview",
557
+ width: plan.final.width,
558
+ height: plan.final.height,
559
+ loading,
560
+ decoding: props.decoding,
561
+ crossOrigin: props.crossOrigin,
562
+ referrerPolicy: props.referrerPolicy,
563
+ style: {
564
+ position: "absolute",
565
+ inset: 0,
566
+ width: "100%",
567
+ height: "100%",
568
+ objectFit: "contain",
569
+ pointerEvents: "none",
570
+ zIndex: 1,
571
+ opacity: previewReady ? 1 : 0
572
+ },
573
+ onLoad: (event) => void revealPreview(event.currentTarget)
574
+ }
575
+ )
576
+ ] });
577
+ }
578
+
579
+ // packages/react/index.tsx
580
+ import { jsx as jsx2 } from "react/jsx-runtime";
581
+ var IrisDebuggerContext = createContext(null);
582
+ function IrisProvider({
583
+ children,
584
+ enabled = true,
585
+ manifest,
586
+ scope = "marked",
587
+ console: consoleMode = "warnings"
588
+ }) {
589
+ const [iris] = useState2(
590
+ () => typeof window === "undefined" ? null : createIrisDebugger({ manifest, scope, console: consoleMode })
591
+ );
592
+ useEffect(() => {
593
+ if (!enabled || !iris) return;
594
+ iris.observe(document);
595
+ window.__IRIS__ = iris;
596
+ return () => {
597
+ iris.disconnect();
598
+ if (window.__IRIS__ === iris) delete window.__IRIS__;
599
+ };
600
+ }, [enabled, iris]);
601
+ return /* @__PURE__ */ jsx2(IrisDebuggerContext.Provider, { value: enabled ? iris : null, children });
602
+ }
603
+ function useIrisDebugger() {
604
+ return useContext(IrisDebuggerContext);
605
+ }
606
+ export {
607
+ IrisPlanImage,
608
+ IrisProvider,
609
+ useIrisDebugger
610
+ };
611
+ //# sourceMappingURL=react.js.map