vireglass 3.1.0 → 3.2.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.1.0",
3
+ "version": "3.2.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",
@@ -78,6 +78,14 @@ export type DisplacementState = {
78
78
  morphs?: readonly MorphShape[];
79
79
  };
80
80
 
81
+ /**
82
+ * The `feDisplacementMap` scale for these optics. The map's pixels are a fraction of it and do not
83
+ * depend on it, so a change of strength alone, such as appear, is this attribute and not a bake.
84
+ */
85
+ export function displacementScale(optics: VireGlassOptics, geometry: VireGlassGeometry): number {
86
+ return Math.min(optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN, MAX_SCALE);
87
+ }
88
+
81
89
  /**
82
90
  * The raw pixel buffer, with no canvas involved — the part actually worth unit-testing.
83
91
  * `buildDisplacementMap` below is a thin DOM wrapper around this.
@@ -97,8 +105,7 @@ export function renderDisplacementPixels(
97
105
  const density = mapDensity(geometry, bevel, dpr);
98
106
  const width = Math.max(1, Math.round(geometry.width * density));
99
107
  const height = Math.max(1, Math.round(geometry.height * density));
100
- const rawScale = optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN;
101
- const scale = Math.min(rawScale, MAX_SCALE);
108
+ const scale = displacementScale(optics, geometry);
102
109
  const smoothing = state.smoothing ?? 0;
103
110
  // One list, whichever way the caller expressed it.
104
111
  const shapes: (MorphShape | undefined)[] = [state.morph, state.morph2, ...(state.morphs ?? [])];
package/src/dom/index.ts CHANGED
@@ -12,7 +12,7 @@
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, type DisplacementMap } from './displacement';
15
+ import { buildDisplacementMap, displacementScale, 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';
@@ -132,6 +132,11 @@ function getFilterHost(host: Document | ShadowRoot): SVGSVGElement {
132
132
  return svg;
133
133
  }
134
134
 
135
+ /** Every apply writes the presence, and most applies do not change it. */
136
+ function writeAttr(node: Element, name: string, value: string): void {
137
+ if (node.getAttribute(name) !== value) node.setAttribute(name, value);
138
+ }
139
+
135
140
  /**
136
141
  * Finding #1 (do not re-litigate): `objectBoundingBox` with an explicit unit box. `userSpaceOnUse`
137
142
  * measures from something other than the element's own box and the displacement smears across
@@ -254,6 +259,7 @@ function withSpectral(filter: SVGFilterElement, spectral: SpectralMap | null): S
254
259
  product.setAttribute('in2', 'hue');
255
260
  product.setAttribute('operator', 'arithmetic');
256
261
  product.setAttribute('k1', String(spectral.headroom));
262
+ product.setAttribute('data-vireglass-headroom', String(spectral.headroom));
257
263
  product.setAttribute('k2', '0');
258
264
  product.setAttribute('k3', '0');
259
265
  product.setAttribute('k4', '0');
@@ -299,7 +305,7 @@ function decoded(url: string): Promise<void> {
299
305
  return typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve();
300
306
  }
301
307
 
302
- type InterimMap = { map: DisplacementMap; ready: boolean };
308
+ type InterimMap = { map: DisplacementMap; ready: boolean; geometry: VireGlassGeometry };
303
309
 
304
310
  /** Interim with no call for this long ends itself: a morph that never reported landing must not
305
311
  * leave the glass on a stretched map for good. */
