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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vireglass",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "A physically-derived liquid glass material for interfaces. One material model, three render targets: WebGL2, live DOM, and Android AGSL.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Danil Kotlaev",
@@ -140,6 +140,7 @@
140
140
  "expo": "~57.0.18",
141
141
  "expo-blur": "~57.0.2",
142
142
  "expo-sensors": "~57.0.2",
143
+ "jsdom": "^30.1.3",
143
144
  "playwright": "^1.62.1",
144
145
  "react": "19.2.3",
145
146
  "react-native": "0.86.2",
package/src/dom/index.ts CHANGED
@@ -12,12 +12,14 @@
12
12
  // See `docs/superpowers/specs/2026-09-19-vireglass-over-live-dom.md` for the technique and the
13
13
  // three findings that make it work (objectBoundingBox, prefiltering the source, the 8-bit step).
14
14
  import { ambientFrom, INK_DARK, INK_LIGHT, shouldInkBeLight, type BackdropSample } from '../adaptation';
15
- import { buildDisplacementMap } from './displacement';
15
+ import { buildDisplacementMap, type DisplacementMap } from './displacement';
16
16
  import { buildSpectralMap, hasSpectralEdge, type SpectralMap } from './spectral-map';
17
17
  import { cached } from './map-cache';
18
18
  import { roundedRectGeometry, type VireGlassGeometry } from '../geometry';
19
19
  import { resolveOptics, VIREGLASS_MATERIAL, type VireGlassMaterial, type VireGlassOptics } from '../material';
20
20
  import { probeBackdrop } from './probe';
21
+ import { bump } from './stats';
22
+ import { createLru, filterSignature, interimKey, quantise } from './interim';
21
23
  import { supportsSvgBackdropFilter } from './support';
22
24
  import { boxShadowCss } from './shadow';
23
25
  import { bodyLayer, resolveBody, withPresence } from './body';
@@ -49,6 +51,7 @@ export * from './scroll-edge';
49
51
  export * from './group';
50
52
  export * from './spectral-map';
51
53
  export * from './map-cache';
54
+ export { renderStats, resetRenderStats, type RenderStats } from './stats';
52
55
 
53
56
  const SVG_NS = 'http://www.w3.org/2000/svg';
54
57
  const FILTER_HOST_ATTR = 'data-vireglass-filters';
@@ -282,6 +285,28 @@ function fallbackFilterCss(optics: VireGlassOptics): string {
282
285
  return `blur(${blurPx}px) saturate(${saturate})`;
283
286
  }
284
287
 
288
+ function probe(el: HTMLElement): BackdropSample {
289
+ bump('probes');
290
+ return probeBackdrop(el);
291
+ }
292
+
293
+ /** A map that is still decoding draws the backdrop displaced by the wrong amount, so a new one is
294
+ * decoded before it is shown. */
295
+ function decoded(url: string): Promise<void> {
296
+ if (typeof Image === 'undefined') return Promise.resolve();
297
+ const img = new Image();
298
+ img.src = url;
299
+ return typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve();
300
+ }
301
+
302
+ type InterimMap = { map: DisplacementMap; ready: boolean };
303
+
304
+ /** Interim with no call for this long ends itself: a morph that never reported landing must not
305
+ * leave the glass on a stretched map for good. */
306
+ const INTERIM_IDLE_MS = 2000;
307
+
308
+ const isEmpty = (g: VireGlassGeometry) => quantise(g.width) < 1 || quantise(g.height) < 1;
309
+
285
310
  const to255 = (v: number) => Math.round(Math.min(Math.max(v, 0), 1) * 255);
286
311
 
