vireglass 3.0.0 → 3.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 CHANGED
@@ -153,6 +153,36 @@ glass.setMorph({ smoothing: 34, shape: { offsetX: 150, offsetY: -78, width: 170,
153
153
  Two shapes joined through the smooth union — §5's merging and splitting. What the shapes mean is
154
154
  your choreography; the material only knows how two silhouettes join.
155
155
 
156
+ ### Morphing elements
157
+
158
+ ```ts
159
+ import { attachGlass, renderStats, resetRenderStats } from 'vireglass/dom';
160
+
161
+ glass.setInterim(true); // before a morph, a drag or a growth
162
+ // ...animate the element's box, and `setMorph` shapes if it flows into others...
163
+ glass.setInterim(false); // when it lands
164
+ ```
165
+
166
+ A glass element that changes shape every frame used to re-run the whole pipeline every frame: a
167
+ backdrop probe, a full-resolution displacement map (and spectral map) and a new filter element.
168
+ That is the difference between 60 fps and 10 to 20. While interim, the element does none of it: it
169
+ reuses its last sample, keeps its filter, and the browser stretches the existing map over the live
170
+ box. The body, rim, shadow and custom properties still update every frame. A change of the morph
171
+ shapes passed to `setMorph`, of `setAppear` or of the system accessibility settings rebuilds the map
172
+ at reduced resolution and shows it in place, where the browser decodes lazily only once it has been
173
+ decoded. `setInterim(false)` does one full-resolution rebuild and one probe (with a supplied
174
+ `sample` it does not probe). Repeating a call is a no-op.
175
+
176
+ Always pair `setInterim(true)` with `setInterim(false)`. As a safety net an element that gets no call
177
+ at all for 2 seconds (no resize, morph, appear or update) leaves interim by itself, exactly as
178
+ `setInterim(false)` would, so a morph that never reported landing cannot leave the glass on a
179
+ stretched map. A pause longer than that in the middle of a morph ends interim early.
180
+
181
+ `renderStats()` returns `{ probes, fullBakes, reducedBakes, filterSwaps }`, counted since the last
182
+ `resetRenderStats()`. Every map counts: a rebuild with a spectral edge is two full bakes, one for
183
+ each map; a cache hit is none. It is the hook for a performance gate that counts work instead of
184
+ timing it: drive a morph, then assert the counters stayed at zero.
185
+
156
186
  `attachGlass` writes CSS custom properties you can use directly:
157
187
 
158
188
  | | |
package/dist/dom.cjs CHANGED
@@ -1024,6 +1024,55 @@ function probeBackdrop(el, opts = {}) {
1024
1024
  return statsFromSamples(samples);
1025
1025
  }
1026
1026
 
1027
+ // src/dom/stats.ts
1028
+ var counts = { probes: 0, fullBakes: 0, reducedBakes: 0, filterSwaps: 0 };
1029
+ function renderStats() {
1030
+ return { ...counts };
1031
+ }
1032
+ function resetRenderStats() {
1033
+ counts.probes = 0;
1034
+ counts.fullBakes = 0;
1035
+ counts.reducedBakes = 0;
1036
+ counts.filterSwaps = 0;
1037
+ }
1038
+ function bump(key) {
1039
+ counts[key] += 1;
1040
+ }
1041
+
1042
+ // src/dom/interim.ts
1043
+ function quantise(value, step = 2) {
1044
+ return Math.round(value / step) * step;
1045
+ }
1046
+ function interimKey(width, height, cornerRadius, morph, extra) {
1047
+ return `i|${quantise(width)}x${quantise(height)}r${quantise(cornerRadius)}|${morph}|${extra}`;
1048
+ }
1049
+ function filterSignature(blur2, ior, iorSpread2, hasHue) {
1050
+ return `${blur2}|${ior}|${iorSpread2 > 1e-4 ? iorSpread2 : 0}|${hasHue ? "h" : "-"}`;
1051
+ }
1052
+ function createLru(limit) {
1053
+ const store2 = /* @__PURE__ */ new Map();
1054
+ return {
1055
+ get(key) {
1056
+ const hit = store2.get(key);
1057
+ if (hit === void 0) return void 0;
1058
+ store2.delete(key);
1059
+ store2.set(key, hit);
1060
+ return hit;
1061
+ },
1062
+ set(key, value) {
1063
+ store2.delete(key);
1064
+ store2.set(key, value);
1065
+ if (store2.size > limit) {
1066
+ const oldest = store2.keys().next();
1067
+ if (!oldest.done) store2.delete(oldest.value);
1068
+ }
1069
+ },
1070
+ get size() {
1071
+ return store2.size;
1072
+ }
1073
+ };
1074
+ }
1075
+
1027
1076
  // src/dom/support.ts
1028
1077
  var backdropFilterSupport = null;
1029
1078
  var svgBackdropFilterSupport = null;
@@ -1338,9 +1387,11 @@ function rimGradientCss(optics, ambient, light, appear = 1) {
1338
1387
  return `conic-gradient(from ${start.toFixed(1)}deg, ${line.join(", ")}), conic-gradient(${dark} 0deg, ${dark} 360deg)`;
1339
1388
  }
1340
1389
  var RIM_WIDTH_PX = RIM.widthPx;
1341
- function contrastRimCss(bodyLuma, rimLuma) {
1390
+ function contrastRimCss(bodyLuma, rimLuma, appear = 1) {
1342
1391
  const v = to2552(rimLuma);
1343
- return `conic-gradient(rgb(${v},${v},${v}) 0deg, rgb(${v},${v},${v}) 360deg)`;
1392
+ const present = clamp016(appear);
1393
+ const colour = present >= 1 ? `rgb(${v},${v},${v})` : `rgba(${v},${v},${v},${present.toFixed(3)})`;
1394
+ return `conic-gradient(${colour} 0deg, ${colour} 360deg)`;
1344
1395
  }
1345
1396
 
1346
1397
  // src/dom/nesting.ts
@@ -1685,7 +1736,7 @@ function createGlassGroup(members, opts = {}) {
1685
1736
  }
1686
1737
  function update() {
1687
1738
  if (destroyed) return;
1688
- const probe = opts.probe ?? probeBackdrop;
1739
+ const probe2 = opts.probe ?? probeBackdrop;
1689
1740
  members.forEach(({ el, legibility }, index) => {
1690
1741
  const rect = el.getBoundingClientRect();
1691
1742
  if (rect.width <= 0 || rect.height <= 0) {
@@ -1696,7 +1747,7 @@ function createGlassGroup(members, opts = {}) {
1696
1747
  String(index),
1697
1748
  rect.left + rect.width / 2,
1698
1749
  rect.top + rect.height / 2,
1699
- probe(el),
1750
+ probe2(el),
1700
1751
  legibility ?? 0
1701
1752
  );
1702
1753
  });
@@ -1860,6 +1911,18 @@ function fallbackFilterCss(optics) {
1860
1911
  const saturate = 1 + optics.colorPickup;
1861
1912
  return `blur(${blurPx}px) saturate(${saturate})`;
1862
1913
  }
1914
+ function probe(el) {
1915
+ bump("probes");
1916
+ return probeBackdrop(el);
1917
+ }
1918
+ function decoded(url) {
1919
+ if (typeof Image === "undefined") return Promise.resolve();
1920
+ const img = new Image();
1921
+ img.src = url;
1922
+ return typeof img.decode === "function" ? img.decode().catch(() => void 0) : Promise.resolve();
1923
+ }
1924
+ var INTERIM_IDLE_MS = 2e3;
1925
+ var isEmpty = (g) => quantise(g.width) < 1 || quantise(g.height) < 1;
1863
1926
  var to2553 = (v) => Math.round(Math.min(Math.max(v, 0), 1) * 255);
1864
1927
  function rgbCss(r, g, b) {
1865
1928
  return `rgb(${to2553(r)} ${to2553(g)} ${to2553(b)})`;
@@ -1886,6 +1949,13 @@ function attachGlass(el, opts = {}) {
1886
1949
  let filterEl = null;
1887
1950
  let destroyed = false;
1888
1951
  let mapKey = "";
1952
+ let interim = false;
1953
+ let settling = false;
1954
+ let lastSample = null;
1955
+ let seq = 0;
1956
+ let shownSeq = 0;
1957
+ let idleTimer = null;
1958
+ const interimMaps = createLru(8);
1889
1959
  let morph = null;
1890
1960
  const onlyWhenTouched = opts.variant === "interactive";
1891
1961
  let appear = onlyWhenTouched ? 0 : opts.appear ?? 1;
@@ -1897,6 +1967,7 @@ function attachGlass(el, opts = {}) {
1897
1967
  mapKey = "";
1898
1968
  deform.setElastic(elasticAllowed(opts.accessibility ?? a11y));
1899
1969
  apply();
1970
+ if (interim) requestInterimMap();
1900
1971
  });
1901
1972
  warnIfNested(el);
1902
1973
  const host = hostFor(el);
@@ -1922,24 +1993,111 @@ function attachGlass(el, opts = {}) {
1922
1993
  const part = (s) => s ? `${s.offsetX},${s.offsetY},${s.width},${s.height},${s.cornerRadius}` : "-";
1923
1994
  return `${morph.smoothing}/${part(morph.shape)}/${part(morph.shape2)}`;
1924
1995
  }
1996
+ function invalidate() {
1997
+ seq += 1;
1998
+ shownSeq = seq;
1999
+ }
2000
+ function keepInterim() {
2001
+ if (idleTimer !== null) clearTimeout(idleTimer);
2002
+ idleTimer = setTimeout(leaveInterim, INTERIM_IDLE_MS);
2003
+ }
2004
+ function leaveInterim() {
2005
+ if (idleTimer !== null) clearTimeout(idleTimer);
2006
+ idleTimer = null;
2007
+ if (destroyed || !interim) return;
2008
+ interim = false;
2009
+ invalidate();
2010
+ mapKey = "";
2011
+ settling = true;
2012
+ apply();
2013
+ }
2014
+ function retarget(f, map, hue) {
2015
+ const images = f.querySelectorAll("feImage");
2016
+ images[0]?.setAttribute("href", map.url);
2017
+ if (hue) images[1]?.setAttribute("href", hue.url);
2018
+ for (const pass of f.querySelectorAll("feDisplacementMap")) {
2019
+ const ratio = Number(pass.getAttribute("data-vireglass-ratio") ?? 1);
2020
+ pass.setAttribute("scale", String(map.scale * (Number.isFinite(ratio) ? ratio : 1)));
2021
+ }
2022
+ }
2023
+ function requestInterimMap() {
2024
+ if (!svgSupported || !filterEl || destroyed) return;
2025
+ const geometry = opts.geometry ?? measureGeometry(el);
2026
+ if (isEmpty(geometry)) return;
2027
+ const opticsNow = optics();
2028
+ const key = interimKey(
2029
+ geometry.width,
2030
+ geometry.height,
2031
+ geometry.cornerRadius,
2032
+ JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
2033
+ `a${appear.toFixed(3)}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}`
2034
+ );
2035
+ let rec = interimMaps.get(key);
2036
+ if (!rec) {
2037
+ bump("reducedBakes");
2038
+ rec = { map: buildDisplacementMap(opticsNow, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false };
2039
+ interimMaps.set(key, rec);
2040
+ }
2041
+ const mine = seq += 1;
2042
+ const record = rec;
2043
+ const forFilter = filterEl;
2044
+ const show = () => {
2045
+ if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
2046
+ shownSeq = mine;
2047
+ retarget(forFilter, record.map, null);
2048
+ };
2049
+ if (record.ready) {
2050
+ show();
2051
+ return;
2052
+ }
2053
+ void decoded(record.map.url).then(() => {
2054
+ record.ready = true;
2055
+ show();
2056
+ });
2057
+ }
1925
2058
  function apply() {
1926
2059
  if (destroyed) return;
2060
+ if (interim) keepInterim();
1927
2061
  const geometry = opts.geometry ?? measureGeometry(el);
1928
2062
  const opticsNow = optics();
1929
- const sample = supplied ?? probeBackdrop(el);
2063
+ const sample = supplied ?? (interim && lastSample ? lastSample : probe(el));
2064
+ lastSample = sample;
1930
2065
  const light = shouldInkBeLight(sample, wasLight);
1931
2066
  wasLight = light;
1932
2067
  const body = opts.body === false ? null : bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear);
1933
2068
  if (svgSupported) {
1934
2069
  const key = `a${appear.toFixed(3)}|m${morphKey()}|${geometry.width}x${geometry.height}r${geometry.cornerRadius}@${devicePixelRatio()}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}:${opticsNow.blur}`;
1935
- if (key !== mapKey || !filterEl) {
1936
- const map = cached(`d|${key}`, () => buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState()));
1937
- const defs = getFilterHost(host).querySelector("defs");
1938
- const hue = hasSpectralEdge(opticsNow) ? cached(`h|${key}`, () => buildSpectralMap(opticsNow, geometry, devicePixelRatio())) : null;
1939
- const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
1940
- if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
1941
- else defs.appendChild(next);
1942
- filterEl = next;
2070
+ if (interim && filterEl) ; else if (isEmpty(geometry)) ; else if (key !== mapKey || !filterEl) {
2071
+ const map = cached(`d|${key}`, () => {
2072
+ bump("fullBakes");
2073
+ return buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState());
2074
+ });
2075
+ const hue = hasSpectralEdge(opticsNow) ? cached(`h|${key}`, () => {
2076
+ bump("fullBakes");
2077
+ return buildSpectralMap(opticsNow, geometry, devicePixelRatio());
2078
+ }) : null;
2079
+ const signature = filterSignature(opticsNow.blur, opticsNow.ior, opticsNow.iorSpread, !!hue);
2080
+ if (settling && filterEl?.getAttribute("data-vireglass-signature") === signature) {
2081
+ settling = false;
2082
+ const mine = seq += 1;
2083
+ const forFilter = filterEl;
2084
+ const forKey = key;
2085
+ void Promise.all([decoded(map.url), hue ? decoded(hue.url) : void 0]).then(() => {
2086
+ if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
2087
+ shownSeq = mine;
2088
+ retarget(forFilter, map, hue);
2089
+ });
2090
+ } else {
2091
+ settling = false;
2092
+ invalidate();
2093
+ const defs = getFilterHost(host).querySelector("defs");
2094
+ const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
2095
+ next.setAttribute("data-vireglass-signature", signature);
2096
+ if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
2097
+ else defs.appendChild(next);
2098
+ bump("filterSwaps");
2099
+ filterEl = next;
2100
+ }
1943
2101
  mapKey = key;
1944
2102
  }
1945
2103
  el.style.setProperty("backdrop-filter", `url(#${id})`);
@@ -1963,7 +2121,7 @@ function attachGlass(el, opts = {}) {
1963
2121
  const pole = withPresence(resolveBody(opticsNow, sample, light ? 1 : 0), opticsNow).tintLuma;
1964
2122
  el.style.setProperty(
1965
2123
  "--vireglass-rim",
1966
- (opts.accessibility ?? a11y).increaseContrast ? contrastRimCss(pole, contrastRimLuma(pole)) : rimGradientCss(opticsNow, [tr, tg, tb], opts.light ?? REST_LIGHT, appear)
2124
+ (opts.accessibility ?? a11y).increaseContrast ? contrastRimCss(pole, contrastRimLuma(pole), appear) : rimGradientCss(opticsNow, [tr, tg, tb], opts.light ?? REST_LIGHT, appear)
1967
2125
  );
1968
2126
  el.style.setProperty("--vireglass-rim-width", `${RIM_WIDTH_PX}px`);
1969
2127
  el.style.setProperty("--vireglass-radius", `${geometry.cornerRadius}px`);
@@ -1986,7 +2144,9 @@ function attachGlass(el, opts = {}) {
1986
2144
  const base = optics();
1987
2145
  const rise = raiseIntoGlass(sampleOut.press);
1988
2146
  const risen = optics({ ...opts.material, roughness: (opts.material?.roughness ?? VIREGLASS_MATERIAL.roughness) * rise.solid });
1989
- const map = buildDisplacementMap(risen, geometry, interacting ? 1 : devicePixelRatio(), {
2147
+ if (interim) keepInterim();
2148
+ bump("reducedBakes");
2149
+ const map = buildDisplacementMap(risen, geometry, 1, {
1990
2150
  ...morphState(),
1991
2151
  touch: {
1992
2152
  x: sampleOut.touchX,
@@ -1999,6 +2159,7 @@ function attachGlass(el, opts = {}) {
1999
2159
  wavePhase: sampleOut.wavePhase
2000
2160
  }
2001
2161
  });
2162
+ invalidate();
2002
2163
  if (filterEl) {
2003
2164
  const feImage = filterEl.querySelector("feImage");
2004
2165
  feImage?.setAttribute("href", map.url);
@@ -2021,7 +2182,7 @@ function attachGlass(el, opts = {}) {
2021
2182
  el.style.setProperty("--vireglass-glow-opacity", "0");
2022
2183
  return;
2023
2184
  }
2024
- const [gr, gg, gb] = ambientFrom(supplied ?? probeBackdrop(el));
2185
+ const [gr, gg, gb] = ambientFrom(supplied ?? (interim && lastSample ? lastSample : probe(el)));
2025
2186
  const cx = (x + geometry.width / 2) / geometry.width * 100;
2026
2187
  const cy = (y + geometry.height / 2) / geometry.height * 100;
2027
2188
  const reach = halfMinDp(geometry) * TOUCH_RADIUS_FRACTION;
@@ -2045,7 +2206,9 @@ function attachGlass(el, opts = {}) {
2045
2206
  lastFrameAt = 0;
2046
2207
  el.style.setProperty("--vireglass-glow-opacity", "0");
2047
2208
  opts.onGlow?.(null);
2209
+ settling = !interim;
2048
2210
  apply();
2211
+ if (interim) requestInterimMap();
2049
2212
  return;
2050
2213
  }
2051
2214
  frame = requestAnimationFrame(tick);
@@ -2093,6 +2256,9 @@ function attachGlass(el, opts = {}) {
2093
2256
  }
2094
2257
  function destroy() {
2095
2258
  destroyed = true;
2259
+ invalidate();
2260
+ if (idleTimer !== null) clearTimeout(idleTimer);
2261
+ idleTimer = null;
2096
2262
  stopWatchingA11y();
2097
2263
  if (attached.get(el) === handle) attached.delete(el);
2098
2264
  if (frame !== null) cancelAnimationFrame(frame);
@@ -2134,6 +2300,7 @@ function attachGlass(el, opts = {}) {
2134
2300
  if (next === appear) return;
2135
2301
  appear = next;
2136
2302
  apply();
2303
+ if (interim) requestInterimMap();
2137
2304
  },
2138
2305
  setAccent(next) {
2139
2306
  accent = next ?? void 0;
@@ -2156,9 +2323,23 @@ function attachGlass(el, opts = {}) {
2156
2323
  },
2157
2324
  setMorph(next) {
2158
2325
  morph = next && next.smoothing > 0 ? next : null;
2326
+ if (interim) {
2327
+ apply();
2328
+ requestInterimMap();
2329
+ return;
2330
+ }
2159
2331
  mapKey = "";
2160
2332
  apply();
2161
2333
  },
2334
+ setInterim(on) {
2335
+ if (destroyed || on === interim) return;
2336
+ if (on) {
2337
+ interim = true;
2338
+ keepInterim();
2339
+ return;
2340
+ }
2341
+ leaveInterim();
2342
+ },
2162
2343
  setSample(next) {
2163
2344
  supplied = next ?? void 0;
2164
2345
  apply();
@@ -2190,6 +2371,8 @@ exports.parseColor = parseColor;
2190
2371
  exports.probeBackdrop = probeBackdrop;
2191
2372
  exports.renderDisplacementPixels = renderDisplacementPixels;
2192
2373
  exports.renderSpectralPixels = renderSpectralPixels;
2374
+ exports.renderStats = renderStats;
2375
+ exports.resetRenderStats = resetRenderStats;
2193
2376
  exports.resetSupportCache = resetSupportCache;
2194
2377
  exports.resolveBody = resolveBody;
2195
2378
  exports.rimGradientCss = rimGradientCss;