@stapel/image 0.1.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 ADDED
@@ -0,0 +1,64 @@
1
+ # @stapel/image
2
+
3
+ Image rendering over the stapel-cdn variant ladder: pure tier/branch math,
4
+ a slot-measurement hook, and a blur-up `<Image>` component.
5
+
6
+ Pairs with the stapel-cdn render-metadata snapshot (`cdn.describe(ref)` /
7
+ `stapel_core.media.describe(ref)`): an immutable `{mime, bytes, width, height,
8
+ aspect, preview_b64, square, variants[]}` object produced on ingest.
9
+
10
+ ## The ladder
11
+
12
+ Default tiers `16, 32, 64, 120, 160, 240, 480, 560, 720, 1080`:
13
+
14
+ - **16 (micro)** — inlined as `preview_b64`, the blur-up placeholder;
15
+ - **32/64/120 (thumbnail)** — min-side resize, no w/h branches (`branch: null`),
16
+ for fixed square-ish slots (avatars, grids);
17
+ - **160–1080 (preview)** — two branches per tier: `{T}w` (width == T) and
18
+ `{T}h` (height == T), so the limiting axis of any slot is served without
19
+ upscaling. Square images store one branch and set `square: true`.
20
+
21
+ ## Pure math (`no DOM, no React`)
22
+
23
+ ```ts
24
+ import { pickTier, limitingAxis, chooseVariant } from "@stapel/image";
25
+
26
+ pickTier(792, [560, 720, 1080]); // 720 — smallest T with needed ≤ T×1.1
27
+ pickTier(793, [560, 720, 1080]); // 1080
28
+ pickTier(616, [560, 720, 1080]); // 560
29
+
30
+ limitingAxis(0.75, 16 / 9, "cover"); // "w" — portrait in a wide slot
31
+
32
+ chooseVariant(
33
+ { slotWidthCss: 640, slotHeightCss: 360, dpr: 2, imgAspect: meta.aspect, fit: "cover" },
34
+ meta
35
+ ); // → VariantMeta (falls back to "original" past the top of the ladder)
36
+ ```
37
+
38
+ `neededPx` always includes DPR: a 400px CSS slot on a dpr=2 screen shops for
39
+ 800 physical pixels.
40
+
41
+ ## `useImageSlot()`
42
+
43
+ ResizeObserver measurement of the rendered slot. SSR-safe (`size` is
44
+ `undefined` until mounted). The reported size is a per-axis high-water mark —
45
+ it only grows, so resize jitter or a transient shrink never re-picks a
46
+ smaller, already-loaded tier.
47
+
48
+ ## `<Image>`
49
+
50
+ ```tsx
51
+ import { Image } from "@stapel/image";
52
+
53
+ <Image meta={attachment.render_meta} alt={attachment.name} fit="cover" />
54
+ ```
55
+
56
+ 1. `aspect-ratio` from metadata lands on the container before any measurement
57
+ — zero layout shift, zero network.
58
+ 2. The 16px `preview_b64` renders instantly underneath (blur-up).
59
+ 3. The measured slot picks `(branch, tier)` via `chooseVariant`.
60
+ 4. Upgrades only: a bigger pick loads off-DOM and swaps after `decode()`;
61
+ an equal-or-smaller pick is ignored — never a blank frame, never a
62
+ visual downgrade.
63
+
64
+ `fit` defaults to `"cover"`; `alt` is required.
@@ -0,0 +1,24 @@
1
+ import type { ImgHTMLAttributes, ReactElement } from "react";
2
+ import type { Fit, RenderMetadata } from "./tiers.js";
3
+ export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "alt"> {
4
+ meta: RenderMetadata;
5
+ /** Default `"cover"` (chat/catalog/avatar — fill the slot). */
6
+ fit?: Fit;
7
+ /** Required, no default. */
8
+ alt: string;
9
+ }
10
+ /**
11
+ * CDN-ladder-aware image (images-and-cdn.md §4):
12
+ *
13
+ * 1. `aspect-ratio` from metadata goes on the container BEFORE the first slot
14
+ * measurement — layout-shift protection entirely from the snapshot, no
15
+ * network round-trip.
16
+ * 2. Blur-up: the inlined 16px `preview_b64` renders instantly underneath
17
+ * until the chosen tier has decoded.
18
+ * 3. `useImageSlot()` measures the actual slot → `chooseVariant(...)` → src.
19
+ * 4. Upgrades only: a re-measure that picks a variant no bigger than the one
20
+ * already rendered is ignored; a bigger pick loads off-DOM and swaps in
21
+ * only after `decode()` — never a flash of empty slot, never a downgrade.
22
+ */
23
+ export declare function Image({ meta, fit, alt, style, className, ...imgProps }: ImageProps): ReactElement;
24
+ //# sourceMappingURL=Image.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Image.d.ts","sourceRoot":"","sources":["../src/Image.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAiB,iBAAiB,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAE5E,OAAO,KAAK,EAAE,GAAG,EAAE,cAAc,EAAe,MAAM,YAAY,CAAC;AAGnE,MAAM,WAAW,UACf,SAAQ,IAAI,CAAC,iBAAiB,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,KAAK,CAAC;IAC3E,IAAI,EAAE,cAAc,CAAC;IACrB,+DAA+D;IAC/D,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,4BAA4B;IAC5B,GAAG,EAAE,MAAM,CAAC;CACb;AASD;;;;;;;;;;;;GAYG;AACH,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,GAAa,EACb,GAAG,EACH,KAAK,EACL,SAAS,EACT,GAAG,QAAQ,EACZ,EAAE,UAAU,GAAG,YAAY,CA6G3B"}
package/dist/Image.js ADDED
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { chooseVariant } from "./tiers.js";
4
+ import { useImageSlot } from "./useImageSlot.js";
5
+ const FILL = {
6
+ position: "absolute",
7
+ inset: 0,
8
+ width: "100%",
9
+ height: "100%",
10
+ };
11
+ /**
12
+ * CDN-ladder-aware image (images-and-cdn.md §4):
13
+ *
14
+ * 1. `aspect-ratio` from metadata goes on the container BEFORE the first slot
15
+ * measurement — layout-shift protection entirely from the snapshot, no
16
+ * network round-trip.
17
+ * 2. Blur-up: the inlined 16px `preview_b64` renders instantly underneath
18
+ * until the chosen tier has decoded.
19
+ * 3. `useImageSlot()` measures the actual slot → `chooseVariant(...)` → src.
20
+ * 4. Upgrades only: a re-measure that picks a variant no bigger than the one
21
+ * already rendered is ignored; a bigger pick loads off-DOM and swaps in
22
+ * only after `decode()` — never a flash of empty slot, never a downgrade.
23
+ */
24
+ export function Image({ meta, fit = "cover", alt, style, className, ...imgProps }) {
25
+ const { ref, size } = useImageSlot();
26
+ const [displayed, setDisplayed] = useState(undefined);
27
+ const [visible, setVisible] = useState(false);
28
+ const displayedRef = useRef(undefined);
29
+ const target = useMemo(() => {
30
+ if (size === undefined || size.width <= 0 || size.height <= 0) {
31
+ return undefined;
32
+ }
33
+ if (meta.variants.length === 0) {
34
+ return undefined;
35
+ }
36
+ const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
37
+ return chooseVariant({
38
+ slotWidthCss: size.width,
39
+ slotHeightCss: size.height,
40
+ dpr,
41
+ imgAspect: meta.aspect,
42
+ fit,
43
+ }, meta);
44
+ }, [size, meta, fit]);
45
+ useEffect(() => {
46
+ if (target === undefined) {
47
+ return;
48
+ }
49
+ const current = displayedRef.current;
50
+ // Upgrade only (§4): never replace an already-rendered variant with an
51
+ // equal or smaller one (resize jitter, transient shrink).
52
+ if (current !== undefined && target.width * target.height <= current.width * current.height) {
53
+ return;
54
+ }
55
+ let cancelled = false;
56
+ const loader = document.createElement("img");
57
+ const commit = () => {
58
+ if (cancelled) {
59
+ return;
60
+ }
61
+ displayedRef.current = target;
62
+ setDisplayed(target);
63
+ };
64
+ loader.src = target.url;
65
+ if (typeof loader.decode === "function") {
66
+ // Swap only after decode — no blank frame during the upgrade.
67
+ loader.decode().then(commit, commit);
68
+ }
69
+ else {
70
+ loader.onload = commit;
71
+ loader.onerror = commit;
72
+ }
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [target]);
77
+ useEffect(() => {
78
+ if (displayed === undefined || visible) {
79
+ return;
80
+ }
81
+ // One frame at opacity 0 so the blur-up → sharp transition actually runs.
82
+ const id = requestAnimationFrame(() => {
83
+ setVisible(true);
84
+ });
85
+ return () => {
86
+ cancelAnimationFrame(id);
87
+ };
88
+ }, [displayed, visible]);
89
+ const containerStyle = {
90
+ position: "relative",
91
+ overflow: "hidden",
92
+ aspectRatio: String(meta.aspect),
93
+ ...style,
94
+ };
95
+ return (_jsxs("div", { ref: ref, className: className, style: containerStyle, children: [meta.preview_b64 !== undefined && (_jsx("img", { src: meta.preview_b64, alt: "", "aria-hidden": "true", style: {
96
+ ...FILL,
97
+ objectFit: fit,
98
+ filter: "blur(12px)",
99
+ transform: "scale(1.05)",
100
+ } })), displayed !== undefined && (_jsx("img", { ...imgProps, src: displayed.url, alt: alt, style: {
101
+ ...FILL,
102
+ objectFit: fit,
103
+ opacity: visible ? 1 : 0,
104
+ transition: "opacity 200ms ease",
105
+ } }))] }));
106
+ }
107
+ //# sourceMappingURL=Image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Image.js","sourceRoot":"","sources":["../src/Image.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export { pickTier, limitingAxis, chooseVariant } from "./tiers.js";
2
+ export type { Branch, ChooseVariantArgs, Fit, RenderMetadata, VariantMeta, } from "./tiers.js";
3
+ export { useImageSlot } from "./useImageSlot.js";
4
+ export type { ImageSlot, ImageSlotSize } from "./useImageSlot.js";
5
+ export { Image } from "./Image.js";
6
+ export type { ImageProps } from "./Image.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACnE,YAAY,EACV,MAAM,EACN,iBAAiB,EACjB,GAAG,EACH,cAAc,EACd,WAAW,GACZ,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGlE,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Pure tier/branch math (no DOM, no React).
2
+ export { pickTier, limitingAxis, chooseVariant } from "./tiers.js";
3
+ // Slot measurement (ResizeObserver, high-water-mark, SSR-safe).
4
+ export { useImageSlot } from "./useImageSlot.js";
5
+ // Blur-up component over the ladder.
6
+ export { Image } from "./Image.js";
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,4CAA4C;AAC5C,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AASnE,gEAAgE;AAChE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAGjD,qCAAqC;AACrC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,76 @@
1
+ /** One generated variant file of an image (images-and-cdn.md §5). */
2
+ export interface VariantMeta {
3
+ /** Ladder tier (px along the branch axis) or the untouched original. */
4
+ tier: number | "original";
5
+ /**
6
+ * `"w"` / `"h"` — preview-class branch (resize so that width/height == tier);
7
+ * `null` — thumbnail-class (min-side resize) and `"original"`.
8
+ */
9
+ branch: "w" | "h" | null;
10
+ url: string;
11
+ /** Actual pixel width of the file (after the no-upscale cap on native). */
12
+ width: number;
13
+ /** Actual pixel height of the file (after the no-upscale cap on native). */
14
+ height: number;
15
+ }
16
+ /**
17
+ * Immutable render-metadata snapshot produced on ingest by stapel-cdn (or the
18
+ * PIL provider) and resolved via `cdn.describe(ref)` — images-and-cdn.md §5,
19
+ * an additive extension of the chat-design.md attachment snapshot.
20
+ */
21
+ export interface RenderMetadata {
22
+ mime: string;
23
+ bytes: number;
24
+ width: number;
25
+ height: number;
26
+ /** width / height. */
27
+ aspect: number;
28
+ /** Video/audio duration; `null`/absent for still images. */
29
+ duration_ms?: number | null;
30
+ /** `data:image/webp;base64,...` — the 16px micro tier, blur-up placeholder. */
31
+ preview_b64?: string;
32
+ /** `true` ⇒ w/h branches are identical; `variants[].branch` is irrelevant (§3.3). */
33
+ square?: boolean;
34
+ variants: VariantMeta[];
35
+ }
36
+ export type Fit = "cover" | "contain";
37
+ export type Branch = "w" | "h";
38
+ /**
39
+ * Smallest tier that does not upscale beyond the ×1.1 tolerance (§2.2):
40
+ * the smallest `T` in `tiers` with `neededPx ≤ T × 1.1`; if none qualifies,
41
+ * the largest tier (upscaling is forbidden at this level too — past the
42
+ * ladder the consumer falls back to "original", see `chooseVariant`).
43
+ *
44
+ * `neededPx` must already include DPR (`cssSize × devicePixelRatio`).
45
+ */
46
+ export declare function pickTier(neededPx: number, tiers: readonly number[]): number;
47
+ /**
48
+ * Which axis of the slot limits the pixel budget (§3.5, mode Б):
49
+ * `cover` scales by `max(slotW/imgW, slotH/imgH)` — the limiting side is the
50
+ * one where the image is "narrower" relative to the slot; `contain` is the
51
+ * exact opposite. Tie-break on aspect equality is "w" (branches equivalent).
52
+ */
53
+ export declare function limitingAxis(imgAspect: number, slotAspect: number, fit: Fit): Branch;
54
+ export interface ChooseVariantArgs {
55
+ slotWidthCss: number;
56
+ slotHeightCss: number;
57
+ dpr: number;
58
+ /** width / height of the image — from metadata, known before any pixel loads. */
59
+ imgAspect: number;
60
+ fit: Fit;
61
+ }
62
+ /**
63
+ * Full selection (§3.5): limiting axis from (image aspect × slot aspect × fit),
64
+ * needed pixels along that axis (CSS × DPR), then the smallest non-upscaling
65
+ * tier among the variants that can serve that axis:
66
+ *
67
+ * - branch === axis — the matching preview branch;
68
+ * - branch === null (numeric tier) — thumbnail-class min-side variants, whose
69
+ * BOTH sides are ≥ tier, so they serve either axis;
70
+ * - `meta.square` — any branch (w/h identical, §3.3).
71
+ *
72
+ * Past the top of the ladder (needed > maxTier × 1.1) the "original" variant
73
+ * is returned when present — no tier would avoid an upscale (§2.2).
74
+ */
75
+ export declare function chooseVariant(args: ChooseVariantArgs, meta: RenderMetadata): VariantMeta;
76
+ //# sourceMappingURL=tiers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tiers.d.ts","sourceRoot":"","sources":["../src/tiers.ts"],"names":[],"mappings":"AAIA,qEAAqE;AACrE,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;IAC1B;;;OAGG;IACH,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,IAAI,CAAC;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,+EAA+E;IAC/E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,MAAM,GAAG,GAAG,OAAO,GAAG,SAAS,CAAC;AAEtC,MAAM,MAAM,MAAM,GAAG,GAAG,GAAG,GAAG,CAAC;AAE/B;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAW3E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,GAAG,MAAM,CASpF;AAED,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,GAAG,CAAC;CACV;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,cAAc,GAAG,WAAW,CAoCxF"}
package/dist/tiers.js ADDED
@@ -0,0 +1,83 @@
1
+ // Pure tier/branch math for the stapel-cdn variant ladder
2
+ // (docs/pending/images-and-cdn.md §2-3, §5). No DOM, no React — unit-testable
3
+ // against the owner's examples verbatim and reusable outside the component.
4
+ /**
5
+ * Smallest tier that does not upscale beyond the ×1.1 tolerance (§2.2):
6
+ * the smallest `T` in `tiers` with `neededPx ≤ T × 1.1`; if none qualifies,
7
+ * the largest tier (upscaling is forbidden at this level too — past the
8
+ * ladder the consumer falls back to "original", see `chooseVariant`).
9
+ *
10
+ * `neededPx` must already include DPR (`cssSize × devicePixelRatio`).
11
+ */
12
+ export function pickTier(neededPx, tiers) {
13
+ if (tiers.length === 0) {
14
+ throw new TypeError("pickTier: tiers must be non-empty");
15
+ }
16
+ const sorted = [...tiers].sort((a, b) => a - b);
17
+ for (const tier of sorted) {
18
+ if (neededPx <= tier * 1.1) {
19
+ return tier;
20
+ }
21
+ }
22
+ return sorted[sorted.length - 1];
23
+ }
24
+ /**
25
+ * Which axis of the slot limits the pixel budget (§3.5, mode Б):
26
+ * `cover` scales by `max(slotW/imgW, slotH/imgH)` — the limiting side is the
27
+ * one where the image is "narrower" relative to the slot; `contain` is the
28
+ * exact opposite. Tie-break on aspect equality is "w" (branches equivalent).
29
+ */
30
+ export function limitingAxis(imgAspect, slotAspect, fit) {
31
+ if (imgAspect === slotAspect) {
32
+ return "w";
33
+ }
34
+ const imageWiderThanSlot = imgAspect > slotAspect;
35
+ if (fit === "cover") {
36
+ return imageWiderThanSlot ? "h" : "w";
37
+ }
38
+ return imageWiderThanSlot ? "w" : "h";
39
+ }
40
+ /**
41
+ * Full selection (§3.5): limiting axis from (image aspect × slot aspect × fit),
42
+ * needed pixels along that axis (CSS × DPR), then the smallest non-upscaling
43
+ * tier among the variants that can serve that axis:
44
+ *
45
+ * - branch === axis — the matching preview branch;
46
+ * - branch === null (numeric tier) — thumbnail-class min-side variants, whose
47
+ * BOTH sides are ≥ tier, so they serve either axis;
48
+ * - `meta.square` — any branch (w/h identical, §3.3).
49
+ *
50
+ * Past the top of the ladder (needed > maxTier × 1.1) the "original" variant
51
+ * is returned when present — no tier would avoid an upscale (§2.2).
52
+ */
53
+ export function chooseVariant(args, meta) {
54
+ const { slotWidthCss, slotHeightCss, dpr, imgAspect, fit } = args;
55
+ const slotAspect = slotWidthCss / slotHeightCss;
56
+ const axis = limitingAxis(imgAspect, slotAspect, fit);
57
+ const neededPx = (axis === "w" ? slotWidthCss : slotHeightCss) * dpr;
58
+ const square = meta.square === true;
59
+ const candidates = meta.variants.filter((v) => typeof v.tier === "number" && (square || v.branch === null || v.branch === axis));
60
+ const original = meta.variants.find((v) => v.tier === "original");
61
+ if (candidates.length === 0) {
62
+ if (original !== undefined) {
63
+ return original;
64
+ }
65
+ throw new TypeError("chooseVariant: metadata has no usable variants");
66
+ }
67
+ const tiers = [...new Set(candidates.map((v) => v.tier))];
68
+ const maxTier = Math.max(...tiers);
69
+ if (neededPx > maxTier * 1.1 && original !== undefined) {
70
+ return original;
71
+ }
72
+ const tier = pickTier(neededPx, tiers);
73
+ const exact = candidates.find((v) => v.tier === tier && v.branch === axis);
74
+ if (exact !== undefined) {
75
+ return exact;
76
+ }
77
+ const minSide = candidates.find((v) => v.tier === tier && v.branch === null);
78
+ if (minSide !== undefined) {
79
+ return minSide;
80
+ }
81
+ return candidates.find((v) => v.tier === tier);
82
+ }
83
+ //# sourceMappingURL=tiers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tiers.js","sourceRoot":"","sources":["../src/tiers.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import type { RefObject } from "react";
2
+ export interface ImageSlotSize {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ export interface ImageSlot<T extends HTMLElement = HTMLElement> {
7
+ ref: RefObject<T | null>;
8
+ /** `undefined` until mounted and first measured (SSR-safe, useBreakpoint pattern). */
9
+ size: ImageSlotSize | undefined;
10
+ }
11
+ /**
12
+ * Measures the rendered slot of an element with a ResizeObserver
13
+ * (images-and-cdn.md §4). SSR-safe: `size` is `undefined` until mounted, so
14
+ * server and first client render agree.
15
+ *
16
+ * High-water-mark per axis: the reported size only ever GROWS. A transient
17
+ * shrink (splitter drag, viewport resize down, sidebar toggle) must not
18
+ * re-pick a smaller, already-loaded tier — upgrades only (§4 "докачка только
19
+ * вверх").
20
+ */
21
+ export declare function useImageSlot<T extends HTMLElement = HTMLElement>(): ImageSlot<T>;
22
+ //# sourceMappingURL=useImageSlot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useImageSlot.d.ts","sourceRoot":"","sources":["../src/useImageSlot.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,SAAS,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW;IAC5D,GAAG,EAAE,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACzB,sFAAsF;IACtF,IAAI,EAAE,aAAa,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,CAkDhF"}
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ /**
3
+ * Measures the rendered slot of an element with a ResizeObserver
4
+ * (images-and-cdn.md §4). SSR-safe: `size` is `undefined` until mounted, so
5
+ * server and first client render agree.
6
+ *
7
+ * High-water-mark per axis: the reported size only ever GROWS. A transient
8
+ * shrink (splitter drag, viewport resize down, sidebar toggle) must not
9
+ * re-pick a smaller, already-loaded tier — upgrades only (§4 "докачка только
10
+ * вверх").
11
+ */
12
+ export function useImageSlot() {
13
+ const ref = useRef(null);
14
+ const [size, setSize] = useState(undefined);
15
+ useEffect(() => {
16
+ const el = ref.current;
17
+ if (el === null) {
18
+ return;
19
+ }
20
+ const grow = (width, height) => {
21
+ if (width <= 0 && height <= 0) {
22
+ return;
23
+ }
24
+ setSize((prev) => {
25
+ const next = {
26
+ width: Math.max(prev?.width ?? 0, width),
27
+ height: Math.max(prev?.height ?? 0, height),
28
+ };
29
+ if (prev !== undefined && next.width === prev.width && next.height === prev.height) {
30
+ return prev;
31
+ }
32
+ return next;
33
+ });
34
+ };
35
+ if (typeof ResizeObserver === "undefined") {
36
+ // Environment without ResizeObserver: single static measurement.
37
+ const rect = el.getBoundingClientRect();
38
+ grow(rect.width, rect.height);
39
+ return;
40
+ }
41
+ const observer = new ResizeObserver((entries) => {
42
+ for (const entry of entries) {
43
+ const box = entry.contentBoxSize[0];
44
+ if (box !== undefined) {
45
+ grow(box.inlineSize, box.blockSize);
46
+ }
47
+ else {
48
+ grow(entry.contentRect.width, entry.contentRect.height);
49
+ }
50
+ }
51
+ });
52
+ observer.observe(el);
53
+ return () => {
54
+ observer.disconnect();
55
+ };
56
+ }, []);
57
+ return { ref, size };
58
+ }
59
+ //# sourceMappingURL=useImageSlot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useImageSlot.js","sourceRoot":"","sources":["../src/useImageSlot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAcpD;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY;IAC1B,MAAM,GAAG,GAAG,MAAM,CAAI,IAAI,CAAC,CAAC;IAC5B,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAA4B,SAAS,CAAC,CAAC;IAEvE,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO,CAAC;QACvB,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO;QACT,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,KAAa,EAAE,MAAc,EAAQ,EAAE;YACnD,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;gBAC9B,OAAO;YACT,CAAC;YACD,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;gBACf,MAAM,IAAI,GAAG;oBACX,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,CAAC;oBACxC,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC,EAAE,MAAM,CAAC;iBAC5C,CAAC;gBACF,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC;oBACnF,OAAO,IAAI,CAAC;gBACd,CAAC;gBACD,OAAO,IAAI,CAAC;YACd,CAAC,CAAC,CAAC;QACL,CAAC,CAAC;QAEF,IAAI,OAAO,cAAc,KAAK,WAAW,EAAE,CAAC;YAC1C,iEAAiE;YACjE,MAAM,IAAI,GAAG,EAAE,CAAC,qBAAqB,EAAE,CAAC;YACxC,IAAI,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;YAC9B,OAAO;QACT,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,CAAC,OAAO,EAAE,EAAE;YAC9C,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;gBAC5B,MAAM,GAAG,GAAG,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC;gBACpC,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;oBACtB,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC;gBACtC,CAAC;qBAAM,CAAC;oBACN,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBAC1D,CAAC;YACH,CAAC;QACH,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QACrB,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,UAAU,EAAE,CAAC;QACxB,CAAC,CAAC;IACJ,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,CAAC;AACvB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@stapel/image",
3
+ "version": "0.1.0",
4
+ "description": "Stapel image rendering: tier/branch math for the CDN variant ladder (pickTier, limitingAxis, chooseVariant), slot measurement hook (useImageSlot), and a blur-up <Image> component with upgrade-only tier fetching.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/usestapel/stapel-react.git",
9
+ "directory": "packages/image"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "tsconfig.json",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -p tsconfig.json",
30
+ "test": "vitest run",
31
+ "lint": "eslint .",
32
+ "size": "size-limit"
33
+ },
34
+ "size-limit": [
35
+ {
36
+ "path": "dist/index.js",
37
+ "limit": "6 KB"
38
+ }
39
+ ],
40
+ "peerDependencies": {
41
+ "react": ">=19"
42
+ },
43
+ "devDependencies": {
44
+ "@size-limit/preset-small-lib": "^11.2.0",
45
+ "@testing-library/react": "^16.3.0",
46
+ "@types/react": "^19.1.0",
47
+ "@types/react-dom": "^19.1.0",
48
+ "jsdom": "^26.1.0",
49
+ "react": "^19.1.0",
50
+ "react-dom": "^19.1.0",
51
+ "size-limit": "^11.2.0",
52
+ "typescript": "^5.8.3",
53
+ "vitest": "^3.2.4"
54
+ },
55
+ "engines": {
56
+ "node": ">=22"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
package/src/Image.tsx ADDED
@@ -0,0 +1,152 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import type { CSSProperties, ImgHTMLAttributes, ReactElement } from "react";
3
+ import { chooseVariant } from "./tiers.js";
4
+ import type { Fit, RenderMetadata, VariantMeta } from "./tiers.js";
5
+ import { useImageSlot } from "./useImageSlot.js";
6
+
7
+ export interface ImageProps
8
+ extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "alt"> {
9
+ meta: RenderMetadata;
10
+ /** Default `"cover"` (chat/catalog/avatar — fill the slot). */
11
+ fit?: Fit;
12
+ /** Required, no default. */
13
+ alt: string;
14
+ }
15
+
16
+ const FILL: CSSProperties = {
17
+ position: "absolute",
18
+ inset: 0,
19
+ width: "100%",
20
+ height: "100%",
21
+ };
22
+
23
+ /**
24
+ * CDN-ladder-aware image (images-and-cdn.md §4):
25
+ *
26
+ * 1. `aspect-ratio` from metadata goes on the container BEFORE the first slot
27
+ * measurement — layout-shift protection entirely from the snapshot, no
28
+ * network round-trip.
29
+ * 2. Blur-up: the inlined 16px `preview_b64` renders instantly underneath
30
+ * until the chosen tier has decoded.
31
+ * 3. `useImageSlot()` measures the actual slot → `chooseVariant(...)` → src.
32
+ * 4. Upgrades only: a re-measure that picks a variant no bigger than the one
33
+ * already rendered is ignored; a bigger pick loads off-DOM and swaps in
34
+ * only after `decode()` — never a flash of empty slot, never a downgrade.
35
+ */
36
+ export function Image({
37
+ meta,
38
+ fit = "cover",
39
+ alt,
40
+ style,
41
+ className,
42
+ ...imgProps
43
+ }: ImageProps): ReactElement {
44
+ const { ref, size } = useImageSlot<HTMLDivElement>();
45
+
46
+ const [displayed, setDisplayed] = useState<VariantMeta | undefined>(undefined);
47
+ const [visible, setVisible] = useState(false);
48
+ const displayedRef = useRef<VariantMeta | undefined>(undefined);
49
+
50
+ const target = useMemo(() => {
51
+ if (size === undefined || size.width <= 0 || size.height <= 0) {
52
+ return undefined;
53
+ }
54
+ if (meta.variants.length === 0) {
55
+ return undefined;
56
+ }
57
+ const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
58
+ return chooseVariant(
59
+ {
60
+ slotWidthCss: size.width,
61
+ slotHeightCss: size.height,
62
+ dpr,
63
+ imgAspect: meta.aspect,
64
+ fit,
65
+ },
66
+ meta
67
+ );
68
+ }, [size, meta, fit]);
69
+
70
+ useEffect(() => {
71
+ if (target === undefined) {
72
+ return;
73
+ }
74
+ const current = displayedRef.current;
75
+ // Upgrade only (§4): never replace an already-rendered variant with an
76
+ // equal or smaller one (resize jitter, transient shrink).
77
+ if (current !== undefined && target.width * target.height <= current.width * current.height) {
78
+ return;
79
+ }
80
+ let cancelled = false;
81
+ const loader = document.createElement("img");
82
+ const commit = (): void => {
83
+ if (cancelled) {
84
+ return;
85
+ }
86
+ displayedRef.current = target;
87
+ setDisplayed(target);
88
+ };
89
+ loader.src = target.url;
90
+ if (typeof loader.decode === "function") {
91
+ // Swap only after decode — no blank frame during the upgrade.
92
+ loader.decode().then(commit, commit);
93
+ } else {
94
+ loader.onload = commit;
95
+ loader.onerror = commit;
96
+ }
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, [target]);
101
+
102
+ useEffect(() => {
103
+ if (displayed === undefined || visible) {
104
+ return;
105
+ }
106
+ // One frame at opacity 0 so the blur-up → sharp transition actually runs.
107
+ const id = requestAnimationFrame(() => {
108
+ setVisible(true);
109
+ });
110
+ return () => {
111
+ cancelAnimationFrame(id);
112
+ };
113
+ }, [displayed, visible]);
114
+
115
+ const containerStyle: CSSProperties = {
116
+ position: "relative",
117
+ overflow: "hidden",
118
+ aspectRatio: String(meta.aspect),
119
+ ...style,
120
+ };
121
+
122
+ return (
123
+ <div ref={ref} className={className} style={containerStyle}>
124
+ {meta.preview_b64 !== undefined && (
125
+ <img
126
+ src={meta.preview_b64}
127
+ alt=""
128
+ aria-hidden="true"
129
+ style={{
130
+ ...FILL,
131
+ objectFit: fit,
132
+ filter: "blur(12px)",
133
+ transform: "scale(1.05)",
134
+ }}
135
+ />
136
+ )}
137
+ {displayed !== undefined && (
138
+ <img
139
+ {...imgProps}
140
+ src={displayed.url}
141
+ alt={alt}
142
+ style={{
143
+ ...FILL,
144
+ objectFit: fit,
145
+ opacity: visible ? 1 : 0,
146
+ transition: "opacity 200ms ease",
147
+ }}
148
+ />
149
+ )}
150
+ </div>
151
+ );
152
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Pure tier/branch math (no DOM, no React).
2
+ export { pickTier, limitingAxis, chooseVariant } from "./tiers.js";
3
+ export type {
4
+ Branch,
5
+ ChooseVariantArgs,
6
+ Fit,
7
+ RenderMetadata,
8
+ VariantMeta,
9
+ } from "./tiers.js";
10
+
11
+ // Slot measurement (ResizeObserver, high-water-mark, SSR-safe).
12
+ export { useImageSlot } from "./useImageSlot.js";
13
+ export type { ImageSlot, ImageSlotSize } from "./useImageSlot.js";
14
+
15
+ // Blur-up component over the ladder.
16
+ export { Image } from "./Image.js";
17
+ export type { ImageProps } from "./Image.js";
package/src/tiers.ts ADDED
@@ -0,0 +1,142 @@
1
+ // Pure tier/branch math for the stapel-cdn variant ladder
2
+ // (docs/pending/images-and-cdn.md §2-3, §5). No DOM, no React — unit-testable
3
+ // against the owner's examples verbatim and reusable outside the component.
4
+
5
+ /** One generated variant file of an image (images-and-cdn.md §5). */
6
+ export interface VariantMeta {
7
+ /** Ladder tier (px along the branch axis) or the untouched original. */
8
+ tier: number | "original";
9
+ /**
10
+ * `"w"` / `"h"` — preview-class branch (resize so that width/height == tier);
11
+ * `null` — thumbnail-class (min-side resize) and `"original"`.
12
+ */
13
+ branch: "w" | "h" | null;
14
+ url: string;
15
+ /** Actual pixel width of the file (after the no-upscale cap on native). */
16
+ width: number;
17
+ /** Actual pixel height of the file (after the no-upscale cap on native). */
18
+ height: number;
19
+ }
20
+
21
+ /**
22
+ * Immutable render-metadata snapshot produced on ingest by stapel-cdn (or the
23
+ * PIL provider) and resolved via `cdn.describe(ref)` — images-and-cdn.md §5,
24
+ * an additive extension of the chat-design.md attachment snapshot.
25
+ */
26
+ export interface RenderMetadata {
27
+ mime: string;
28
+ bytes: number;
29
+ width: number;
30
+ height: number;
31
+ /** width / height. */
32
+ aspect: number;
33
+ /** Video/audio duration; `null`/absent for still images. */
34
+ duration_ms?: number | null;
35
+ /** `data:image/webp;base64,...` — the 16px micro tier, blur-up placeholder. */
36
+ preview_b64?: string;
37
+ /** `true` ⇒ w/h branches are identical; `variants[].branch` is irrelevant (§3.3). */
38
+ square?: boolean;
39
+ variants: VariantMeta[];
40
+ }
41
+
42
+ export type Fit = "cover" | "contain";
43
+
44
+ export type Branch = "w" | "h";
45
+
46
+ /**
47
+ * Smallest tier that does not upscale beyond the ×1.1 tolerance (§2.2):
48
+ * the smallest `T` in `tiers` with `neededPx ≤ T × 1.1`; if none qualifies,
49
+ * the largest tier (upscaling is forbidden at this level too — past the
50
+ * ladder the consumer falls back to "original", see `chooseVariant`).
51
+ *
52
+ * `neededPx` must already include DPR (`cssSize × devicePixelRatio`).
53
+ */
54
+ export function pickTier(neededPx: number, tiers: readonly number[]): number {
55
+ if (tiers.length === 0) {
56
+ throw new TypeError("pickTier: tiers must be non-empty");
57
+ }
58
+ const sorted = [...tiers].sort((a, b) => a - b);
59
+ for (const tier of sorted) {
60
+ if (neededPx <= tier * 1.1) {
61
+ return tier;
62
+ }
63
+ }
64
+ return sorted[sorted.length - 1] as number;
65
+ }
66
+
67
+ /**
68
+ * Which axis of the slot limits the pixel budget (§3.5, mode Б):
69
+ * `cover` scales by `max(slotW/imgW, slotH/imgH)` — the limiting side is the
70
+ * one where the image is "narrower" relative to the slot; `contain` is the
71
+ * exact opposite. Tie-break on aspect equality is "w" (branches equivalent).
72
+ */
73
+ export function limitingAxis(imgAspect: number, slotAspect: number, fit: Fit): Branch {
74
+ if (imgAspect === slotAspect) {
75
+ return "w";
76
+ }
77
+ const imageWiderThanSlot = imgAspect > slotAspect;
78
+ if (fit === "cover") {
79
+ return imageWiderThanSlot ? "h" : "w";
80
+ }
81
+ return imageWiderThanSlot ? "w" : "h";
82
+ }
83
+
84
+ export interface ChooseVariantArgs {
85
+ slotWidthCss: number;
86
+ slotHeightCss: number;
87
+ dpr: number;
88
+ /** width / height of the image — from metadata, known before any pixel loads. */
89
+ imgAspect: number;
90
+ fit: Fit;
91
+ }
92
+
93
+ /**
94
+ * Full selection (§3.5): limiting axis from (image aspect × slot aspect × fit),
95
+ * needed pixels along that axis (CSS × DPR), then the smallest non-upscaling
96
+ * tier among the variants that can serve that axis:
97
+ *
98
+ * - branch === axis — the matching preview branch;
99
+ * - branch === null (numeric tier) — thumbnail-class min-side variants, whose
100
+ * BOTH sides are ≥ tier, so they serve either axis;
101
+ * - `meta.square` — any branch (w/h identical, §3.3).
102
+ *
103
+ * Past the top of the ladder (needed > maxTier × 1.1) the "original" variant
104
+ * is returned when present — no tier would avoid an upscale (§2.2).
105
+ */
106
+ export function chooseVariant(args: ChooseVariantArgs, meta: RenderMetadata): VariantMeta {
107
+ const { slotWidthCss, slotHeightCss, dpr, imgAspect, fit } = args;
108
+ const slotAspect = slotWidthCss / slotHeightCss;
109
+ const axis = limitingAxis(imgAspect, slotAspect, fit);
110
+ const neededPx = (axis === "w" ? slotWidthCss : slotHeightCss) * dpr;
111
+
112
+ const square = meta.square === true;
113
+ const candidates = meta.variants.filter(
114
+ (v): v is VariantMeta & { tier: number } =>
115
+ typeof v.tier === "number" && (square || v.branch === null || v.branch === axis)
116
+ );
117
+ const original = meta.variants.find((v) => v.tier === "original");
118
+
119
+ if (candidates.length === 0) {
120
+ if (original !== undefined) {
121
+ return original;
122
+ }
123
+ throw new TypeError("chooseVariant: metadata has no usable variants");
124
+ }
125
+
126
+ const tiers = [...new Set(candidates.map((v) => v.tier))];
127
+ const maxTier = Math.max(...tiers);
128
+ if (neededPx > maxTier * 1.1 && original !== undefined) {
129
+ return original;
130
+ }
131
+
132
+ const tier = pickTier(neededPx, tiers);
133
+ const exact = candidates.find((v) => v.tier === tier && v.branch === axis);
134
+ if (exact !== undefined) {
135
+ return exact;
136
+ }
137
+ const minSide = candidates.find((v) => v.tier === tier && v.branch === null);
138
+ if (minSide !== undefined) {
139
+ return minSide;
140
+ }
141
+ return candidates.find((v) => v.tier === tier) as VariantMeta;
142
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+
4
+ export interface ImageSlotSize {
5
+ width: number;
6
+ height: number;
7
+ }
8
+
9
+ export interface ImageSlot<T extends HTMLElement = HTMLElement> {
10
+ ref: RefObject<T | null>;
11
+ /** `undefined` until mounted and first measured (SSR-safe, useBreakpoint pattern). */
12
+ size: ImageSlotSize | undefined;
13
+ }
14
+
15
+ /**
16
+ * Measures the rendered slot of an element with a ResizeObserver
17
+ * (images-and-cdn.md §4). SSR-safe: `size` is `undefined` until mounted, so
18
+ * server and first client render agree.
19
+ *
20
+ * High-water-mark per axis: the reported size only ever GROWS. A transient
21
+ * shrink (splitter drag, viewport resize down, sidebar toggle) must not
22
+ * re-pick a smaller, already-loaded tier — upgrades only (§4 "докачка только
23
+ * вверх").
24
+ */
25
+ export function useImageSlot<T extends HTMLElement = HTMLElement>(): ImageSlot<T> {
26
+ const ref = useRef<T>(null);
27
+ const [size, setSize] = useState<ImageSlotSize | undefined>(undefined);
28
+
29
+ useEffect(() => {
30
+ const el = ref.current;
31
+ if (el === null) {
32
+ return;
33
+ }
34
+
35
+ const grow = (width: number, height: number): void => {
36
+ if (width <= 0 && height <= 0) {
37
+ return;
38
+ }
39
+ setSize((prev) => {
40
+ const next = {
41
+ width: Math.max(prev?.width ?? 0, width),
42
+ height: Math.max(prev?.height ?? 0, height),
43
+ };
44
+ if (prev !== undefined && next.width === prev.width && next.height === prev.height) {
45
+ return prev;
46
+ }
47
+ return next;
48
+ });
49
+ };
50
+
51
+ if (typeof ResizeObserver === "undefined") {
52
+ // Environment without ResizeObserver: single static measurement.
53
+ const rect = el.getBoundingClientRect();
54
+ grow(rect.width, rect.height);
55
+ return;
56
+ }
57
+
58
+ const observer = new ResizeObserver((entries) => {
59
+ for (const entry of entries) {
60
+ const box = entry.contentBoxSize[0];
61
+ if (box !== undefined) {
62
+ grow(box.inlineSize, box.blockSize);
63
+ } else {
64
+ grow(entry.contentRect.width, entry.contentRect.height);
65
+ }
66
+ }
67
+ });
68
+ observer.observe(el);
69
+ return () => {
70
+ observer.disconnect();
71
+ };
72
+ }, []);
73
+
74
+ return { ref, size };
75
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "_comment": "Self-contained on purpose: standalone-buildable per frontend-standard §7. Mirrors the root tsconfig.base.json settings.",
4
+ "compilerOptions": {
5
+ "target": "ES2022",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "noImplicitOverride": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "isolatedModules": true,
15
+ "isolatedDeclarations": true,
16
+ "verbatimModuleSyntax": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "skipLibCheck": true,
21
+ "forceConsistentCasingInFileNames": true,
22
+ "outDir": "dist",
23
+ "rootDir": "src"
24
+ },
25
+ "include": ["src"]
26
+ }