287
312
  function rgbCss(r: number, g: number, b: number): string {
@@ -406,6 +431,14 @@ export type GlassHandle = {
406
431
  /** A glow that started on a neighbour, in page coordinates (§5). Null clears it. */
407
432
  setNeighbourGlow(glow: { pageX: number; pageY: number; strength: number } | null): void;
408
433
  setMorph(morph: GlassMorph | null): void;
434
+ /**
435
+ * While on, the element is changing shape every frame (a morph, a drag, a growth). It stops
436
+ * probing the backdrop, stops rebuilding its maps and keeps its filter: the existing map is
437
+ * stretched over the live box. A change of `setMorph` shapes still updates the map, at reduced
438
+ * resolution and only once the new image has decoded. Turning it off does one full-resolution
439
+ * rebuild and one probe. Repeated calls with the same value do nothing.
440
+ */
441
+ setInterim(on: boolean): void;
409
442
  /**
410
443
  * Replaces the backdrop the glass adapts to, without re-attaching, and repaints. The same
411
444
  * object as `sample` in the options; `null` goes back to probing declared backgrounds.
@@ -430,6 +463,13 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
430
463
  let filterEl: SVGFilterElement | null = null;
431
464
  let destroyed = false;
432
465
  let mapKey = '';
466
+ let interim = false;
467
+ let settling = false;
468
+ let lastSample: BackdropSample | null = null;
469
+ let seq = 0;
470
+ let shownSeq = 0;
471
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
472
+ const interimMaps = createLru<InterimMap>(8);
433
473
  let morph: GlassMorph | null = null;
434
474
  const onlyWhenTouched = opts.variant === 'interactive';
435
475
  let appear = onlyWhenTouched ? 0 : (opts.appear ?? 1);
@@ -443,6 +483,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
443
483
  mapKey = '';
444
484
  deform.setElastic(elasticAllowed(opts.accessibility ?? a11y));
445
485
  apply();
486
+ if (interim) requestInterimMap();
446
487
  });
447
488
 
448
489
  warnIfNested(el);
@@ -487,11 +528,82 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
487
528
  return `${morph.smoothing}/${part(morph.shape)}/${part(morph.shape2)}`;
488
529
  }
489
530
 
531
+ /** Drops every pending decode: whatever installs a map synchronously supersedes them. */
532
+ function invalidate(): void {
533
+ seq += 1;
534
+ shownSeq = seq;
535
+ }
536
+
537
+ function keepInterim(): void {
538
+ if (idleTimer !== null) clearTimeout(idleTimer);
539
+ idleTimer = setTimeout(leaveInterim, INTERIM_IDLE_MS);
540
+ }
541
+
542
+ function leaveInterim(): void {
543
+ if (idleTimer !== null) clearTimeout(idleTimer);
544
+ idleTimer = null;
545
+ if (destroyed || !interim) return;
546
+ interim = false;
547
+ invalidate();
548
+ mapKey = '';
549
+ settling = true;
550
+ apply();
551
+ }
552
+
553
+ function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
554
+ const images = f.querySelectorAll('feImage');
555
+ images[0]?.setAttribute('href', map.url);
556
+ if (hue) images[1]?.setAttribute('href', hue.url);
557
+ // EVERY pass, with its own share of the scale: see paintInteraction.
558
+ for (const pass of f.querySelectorAll('feDisplacementMap')) {
559
+ const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
560
+ pass.setAttribute('scale', String(map.scale * (Number.isFinite(ratio) ? ratio : 1)));
561
+ }
562
+ }
563
+
564
+ function requestInterimMap(): void {
565
+ if (!svgSupported || !filterEl || destroyed) return;
566
+ const geometry = opts.geometry ?? measureGeometry(el);
567
+ if (isEmpty(geometry)) return;
568
+ const opticsNow = optics();
569
+ const key = interimKey(
570
+ geometry.width,
571
+ geometry.height,
572
+ geometry.cornerRadius,
573
+ JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
574
+ `a${appear.toFixed(3)}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}`,
575
+ );
576
+ let rec = interimMaps.get(key);
577
+ if (!rec) {
578
+ bump('reducedBakes');
579
+ rec = { map: buildDisplacementMap(opticsNow, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false };
580
+ interimMaps.set(key, rec);
581
+ }
582
+ const mine = (seq += 1);
583
+ const record = rec;
584
+ const forFilter = filterEl;
585
+ const show = () => {
586
+ if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
587
+ shownSeq = mine;
588
+ retarget(forFilter, record.map, null);
589
+ };
590
+ if (record.ready) {
591
+ show();
592
+ return;
593
+ }
594
+ void decoded(record.map.url).then(() => {
595
+ record.ready = true;
596
+ show();
597
+ });
598
+ }
599
+
490
600
  function apply(): void {
491
601
  if (destroyed) return;
602
+ if (interim) keepInterim();
492
603
  const geometry = opts.geometry ?? measureGeometry(el);
493
604
  const opticsNow = optics();
494
- const sample = supplied ?? probeBackdrop(el);
605
+ const sample = supplied ?? (interim && lastSample ? lastSample : probe(el));
606
+ lastSample = sample;
495
607
  const light = shouldInkBeLight(sample, wasLight);
496
608
  wasLight = light;
497
609
  const body = opts.body === false ? null : bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear);
@@ -501,17 +613,44 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
501
613
  // it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
502
614
  // not change; the refraction itself updates in the compositor with no JS at all.
503
615
  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}`;
504
- if (key !== mapKey || !filterEl) {
616
+ if (interim && filterEl) {
617
+ // The filter keeps its map; feImage stretches it over the live box.
618
+ } else if (isEmpty(geometry)) {
619
+ // Nothing to refract in a box of no size; the next resize bakes it.
620
+ } else if (key !== mapKey || !filterEl) {
505
621
  // Keyed by exactly what the maps are functions of, which is exactly the cache key above.
506
- const map = cached(`d|${key}`, () => buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState()));
507
- const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
622
+ const map = cached(`d|${key}`, () => {
623
+ bump('fullBakes');
624
+ return buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState());
625
+ });
508
626
  const hue = hasSpectralEdge(opticsNow)
509
- ? cached(`h|${key}`, () => buildSpectralMap(opticsNow, geometry, devicePixelRatio()))
627
+ ? cached(`h|${key}`, () => {
628
+ bump('fullBakes');
629
+ return buildSpectralMap(opticsNow, geometry, devicePixelRatio());
630
+ })
510
631
  : null;
511
- const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
512
- if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
513
- else defs.appendChild(next);
514
- filterEl = next;
632
+ const signature = filterSignature(opticsNow.blur, opticsNow.ior, opticsNow.iorSpread, !!hue);
633
+ if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
634
+ settling = false;
635
+ const mine = (seq += 1);
636
+ const forFilter = filterEl;
637
+ const forKey = key;
638
+ void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
639
+ if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
640
+ shownSeq = mine;
641
+ retarget(forFilter, map, hue);
642
+ });
643
+ } else {
644
+ settling = false;
645
+ invalidate();
646
+ const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
647
+ const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
648
+ next.setAttribute('data-vireglass-signature', signature);
649
+ if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
650
+ else defs.appendChild(next);
651
+ bump('filterSwaps');
652
+ filterEl = next;
653
+ }
515
654
  mapKey = key;
516
655
  }
517
656
  el.style.setProperty('backdrop-filter', `url(#${id})`);
@@ -548,7 +687,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
548
687
  el.style.setProperty(
549
688
  '--vireglass-rim',
550
689
  (opts.accessibility ?? a11y).increaseContrast
551
- ? contrastRimCss(pole, contrastRimLuma(pole))
690
+ ? contrastRimCss(pole, contrastRimLuma(pole), appear)
552
691
  : rimGradientCss(opticsNow, [tr, tg, tb], opts.light ?? REST_LIGHT, appear),
553
692
  );
554
693
  el.style.setProperty('--vireglass-rim-width', `${RIM_WIDTH_PX}px`);
@@ -587,10 +726,12 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
587
726
  // applying because a finger is down.
588
727
  const risen = optics({ ...opts.material, roughness: (opts.material?.roughness ?? VIREGLASS_MATERIAL.roughness) * rise.solid });
589
728
 
729
+ if (interim) keepInterim();
590
730
  // A reduced-resolution map while the finger is down: `feImage` stretches it over the element
591
731
  // regardless, the displacement is smooth, and a full device-pixel rebuild plus a PNG encode
592
732
  // every frame does not hold a frame budget.
593
- const map = buildDisplacementMap(risen, geometry, interacting ? 1 : devicePixelRatio(), {
733
+ bump('reducedBakes');
734
+ const map = buildDisplacementMap(risen, geometry, 1, {
594
735
  ...morphState(),
595
736
  touch: {
596
737
  x: sampleOut.touchX,
@@ -603,6 +744,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
603
744
  wavePhase: sampleOut.wavePhase,
604
745
  },
605
746
  });
747
+ invalidate();
606
748
  if (filterEl) {
607
749
  const feImage = filterEl.querySelector('feImage');
608
750
  feImage?.setAttribute('href', map.url);
@@ -641,7 +783,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
641
783
  el.style.setProperty('--vireglass-glow-opacity', '0');
642
784
  return;
643
785
  }
644
- const [gr, gg, gb] = ambientFrom(supplied ?? probeBackdrop(el));
786
+ const [gr, gg, gb] = ambientFrom(supplied ?? (interim && lastSample ? lastSample : probe(el)));
645
787
  const cx = ((x + geometry.width / 2) / geometry.width) * 100;
646
788
  const cy = ((y + geometry.height / 2) / geometry.height) * 100;
647
789
  const reach = halfMinDp(geometry) * TOUCH_RADIUS_FRACTION;
@@ -670,7 +812,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
670
812
  // Settled: put the full-resolution map back and drop the glow.
671
813
  el.style.setProperty('--vireglass-glow-opacity', '0');
672
814
  opts.onGlow?.(null);
815
+ settling = !interim;
673
816
  apply();
817
+ if (interim) requestInterimMap();
674
818
  return;
675
819
  }
676
820
  frame = requestAnimationFrame(tick);
@@ -732,6 +876,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
732
876
 
733
877
  function destroy(): void {
734
878
  destroyed = true;
879
+ invalidate();
880
+ if (idleTimer !== null) clearTimeout(idleTimer);
881
+ idleTimer = null;
735
882
  stopWatchingA11y();
736
883
  if (attached.get(el) === handle) attached.delete(el);
737
884
  if (frame !== null) cancelAnimationFrame(frame);
@@ -774,6 +921,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
774
921
  if (next === appear) return;
775
922
  appear = next;
776
923
  apply();
924
+ if (interim) requestInterimMap();
777
925
  },
778
926
  setAccent(next) {
779
927
  accent = next ?? undefined;
@@ -796,9 +944,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
796
944
  },
797
945
  setMorph(next) {
798
946
  morph = next && next.smoothing > 0 ? next : null;
947
+ if (interim) {
948
+ apply();
949
+ requestInterimMap();
950
+ return;
951
+ }
799
952
  mapKey = '';
800
953
  apply();
801
954
  },
955
+ setInterim(on) {
956
+ if (destroyed || on === interim) return;
957
+ if (on) {
958
+ interim = true;
959
+ keepInterim();
960
+ return;
961
+ }
962
+ leaveInterim();
963
+ },
802
964
  setSample(next) {
803
965
  supplied = next ?? undefined;
804
966
  apply();
@@ -0,0 +1,47 @@
1
+ /** Geometry snapped to a grid, so a morph that moves by a fraction of a pixel reuses a map. */
2
+ export function quantise(value: number, step = 2): number {
3
+ return Math.round(value / step) * step;
4
+ }
5
+
6
+ export function interimKey(
7
+ width: number,
8
+ height: number,
9
+ cornerRadius: number,
10
+ morph: string,
11
+ extra: string,
12
+ ): string {
13
+ return `i|${quantise(width)}x${quantise(height)}r${quantise(cornerRadius)}|${morph}|${extra}`;
14
+ }
15
+
16
+ /**
17
+ * What a filter's structure depends on. A filter with the same signature can take a new map in
18
+ * place; a different one has to be rebuilt.
19
+ */
20
+ export function filterSignature(blur: number, ior: number, iorSpread: number, hasHue: boolean): string {
21
+ return `${blur}|${ior}|${iorSpread > 1e-4 ? iorSpread : 0}|${hasHue ? 'h' : '-'}`;
22
+ }
23
+
24
+ /** A small least-recently-used store, so a long morph cannot evict the resting maps. */
25
+ export function createLru<T>(limit: number) {
26
+ const store = new Map<string, T>();
27
+ return {
28
+ get(key: string): T | undefined {
29
+ const hit = store.get(key);
30
+ if (hit === undefined) return undefined;
31
+ store.delete(key);
32
+ store.set(key, hit);
33
+ return hit;
34
+ },
35
+ set(key: string, value: T): void {
36
+ store.delete(key);
37
+ store.set(key, value);
38
+ if (store.size > limit) {
39
+ const oldest = store.keys().next();
40
+ if (!oldest.done) store.delete(oldest.value);
41
+ }
42
+ },
43
+ get size(): number {
44
+ return store.size;
45
+ },
46
+ };
47
+ }
package/src/dom/rim.ts CHANGED
@@ -129,10 +129,12 @@ export const RIM_WIDTH_PX = RIM.widthPx;
129
129
  * everything around it. It has to hold at every angle, including the one facing away from the key
130
130
  * light where the ordinary rim falls back to a dim dark line.
131
131
  */
132
- export function contrastRimCss(bodyLuma: number, rimLuma: number): string {
132
+ export function contrastRimCss(bodyLuma: number, rimLuma: number, appear = 1): string {
133
133
  const v = to255(rimLuma);
134
- // Opaque on purpose. A border that lets the backdrop through is the thing this setting exists
135
- // to replace, and the body behind it is already at its own pole.
134
+ // Opaque on purpose once the element is there. A border that lets the backdrop through is the
135
+ // thing this setting exists to replace, and the body behind it is already at its own pole.
136
136
  void bodyLuma;
137
- return `conic-gradient(rgb(${v},${v},${v}) 0deg, rgb(${v},${v},${v}) 360deg)`;
137
+ const present = clamp01(appear);
138
+ const colour = present >= 1 ? `rgb(${v},${v},${v})` : `rgba(${v},${v},${v},${present.toFixed(3)})`;
139
+ return `conic-gradient(${colour} 0deg, ${colour} 360deg)`;
138
140
  }
@@ -0,0 +1,28 @@
1
+ export type RenderStats = {
2
+ /** `elementsFromPoint` backdrop probes. */
3
+ probes: number;
4
+ /** Displacement maps baked at full device resolution (cache hits are not bakes). */
5
+ fullBakes: number;
6
+ /** Displacement maps baked at reduced resolution: interim morphs and the finger path. */
7
+ reducedBakes: number;
8
+ /** Filter elements put into the shared `<defs>`, replacing or adding. */
9
+ filterSwaps: number;
10
+ };
11
+
12
+ const counts: RenderStats = { probes: 0, fullBakes: 0, reducedBakes: 0, filterSwaps: 0 };
13
+
14
+ /** The work the renderer has done since the last reset; the hook for a consumer's performance gate. */
15
+ export function renderStats(): RenderStats {
16
+ return { ...counts };
17
+ }
18
+
19
+ export function resetRenderStats(): void {
20
+ counts.probes = 0;
21
+ counts.fullBakes = 0;
22
+ counts.reducedBakes = 0;
23
+ counts.filterSwaps = 0;
24
+ }
25
+
26
+ export function bump(key: keyof RenderStats): void {
27
+ counts[key] += 1;
28
+ }