vireglass 3.0.1 → 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;
@@ -1687,7 +1736,7 @@ function createGlassGroup(members, opts = {}) {
1687
1736
  }
1688
1737
  function update() {
1689
1738
  if (destroyed) return;
1690
- const probe = opts.probe ?? probeBackdrop;
1739
+ const probe2 = opts.probe ?? probeBackdrop;
1691
1740
  members.forEach(({ el, legibility }, index) => {
1692
1741
  const rect = el.getBoundingClientRect();
1693
1742
  if (rect.width <= 0 || rect.height <= 0) {
@@ -1698,7 +1747,7 @@ function createGlassGroup(members, opts = {}) {
1698
1747
  String(index),
1699
1748
  rect.left + rect.width / 2,
1700
1749
  rect.top + rect.height / 2,
1701
- probe(el),
1750
+ probe2(el),
1702
1751
  legibility ?? 0
1703
1752
  );
1704
1753
  });
@@ -1862,6 +1911,18 @@ function fallbackFilterCss(optics) {
1862
1911
  const saturate = 1 + optics.colorPickup;
1863
1912
  return `blur(${blurPx}px) saturate(${saturate})`;
1864
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;
1865
1926
  var to2553 = (v) => Math.round(Math.min(Math.max(v, 0), 1) * 255);
1866
1927
  function rgbCss(r, g, b) {
1867
1928
  return `rgb(${to2553(r)} ${to2553(g)} ${to2553(b)})`;
@@ -1888,6 +1949,13 @@ function attachGlass(el, opts = {}) {
1888
1949
  let filterEl = null;
1889
1950
  let destroyed = false;
1890
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);
1891
1959
  let morph = null;
1892
1960
  const onlyWhenTouched = opts.variant === "interactive";
1893
1961
  let appear = onlyWhenTouched ? 0 : opts.appear ?? 1;
@@ -1899,6 +1967,7 @@ function attachGlass(el, opts = {}) {
1899
1967
  mapKey = "";
1900
1968
  deform.setElastic(elasticAllowed(opts.accessibility ?? a11y));
1901
1969
  apply();
1970
+ if (interim) requestInterimMap();
1902
1971
  });
1903
1972
  warnIfNested(el);
1904
1973
  const host = hostFor(el);
@@ -1924,24 +1993,111 @@ function attachGlass(el, opts = {}) {
1924
1993
  const part = (s) => s ? `${s.offsetX},${s.offsetY},${s.width},${s.height},${s.cornerRadius}` : "-";
1925
1994
  return `${morph.smoothing}/${part(morph.shape)}/${part(morph.shape2)}`;
1926
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
+ }
1927
2058
  function apply() {
1928
2059
  if (destroyed) return;
2060
+ if (interim) keepInterim();
1929
2061
  const geometry = opts.geometry ?? measureGeometry(el);
1930
2062
  const opticsNow = optics();
1931
- const sample = supplied ?? probeBackdrop(el);
2063
+ const sample = supplied ?? (interim && lastSample ? lastSample : probe(el));
2064
+ lastSample = sample;
1932
2065
  const light = shouldInkBeLight(sample, wasLight);
1933
2066
  wasLight = light;
1934
2067
  const body = opts.body === false ? null : bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear);
1935
2068
  if (svgSupported) {
1936
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}`;
1937
- if (key !== mapKey || !filterEl) {
1938
- const map = cached(`d|${key}`, () => buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState()));
1939
- const defs = getFilterHost(host).querySelector("defs");
1940
- const hue = hasSpectralEdge(opticsNow) ? cached(`h|${key}`, () => buildSpectralMap(opticsNow, geometry, devicePixelRatio())) : null;
1941
- const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
1942
- if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
1943
- else defs.appendChild(next);
1944
- 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
+ }
1945
2101
  mapKey = key;
1946
2102
  }
1947
2103
  el.style.setProperty("backdrop-filter", `url(#${id})`);
@@ -1988,7 +2144,9 @@ function attachGlass(el, opts = {}) {
1988
2144
  const base = optics();
1989
2145
  const rise = raiseIntoGlass(sampleOut.press);
1990
2146
  const risen = optics({ ...opts.material, roughness: (opts.material?.roughness ?? VIREGLASS_MATERIAL.roughness) * rise.solid });
1991
- const map = buildDisplacementMap(risen, geometry, interacting ? 1 : devicePixelRatio(), {
2147
+ if (interim) keepInterim();
2148
+ bump("reducedBakes");
2149
+ const map = buildDisplacementMap(risen, geometry, 1, {
1992
2150
  ...morphState(),
1993
2151
  touch: {
1994
2152
  x: sampleOut.touchX,
@@ -2001,6 +2159,7 @@ function attachGlass(el, opts = {}) {
2001
2159
  wavePhase: sampleOut.wavePhase
2002
2160
  }
2003
2161
  });
2162
+ invalidate();
2004
2163
  if (filterEl) {
2005
2164
  const feImage = filterEl.querySelector("feImage");
2006
2165
  feImage?.setAttribute("href", map.url);
@@ -2023,7 +2182,7 @@ function attachGlass(el, opts = {}) {
2023
2182
  el.style.setProperty("--vireglass-glow-opacity", "0");
2024
2183
  return;
2025
2184
  }
2026
- const [gr, gg, gb] = ambientFrom(supplied ?? probeBackdrop(el));
2185
+ const [gr, gg, gb] = ambientFrom(supplied ?? (interim && lastSample ? lastSample : probe(el)));
2027
2186
  const cx = (x + geometry.width / 2) / geometry.width * 100;
2028
2187
  const cy = (y + geometry.height / 2) / geometry.height * 100;
2029
2188
  const reach = halfMinDp(geometry) * TOUCH_RADIUS_FRACTION;
@@ -2047,7 +2206,9 @@ function attachGlass(el, opts = {}) {
2047
2206
  lastFrameAt = 0;
2048
2207
  el.style.setProperty("--vireglass-glow-opacity", "0");
2049
2208
  opts.onGlow?.(null);
2209
+ settling = !interim;
2050
2210
  apply();
2211
+ if (interim) requestInterimMap();
2051
2212
  return;
2052
2213
  }
2053
2214
  frame = requestAnimationFrame(tick);
@@ -2095,6 +2256,9 @@ function attachGlass(el, opts = {}) {
2095
2256
  }
2096
2257
  function destroy() {
2097
2258
  destroyed = true;
2259
+ invalidate();
2260
+ if (idleTimer !== null) clearTimeout(idleTimer);
2261
+ idleTimer = null;
2098
2262
  stopWatchingA11y();
2099
2263
  if (attached.get(el) === handle) attached.delete(el);
2100
2264
  if (frame !== null) cancelAnimationFrame(frame);
@@ -2136,6 +2300,7 @@ function attachGlass(el, opts = {}) {
2136
2300
  if (next === appear) return;
2137
2301
  appear = next;
2138
2302
  apply();
2303
+ if (interim) requestInterimMap();
2139
2304
  },
2140
2305
  setAccent(next) {
2141
2306
  accent = next ?? void 0;
@@ -2158,9 +2323,23 @@ function attachGlass(el, opts = {}) {
2158
2323
  },
2159
2324
  setMorph(next) {
2160
2325
  morph = next && next.smoothing > 0 ? next : null;
2326
+ if (interim) {
2327
+ apply();
2328
+ requestInterimMap();
2329
+ return;
2330
+ }
2161
2331
  mapKey = "";
2162
2332
  apply();
2163
2333
  },
2334
+ setInterim(on) {
2335
+ if (destroyed || on === interim) return;
2336
+ if (on) {
2337
+ interim = true;
2338
+ keepInterim();
2339
+ return;
2340
+ }
2341
+ leaveInterim();
2342
+ },
2164
2343
  setSample(next) {
2165
2344
  supplied = next ?? void 0;
2166
2345
  apply();
@@ -2192,6 +2371,8 @@ exports.parseColor = parseColor;
2192
2371
  exports.probeBackdrop = probeBackdrop;
2193
2372
  exports.renderDisplacementPixels = renderDisplacementPixels;
2194
2373
  exports.renderSpectralPixels = renderSpectralPixels;
2374
+ exports.renderStats = renderStats;
2375
+ exports.resetRenderStats = resetRenderStats;
2195
2376
  exports.resetSupportCache = resetSupportCache;
2196
2377
  exports.resolveBody = resolveBody;
2197
2378
  exports.rimGradientCss = rimGradientCss;