@@ -436,7 +442,8 @@ export type GlassHandle = {
436
442
  * probing the backdrop, stops rebuilding its maps and keeps its filter: the existing map is
437
443
  * stretched over the live box. A change of `setMorph` shapes still updates the map, at reduced
438
444
  * 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.
445
+ * rebuild and one probe. With no call for 2 s it turns itself off; calling it on again restarts
446
+ * the 2 s, which is how a host keeps a gesture held still interim. Turning it off twice does nothing.
440
447
  */
441
448
  setInterim(on: boolean): void;
442
449
  /**
@@ -463,6 +470,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
463
470
  let filterEl: SVGFilterElement | null = null;
464
471
  let destroyed = false;
465
472
  let mapKey = '';
473
+ // The box the shown map was baked for: its strength belongs to that map, also while interim stretches it.
474
+ let shownGeometry: VireGlassGeometry | null = null;
466
475
  let interim = false;
467
476
  let settling = false;
468
477
  let lastSample: BackdropSample | null = null;
@@ -550,14 +559,34 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
550
559
  apply();
551
560
  }
552
561
 
553
- function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
562
+ function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null, geometry: VireGlassGeometry): void {
554
563
  const images = f.querySelectorAll('feImage');
555
564
  images[0]?.setAttribute('href', map.url);
556
565
  if (hue) images[1]?.setAttribute('href', hue.url);
566
+ shownGeometry = geometry;
567
+ present(f);
568
+ }
569
+
570
+ /**
571
+ * The maps are baked at full presence and appear is these attributes. The spectral hue is
572
+ * `1 + appear * (hue - 1)`, so mixing the full one in is the same picture.
573
+ */
574
+ function present(f: SVGFilterElement): void {
575
+ if (!shownGeometry) return;
576
+ const o = optics();
577
+ const scale = displacementScale(o, shownGeometry);
557
578
  // EVERY pass, with its own share of the scale: see paintInteraction.
558
579
  for (const pass of f.querySelectorAll('feDisplacementMap')) {
559
580
  const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
560
- pass.setAttribute('scale', String(map.scale * (Number.isFinite(ratio) ? ratio : 1)));
581
+ writeAttr(pass, 'scale', String(scale * (Number.isFinite(ratio) ? ratio : 1)));
582
+ }
583
+ const frost = f.querySelector('feGaussianBlur');
584
+ if (frost) writeAttr(frost, 'stdDeviation', String(Math.max(o.blur, 0)));
585
+ const hue = f.querySelector('feComposite[in2="hue"]');
586
+ if (hue) {
587
+ const headroom = Number(hue.getAttribute('data-vireglass-headroom') ?? 1);
588
+ writeAttr(hue, 'k1', String(headroom * appear));
589
+ writeAttr(hue, 'k2', String(1 - appear));
561
590
  }
562
591
  }
563
592
 
@@ -565,18 +594,18 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
565
594
  if (!svgSupported || !filterEl || destroyed) return;
566
595
  const geometry = opts.geometry ?? measureGeometry(el);
567
596
  if (isEmpty(geometry)) return;
568
- const opticsNow = optics();
597
+ const full = settledOptics();
569
598
  const key = interimKey(
570
599
  geometry.width,
571
600
  geometry.height,
572
601
  geometry.cornerRadius,
573
602
  JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
574
- `a${appear.toFixed(3)}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}`,
603
+ `${full.refraction}:${full.refractionScale}:${full.bevelDp}`,
575
604
  );
576
605
  let rec = interimMaps.get(key);
577
606
  if (!rec) {
578
607
  bump('reducedBakes');
579
- rec = { map: buildDisplacementMap(opticsNow, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false };
608
+ rec = { map: buildDisplacementMap(full, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false, geometry };
580
609
  interimMaps.set(key, rec);
581
610
  }
582
611
  const mine = (seq += 1);
@@ -585,7 +614,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
585
614
  const show = () => {
586
615
  if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
587
616
  shownSeq = mine;
588
- retarget(forFilter, record.map, null);
617
+ retarget(forFilter, record.map, null, record.geometry);
589
618
  };
590
619
  if (record.ready) {
591
620
  show();
@@ -612,7 +641,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
612
641
  // The map depends only on geometry, optics and density — never on the backdrop. Rebuilding
613
642
  // it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
614
643
  // not change; the refraction itself updates in the compositor with no JS at all.
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}`;
644
+ const full = settledOptics();
645
+ const key = `m${morphKey()}|${geometry.width}x${geometry.height}r${geometry.cornerRadius}@${devicePixelRatio()}:${full.refraction}:${full.refractionScale}:${full.bevelDp}:${full.blur}`;
616
646
  if (interim && filterEl) {
617
647
  // The filter keeps its map; feImage stretches it over the live box.
618
648
  } else if (isEmpty(geometry)) {
@@ -621,15 +651,15 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
621
651
  // Keyed by exactly what the maps are functions of, which is exactly the cache key above.
622
652
  const map = cached(`d|${key}`, () => {
623
653
  bump('fullBakes');
624
- return buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState());
654
+ return buildDisplacementMap(full, geometry, devicePixelRatio(), morphState());
625
655
  });
626
- const hue = hasSpectralEdge(opticsNow)
656
+ const hue = hasSpectralEdge(full)
627
657
  ? cached(`h|${key}`, () => {
628
658
  bump('fullBakes');
629
- return buildSpectralMap(opticsNow, geometry, devicePixelRatio());
659
+ return buildSpectralMap(full, geometry, devicePixelRatio());
630
660
  })
631
661
  : null;
632
- const signature = filterSignature(opticsNow.blur, opticsNow.ior, opticsNow.iorSpread, !!hue);
662
+ const signature = filterSignature(full.blur, full.ior, full.iorSpread, !!hue);
633
663
  if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
634
664
  settling = false;
635
665
  const mine = (seq += 1);
@@ -638,21 +668,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
638
668
  void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
639
669
  if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
640
670
  shownSeq = mine;
641
- retarget(forFilter, map, hue);
671
+ retarget(forFilter, map, hue, geometry);
642
672
  });
643
673
  } else {
644
674
  settling = false;
645
675
  invalidate();
646
676
  const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
647
- const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
677
+ const next = buildFilterElement(id, map.url, full, map.scale, hue);
648
678
  next.setAttribute('data-vireglass-signature', signature);
649
679
  if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
650
680
  else defs.appendChild(next);
651
681
  bump('filterSwaps');
652
682
  filterEl = next;
683
+ shownGeometry = geometry;
653
684
  }
654
685
  mapKey = key;
655
686
  }
687
+ if (filterEl) present(filterEl);
656
688
  el.style.setProperty('backdrop-filter', `url(#${id})`);
657
689
  el.style.setProperty('-webkit-backdrop-filter', `url(#${id})`);
658
690
  } else {
@@ -921,7 +953,6 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
921
953
  if (next === appear) return;
922
954
  appear = next;
923
955
  apply();
924
- if (interim) requestInterimMap();
925
956
  },
926
957
  setAccent(next) {
927
958
  accent = next ?? undefined;
@@ -953,13 +984,14 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
953
984
  apply();
954
985
  },
955
986
  setInterim(on) {
956
- if (destroyed || on === interim) return;
987
+ if (destroyed) return;
957
988
  if (on) {
989
+ // A gesture held still sends no geometry, so a repeat is the host saying it is still moving.
958
990
  interim = true;
959
991
  keepInterim();
960
992
  return;
961
993
  }
962
- leaveInterim();
994
+ if (interim) leaveInterim();
963
995
  },
964
996
  setSample(next) {
965
997
  supplied = next ?? undefined;