vireglass 3.1.1 → 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.1",
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. */
@@ -464,6 +470,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
464
470
  let filterEl: SVGFilterElement | null = null;
465
471
  let destroyed = false;
466
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;
467
475
  let interim = false;
468
476
  let settling = false;
469
477
  let lastSample: BackdropSample | null = null;
@@ -551,14 +559,34 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
551
559
  apply();
552
560
  }
553
561
 
554
- function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
562
+ function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null, geometry: VireGlassGeometry): void {
555
563
  const images = f.querySelectorAll('feImage');
556
564
  images[0]?.setAttribute('href', map.url);
557
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);
558
578
  // EVERY pass, with its own share of the scale: see paintInteraction.
559
579
  for (const pass of f.querySelectorAll('feDisplacementMap')) {
560
580
  const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
561
- 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));
562
590
  }
563
591
  }
564
592
 
@@ -566,18 +594,18 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
566
594
  if (!svgSupported || !filterEl || destroyed) return;
567
595
  const geometry = opts.geometry ?? measureGeometry(el);
568
596
  if (isEmpty(geometry)) return;
569
- const opticsNow = optics();
597
+ const full = settledOptics();
570
598
  const key = interimKey(
571
599
  geometry.width,
572
600
  geometry.height,
573
601
  geometry.cornerRadius,
574
602
  JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
575
- `a${appear.toFixed(3)}:${opticsNow.refraction}:${opticsNow.refractionScale}:${opticsNow.bevelDp}`,
603
+ `${full.refraction}:${full.refractionScale}:${full.bevelDp}`,
576
604
  );
577
605
  let rec = interimMaps.get(key);
578
606
  if (!rec) {
579
607
  bump('reducedBakes');
580
- 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 };
581
609
  interimMaps.set(key, rec);
582
610
  }
583
611
  const mine = (seq += 1);
@@ -586,7 +614,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
586
614
  const show = () => {
587
615
  if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
588
616
  shownSeq = mine;
589
- retarget(forFilter, record.map, null);
617
+ retarget(forFilter, record.map, null, record.geometry);
590
618
  };
591
619
  if (record.ready) {
592
620
  show();
@@ -613,7 +641,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
613
641
  // The map depends only on geometry, optics and density — never on the backdrop. Rebuilding
614
642
  // it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
615
643
  // not change; the refraction itself updates in the compositor with no JS at all.
616
- 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}`;
617
646
  if (interim && filterEl) {
618
647
  // The filter keeps its map; feImage stretches it over the live box.
619
648
  } else if (isEmpty(geometry)) {
@@ -622,15 +651,15 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
622
651
  // Keyed by exactly what the maps are functions of, which is exactly the cache key above.
623
652
  const map = cached(`d|${key}`, () => {
624
653
  bump('fullBakes');
625
- return buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState());
654
+ return buildDisplacementMap(full, geometry, devicePixelRatio(), morphState());
626
655
  });
627
- const hue = hasSpectralEdge(opticsNow)
656
+ const hue = hasSpectralEdge(full)
628
657
  ? cached(`h|${key}`, () => {
629
658
  bump('fullBakes');
630
- return buildSpectralMap(opticsNow, geometry, devicePixelRatio());
659
+ return buildSpectralMap(full, geometry, devicePixelRatio());
631
660
  })
632
661
  : null;
633
- const signature = filterSignature(opticsNow.blur, opticsNow.ior, opticsNow.iorSpread, !!hue);
662
+ const signature = filterSignature(full.blur, full.ior, full.iorSpread, !!hue);
634
663
  if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
635
664
  settling = false;
636
665
  const mine = (seq += 1);
@@ -639,21 +668,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
639
668
  void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
640
669
  if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
641
670
  shownSeq = mine;
642
- retarget(forFilter, map, hue);
671
+ retarget(forFilter, map, hue, geometry);
643
672
  });
644
673
  } else {
645
674
  settling = false;
646
675
  invalidate();
647
676
  const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
648
- const next = buildFilterElement(id, map.url, opticsNow, map.scale, hue);
677
+ const next = buildFilterElement(id, map.url, full, map.scale, hue);
649
678
  next.setAttribute('data-vireglass-signature', signature);
650
679
  if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
651
680
  else defs.appendChild(next);
652
681
  bump('filterSwaps');
653
682
  filterEl = next;
683
+ shownGeometry = geometry;
654
684
  }
655
685
  mapKey = key;
656
686
  }
687
+ if (filterEl) present(filterEl);
657
688
  el.style.setProperty('backdrop-filter', `url(#${id})`);
658
689
  el.style.setProperty('-webkit-backdrop-filter', `url(#${id})`);
659
690
  } else {
@@ -922,7 +953,6 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
922
953
  if (next === appear) return;
923
954
  appear = next;
924
955
  apply();
925
- if (interim) requestInterimMap();
926
956
  },
927
957
  setAccent(next) {
928
958
  accent = next ?? undefined;