vireglass 0.1.0 → 2.0.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.
Files changed (97) hide show
  1. package/README.md +83 -9
  2. package/dist/adapters-BfeAopGO.d.ts +154 -0
  3. package/dist/adapters-DugC6Qri.d.cts +154 -0
  4. package/dist/chunk-3PGYF2NK.js +360 -0
  5. package/dist/chunk-3PGYF2NK.js.map +1 -0
  6. package/dist/{chunk-HF2AWZEZ.js → chunk-IQOBZGCF.js} +210 -606
  7. package/dist/chunk-IQOBZGCF.js.map +1 -0
  8. package/dist/chunk-JF3FK254.js +377 -0
  9. package/dist/chunk-JF3FK254.js.map +1 -0
  10. package/dist/chunk-P2ZOODXD.js +58 -0
  11. package/dist/chunk-P2ZOODXD.js.map +1 -0
  12. package/dist/chunk-TTOKBARR.js +456 -0
  13. package/dist/chunk-TTOKBARR.js.map +1 -0
  14. package/dist/dom.cjs +2055 -0
  15. package/dist/dom.cjs.map +1 -0
  16. package/dist/dom.d.cts +347 -0
  17. package/dist/dom.d.ts +347 -0
  18. package/dist/dom.js +1082 -0
  19. package/dist/dom.js.map +1 -0
  20. package/dist/geometry-GvXtt-_S.d.ts +105 -0
  21. package/dist/geometry-RhKd7CM5.d.cts +105 -0
  22. package/dist/group-model-8u4GKrUu.d.cts +194 -0
  23. package/dist/group-model-vYSmEVia.d.ts +194 -0
  24. package/dist/index.cjs +737 -244
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +122 -113
  27. package/dist/index.d.ts +122 -113
  28. package/dist/index.js +6 -277
  29. package/dist/index.js.map +1 -1
  30. package/dist/law.cjs +478 -0
  31. package/dist/law.cjs.map +1 -0
  32. package/dist/law.d.cts +543 -0
  33. package/dist/law.d.ts +543 -0
  34. package/dist/law.js +3 -0
  35. package/dist/law.js.map +1 -0
  36. package/dist/{material-CkUHUBoy.d.cts → material-CnUmdKg1.d.cts} +1 -1
  37. package/dist/{material-CkUHUBoy.d.ts → material-CnUmdKg1.d.ts} +1 -1
  38. package/dist/react.d.cts +1 -1
  39. package/dist/react.d.ts +1 -1
  40. package/dist/reference.cjs +221 -0
  41. package/dist/reference.cjs.map +1 -0
  42. package/dist/reference.d.cts +183 -0
  43. package/dist/reference.d.ts +183 -0
  44. package/dist/reference.js +197 -0
  45. package/dist/reference.js.map +1 -0
  46. package/dist/web/index.cjs +247 -165
  47. package/dist/web/index.cjs.map +1 -1
  48. package/dist/web/index.d.cts +4 -29
  49. package/dist/web/index.d.ts +4 -29
  50. package/dist/web/index.js +5 -90
  51. package/dist/web/index.js.map +1 -1
  52. package/docs/adr-001-rendering.md +288 -0
  53. package/docs/architecture.md +161 -0
  54. package/docs/benchmarks.md +191 -0
  55. package/docs/dom.md +160 -0
  56. package/docs/law.md +65 -0
  57. package/docs/material-lab.md +799 -0
  58. package/docs/platform-parity.md +266 -0
  59. package/docs/reference.md +397 -0
  60. package/package.json +24 -3
  61. package/src/accent.ts +35 -0
  62. package/src/accessibility.ts +30 -16
  63. package/src/adapters.ts +71 -3
  64. package/src/appear.ts +45 -0
  65. package/src/concentric.ts +66 -0
  66. package/src/dom/body.ts +98 -0
  67. package/src/dom/displacement.ts +163 -0
  68. package/src/dom/group.ts +124 -0
  69. package/src/dom/index.ts +739 -0
  70. package/src/dom/map-cache.ts +37 -0
  71. package/src/dom/nesting.ts +32 -0
  72. package/src/dom/preferences.ts +53 -0
  73. package/src/dom/probe.ts +163 -0
  74. package/src/dom/rim.ts +103 -0
  75. package/src/dom/scroll-edge.ts +106 -0
  76. package/src/dom/shadow.ts +56 -0
  77. package/src/dom/spectral-map.ts +102 -0
  78. package/src/dom/support.ts +47 -0
  79. package/src/geometry.ts +41 -14
  80. package/src/glass-scale.ts +3 -5
  81. package/src/index.ts +3 -1
  82. package/src/law.ts +562 -0
  83. package/src/lens-shader.ts +53 -30
  84. package/src/native/glass-surface.tsx +8 -14
  85. package/src/native/provider.tsx +22 -2
  86. package/src/optics.ts +43 -62
  87. package/src/reference.ts +9 -0
  88. package/src/scroll-edge.ts +22 -7
  89. package/src/sdf.ts +195 -8
  90. package/src/shadow-tint.ts +23 -0
  91. package/src/spectral.ts +110 -0
  92. package/src/surface-shader.ts +8 -6
  93. package/src/touch-response.ts +42 -27
  94. package/src/web/index.ts +0 -1
  95. package/dist/adapters-BPB0D5nr.d.cts +0 -357
  96. package/dist/adapters-BgSc-smX.d.ts +0 -357
  97. package/dist/chunk-HF2AWZEZ.js.map +0 -1
package/src/adapters.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ACCENT, MORPH } from './law';
1
2
  import {
2
3
  bevelDp,
3
4
  bevelFraction,
@@ -9,6 +10,7 @@ import {
9
10
  type VireGlassGeometry,
10
11
  } from './geometry';
11
12
  import { LENS_SHADER } from './lens-shader';
13
+ import { neckToBridge } from './sdf';
12
14
  import { debugIndex, type VireGlassDebugMode, type VireGlassOptics } from './material';
13
15
 
14
16
  /** Smooth union with a second shape — bench morphing experiment only. */
@@ -50,7 +52,7 @@ const NO_PROGRESS = -1;
50
52
  export type VireGlassAccent = { color: readonly [number, number, number]; amount?: number };
51
53
 
52
54
  /** Default tint fraction: the color reads, but content beneath it is still visible. */
53
- export const ACCENT_AMOUNT = 0.8;
55
+ export const ACCENT_AMOUNT = ACCENT.amount;
54
56
  const NO_ACCENT = [0, 0, 0, 0] as const;
55
57
 
56
58
 
@@ -340,7 +342,33 @@ export const OVERLAY_UNIFORMS = ['u_overlayOn'] as const;
340
342
  * large elements the bridge has to be wider, otherwise the seam is left with a sharp corner,
341
343
  * which doesn't happen in a liquid.
342
344
  */
343
- const MORPH_NECK = 0.35;
345
+
346
+
347
+ /**
348
+ * How wide the neck between two lobes has to be: enough to soften the seam on a large element, and
349
+ * enough to actually SPAN whatever is between them. The second term is the one that was missing.
350
+ */
351
+ function neckFor(
352
+ half: number,
353
+ aw: number,
354
+ ah: number,
355
+ bw: number,
356
+ bh: number,
357
+ offsetX: number,
358
+ offsetY: number,
359
+ ): number {
360
+ // Surface-to-surface, measured between the bounding boxes. The rounded corners put the true
361
+ // surfaces slightly further apart, so this errs toward a wider neck, which is the safe side.
362
+ const gx = Math.abs(offsetX) - (aw + bw) / 2;
363
+ const gy = Math.abs(offsetY) - (ah + bh) / 2;
364
+ const gap = Math.hypot(Math.max(gx, 0), Math.max(gy, 0));
365
+ // Capped at the narrowest of the two bodies. A neck wider than what it joins is not a neck, and
366
+ // past that width the honest answer is that these are two things rather than one — a menu that
367
+ // has finished leaving its button, two controls too far apart to be a segmented control. Being
368
+ // separate is a legitimate state; being joined by a blob larger than either shape is not.
369
+ const widest = Math.min(aw, ah, bw, bh);
370
+ return Math.min(Math.max(half * MORPH.neck, neckToBridge(gap) * MORPH.fuse), widest);
371
+ }
344
372
 
345
373
  export function morphBetween(
346
374
  a: VireGlassGeometry,
@@ -357,6 +385,46 @@ export function morphBetween(
357
385
  width: b.width,
358
386
  height: b.height,
359
387
  cornerRadius: b.cornerRadius,
360
- smoothing: half * MORPH_NECK * Math.min(t, 1),
388
+ smoothing: neckFor(half, a.width, a.height, b.width, b.height, offsetX, offsetY) * Math.min(t, 1),
389
+ };
390
+ }
391
+
392
+ /**
393
+ * MITOSIS: the second shape grows OUT OF the first and travels to its place, instead of appearing
394
+ * beside it at full size. A menu coming out of its button, a control dividing into segments.
395
+ *
396
+ * `morphBetween` is the other half — two shapes that are both already where they belong, fusing
397
+ * into one body — and run backwards it is meiosis, the neck thinning until it breaks. What neither
398
+ * of them may do is pop: at any `t` above zero this returns one continuous body, because that is
399
+ * the whole of what the reference says a material transition is.
400
+ *
401
+ * The choreography stays the host's: what the shapes MEAN is the app's business. How a silhouette
402
+ * emerges from another silhouette is the material's, the same way the spring under a finger is.
403
+ */
404
+ export function morphOutOf(
405
+ source: VireGlassGeometry,
406
+ target: VireGlassGeometry,
407
+ offsetX: number,
408
+ offsetY: number,
409
+ t: number,
410
+ ): VireGlassMorph | undefined {
411
+ if (t <= 0) return undefined;
412
+ const k = Math.min(t, 1);
413
+ // At t = 0 the lobe IS the parent, in the parent's place: the transition starts from one body and
414
+ // never has a first frame in which a second one exists. The parent's own surface is what the neck
415
+ // attaches to, so nothing has to be budded off to give it something to hold.
416
+ const width = source.width + (target.width - source.width) * k;
417
+ const height = source.height + (target.height - source.height) * k;
418
+ const cornerRadius = source.cornerRadius + (target.cornerRadius - source.cornerRadius) * k;
419
+ const half = Math.min(halfMinDp(source), Math.min(width, height) / 2);
420
+ return {
421
+ offsetX: offsetX * k,
422
+ offsetY: offsetY * k,
423
+ width,
424
+ height,
425
+ cornerRadius,
426
+ // The lobe travels as it grows, so the span changes every frame and the neck is measured
427
+ // against where the lobe actually is, not against where it will end up.
428
+ smoothing: neckFor(half, source.width, source.height, width, height, offsetX * k, offsetY * k),
361
429
  };
362
430
  }
package/src/appear.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Materialising in and out (docs/reference.md §1, 219 @2:55): "Instead of fading, Liquid Glass
2
+ // objects materialize in and out by gradually modulating the light bending and lensing, ensuring a
3
+ // graceful transition that preserves the optical integrity of the material."
4
+ //
5
+ // So an element must not arrive by opacity. Fading it leaves a half-transparent picture OF glass;
6
+ // modulating the lens leaves glass that is not yet bending much light. The difference is visible:
7
+ // a faded element shows the backdrop through a veil, a materialising one shows it undistorted.
8
+ //
9
+ // JS twin of what `u_appear` multiplies in the lens: the lens strength itself, the frost, the
10
+ // body, the dimming layer and the rim's light. Everything that makes the material present, and
11
+ // nothing that makes it opaque.
12
+ import type { VireGlassOptics } from './material';
13
+
14
+ const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
15
+
16
+ /**
17
+ * `t` of 0 is an element indistinguishable from the backdrop beneath it; 1 is the material at
18
+ * full strength. Between them the glass bends progressively more light.
19
+ */
20
+ export function applyAppear(optics: VireGlassOptics, t: number): VireGlassOptics {
21
+ const a = clamp01(t);
22
+ if (a === 1) return optics;
23
+ return {
24
+ ...optics,
25
+ refraction: optics.refraction * a,
26
+ blur: optics.blur * a,
27
+ bodyDensity: optics.bodyDensity * a,
28
+ dimming: optics.dimming * a,
29
+ edgeLight: optics.edgeLight * a,
30
+ // A floor on how far the element must stand out from its backdrop (§3). Left alone it holds
31
+ // the element visible at t = 0, which is the opposite of what materialising means.
32
+ presence: optics.presence * a,
33
+ // The spectral trio is lensing too — §1's "light bending and lensing" is not only the
34
+ // displacement. The fringe map and the per-channel split are drawn from these, and a filter
35
+ // graph that still paints them is an element that is not there but is casting a rainbow.
36
+ dispersion: optics.dispersion * a,
37
+ iridescence: optics.iridescence * a,
38
+ diffraction: optics.diffraction * a,
39
+ tintStrength: optics.tintStrength * a,
40
+ // Legibility is a REQUIREMENT, not an effect: an element still arriving has no ink on it yet,
41
+ // and holding a demand it cannot meet would darken the body exactly while it should be
42
+ // clearing. It fades with the rest.
43
+ legibility: optics.legibility * a,
44
+ };
45
+ }
package/src/concentric.ts CHANGED
@@ -23,3 +23,69 @@ export function concentricRadius(outerRadius: number, inset: number, minimum = 0
23
23
  export function concentricInset(outerRadius: number, innerRadius: number): number {
24
24
  return Math.max(outerRadius - innerRadius, 0);
25
25
  }
26
+
27
+ /**
28
+ * The three shape types the design system builds concentric layouts from (356 @3:42): "fixed
29
+ * shapes have a constant corner radius. Capsules use a radius that's half the height of the
30
+ * container. And concentric shapes calculate their radius by subtracting padding from the
31
+ * parent's."
32
+ *
33
+ * Naming them is the point. 356 @5:09: "using the three shape types we covered earlier helps keep
34
+ * your components aligned with the system." A host that only has a number has to decide what that
35
+ * number means every time the layout changes; a host that has the type gets the number recomputed
36
+ * for it.
37
+ */
38
+ export type ShapeSpec =
39
+ | { type: 'fixed'; radius: number }
40
+ | { type: 'capsule' }
41
+ /**
42
+ * `fallback` is 356 @6:00: "a neat trick for managing components that need to work both inside a
43
+ * container and on their own: use a concentric shape with a fallback radius. The concentric value
44
+ * adapts when nested, and the fallback kicks in when the component stands alone."
45
+ *
46
+ * It is not the same as `minimum`. A minimum is a floor that applies while nested and breaks
47
+ * concentricity on purpose; a fallback applies only when there is no parent to be concentric
48
+ * with, and never overrides a parent that exists.
49
+ */
50
+ | { type: 'concentric'; inset: number; minimum?: number; fallback?: number };
51
+
52
+ /** What is around a shape, when there is something around it. */
53
+ export type ShapeParent = { cornerRadius: number };
54
+
55
+ /**
56
+ * The corner radius a shape takes in the place it finds itself. `height` is the shape's own, which
57
+ * is what a capsule's radius is measured from; `parent` is absent when the shape stands alone.
58
+ */
59
+ export function resolveShape(spec: ShapeSpec, height: number, parent?: ShapeParent): number {
60
+ switch (spec.type) {
61
+ case 'fixed':
62
+ return Math.max(spec.radius, 0);
63
+ case 'capsule':
64
+ return Math.max(height, 0) / 2;
65
+ case 'concentric':
66
+ return parent
67
+ ? concentricRadius(parent.cornerRadius, spec.inset, spec.minimum)
68
+ : Math.max(spec.fallback ?? 0, 0);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Whether two nested shapes are concentric, and which way they are wrong when they are not.
74
+ * 356 @5:19: "keep an eye out for corners that feel too pinched — or flared. They can create
75
+ * tension and break the sense of balance."
76
+ *
77
+ * Pinched is an inner radius smaller than concentricity asks for: the gap between the corners
78
+ * widens through the curve. Flared is larger: the gap narrows and the corners crowd each other.
79
+ * The defect is visible to the eye and usually goes unnamed, which is why this names it.
80
+ */
81
+ export function concentricFit(
82
+ outerRadius: number,
83
+ inset: number,
84
+ innerRadius: number,
85
+ tolerance = 0.5,
86
+ ): 'concentric' | 'pinched' | 'flared' {
87
+ const want = concentricRadius(outerRadius, inset);
88
+ if (innerRadius < want - tolerance) return 'pinched';
89
+ if (innerRadius > want + tolerance) return 'flared';
90
+ return 'concentric';
91
+ }
@@ -0,0 +1,98 @@
1
+ // The glass BODY (docs/reference.md §3): how dense the medium has to be over this particular
2
+ // backdrop, and which way it tints.
3
+ //
4
+ // The law is the lens shader's, lines 444–482, ported to one value per element instead of one per
5
+ // pixel — the DOM path has a single backdrop sample, not a plane. Constants keep their names from
6
+ // the shader so the two stay comparable.
7
+ import { BODY } from '../law';
8
+ import type { VireGlassOptics } from '../material';
9
+
10
+ const {
11
+ capLoose: BODY_CAP_LOOSE,
12
+ capTight: BODY_CAP_TIGHT,
13
+ tintDark: TINT_DARK,
14
+ tintLight: TINT_LIGHT,
15
+ groundSpan: GROUND_SPAN,
16
+ busyEdge: BUSY_EDGE,
17
+ darkSideFrom: DARK_SIDE_FROM,
18
+ darkSideTo: DARK_SIDE_TO,
19
+ maxDemand: MAX_DEMAND,
20
+ } = BODY;
21
+
22
+ const clamp = (v: number, lo: number, hi: number) => (v < lo ? lo : v > hi ? hi : v);
23
+ const mix = (a: number, b: number, t: number) => a + (b - a) * t;
24
+
25
+ function smoothstep(edge0: number, edge1: number, x: number): number {
26
+ const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
27
+ return t * t * (3 - 2 * t);
28
+ }
29
+
30
+ /** Only the two figures the law reads — the same narrowing `shadowOpacityFrom` uses. */
31
+ export type BodyBackdrop = { luma: number; busy: number };
32
+
33
+ export type GlassBody = {
34
+ /** How much medium stands between the backdrop and the eye, 0…1. */
35
+ density: number;
36
+ /** Lightness the body tints toward. */
37
+ tintLuma: number;
38
+ };
39
+
40
+ /**
41
+ * `ink` is POLARITY, 1 light and 0 dark, matching the shader's uniform. The requirements are
42
+ * evaluated at both ends and blended by it — computing at the current polarity alone makes the
43
+ * glass dip toward the middle while a recolor is in flight.
44
+ */
45
+ export function resolveBody(optics: VireGlassOptics, sample: BodyBackdrop, ink: number): GlassBody {
46
+ const strict = clamp(optics.legibility * 2, 0, 1);
47
+ const capLight = mix(BODY_CAP_LOOSE, BODY_CAP_TIGHT, strict);
48
+ const floorDark = 1 - capLight;
49
+ const pol = clamp(ink, 0, 1);
50
+ // With no ink on it, the model promises transparent glass: the requirement fades out with
51
+ // legibility rather than standing at a floor.
52
+ const demand = clamp(optics.legibility * 4, 0, 1);
53
+
54
+ const mean = clamp(sample.luma, 0, 1);
55
+ const busy = Math.max(sample.busy, 0);
56
+
57
+ // Direction of the tint follows the element's AVERAGE lightness, not a local spot: on a
58
+ // gradient a local threshold cuts a diagonal step across the body.
59
+ const darkSide = smoothstep(DARK_SIDE_FROM, DARK_SIDE_TO, mean);
60
+ const away = mix(TINT_LIGHT, TINT_DARK, darkSide);
61
+ const tintLuma = mix(away, mix(TINT_LIGHT, TINT_DARK, pol), demand);
62
+
63
+ // How much medium it takes to push the lightness under the ink past its threshold, measured
64
+ // from whichever edge of the backdrop's spread is closer to the ink — a light ink drowns in a
65
+ // light patch right beneath it, and the average knows nothing about that patch.
66
+ const edge = busy * BUSY_EDGE;
67
+ const inkHi = Math.min(mean + edge, 1);
68
+ const inkLo = Math.max(mean - edge, 0);
69
+ const needForLight = inkHi > capLight ? clamp((inkHi - capLight) / Math.max(inkHi - TINT_DARK, 1e-4), 0, MAX_DEMAND) : 0;
70
+ const needForDark = inkLo < floorDark ? clamp((floorDark - inkLo) / Math.max(TINT_LIGHT - inkLo, 1e-4), 0, MAX_DEMAND) : 0;
71
+ const needForInk = mix(needForDark, needForLight, pol) * demand;
72
+
73
+ // Fine texture the body has to stop being a window for, the same way roughness does it.
74
+ const ground = busy * optics.legibility * GROUND_SPAN;
75
+
76
+ return { density: Math.max(Math.max(optics.bodyDensity, ground), needForInk), tintLuma };
77
+ }
78
+
79
+ /**
80
+ * PRESENCE (§3): over a uniform backdrop there is nothing to refract and the glass honestly
81
+ * disappears — right for a piece of background, wrong for a control. The sign comes from the
82
+ * BACKDROP, not from ink polarity: lighter over dark, darker over light. It is a minimum, not an
83
+ * addition, so a body that is already dense enough is left alone.
84
+ */
85
+ export function withPresence(body: GlassBody, optics: VireGlassOptics, sample: BodyBackdrop): GlassBody {
86
+ if (optics.presence <= 0) return body;
87
+ const mean = clamp(sample.luma, 0, 1);
88
+ const lighter = mean < 0.5;
89
+ const target = lighter ? Math.min(mean + optics.presence, 1) : Math.max(mean - optics.presence, 0);
90
+ const separation = Math.abs(target - mean);
91
+ const reached = Math.abs(body.tintLuma - mean) * body.density;
92
+ if (reached >= separation) return body;
93
+ const needed = separation / Math.max(Math.abs((lighter ? TINT_LIGHT : TINT_DARK) - mean), 1e-4);
94
+ return {
95
+ density: Math.max(body.density, Math.min(needed, MAX_DEMAND)),
96
+ tintLuma: lighter ? Math.max(body.tintLuma, mean) : Math.min(body.tintLuma, mean),
97
+ };
98
+ }
@@ -0,0 +1,163 @@
1
+ // The DOM renderer's own optics: an `feDisplacementMap` source built from the same causes as the
2
+ // GL/AGSL shaders, so a page refracting live content still reads as the same material. See
3
+ // `docs/superpowers/specs/2026-09-19-vireglass-over-live-dom.md` for the technique this encodes.
4
+ import { bevelDp, thicknessDp, type VireGlassGeometry } from '../geometry';
5
+ import { BEVEL } from '../law';
6
+ import type { VireGlassOptics } from '../material';
7
+ import {
8
+ sceneDistance,
9
+ sceneGradient,
10
+ touchWarp,
11
+ type MorphShape,
12
+ type TouchWarp,
13
+ } from '../sdf';
14
+
15
+ export type DisplacementMap = { url: string; width: number; height: number; scale: number };
16
+
17
+ /** dp of rim displacement per dp of size-adjusted thickness, at full refraction strength (ior →
18
+ * 1.6). Tuned so the product default (V5: ior 1.5, thickness 28dp) lands well under `MAX_SCALE`
19
+ * before it's ever clamped — the ceiling below is what actually limits denser glass. */
20
+ const SCALE_GAIN = 0.75;
21
+
22
+ /**
23
+ * `feDisplacementMap` displaces by `scale * (channel / 255 - 0.5)`: a full channel swing spans
24
+ * ±scale/2, and one 8-bit step is `scale/255` px — not `scale/127`, which an earlier version of
25
+ * this file assumed and which rendered every material at half its derived refraction.
26
+ *
27
+ * Measured in Chromium on 2026-09-19: at scale 40 (0.157 px/step) a high-contrast edge visibly
28
+ * terraces; at scale 12 (0.047 px/step) it does not. The ceiling sits between them and nearer
29
+ * the clean end — thick, high-index glass is flattened to it instead of its derived scale,
30
+ * trading rim depth for an edge without stairs.
31
+ */
32
+ const MAX_STEP_PX = 0.09;
33
+ const MAX_SCALE = 255 * MAX_STEP_PX;
34
+
35
+ /**
36
+ * Samples across the bevel band, which is the only place the map carries anything. Everything
37
+ * inside it is a constant, and `feImage` stretches whatever it is given over the element anyway,
38
+ * so resolving the element is wasted work: a sheet at device resolution is 197 000 pixels of
39
+ * per-pixel maths and a PNG encode, 120 ms on attach and far past a frame during interaction.
40
+ *
41
+ * Eight samples describe a profile that is smooth by construction. The measured cost falls with
42
+ * the square of the reduction, and `check:dom` measures the displacement afterwards to say
43
+ * whether anything was lost.
44
+ */
45
+ const BEVEL_SAMPLES = 8;
46
+
47
+ /** Resolution the map is built at: enough to resolve the bevel, never more than the element. */
48
+ function mapDensity(geometry: VireGlassGeometry, bevel: number, dpr: number): number {
49
+ if (bevel <= 0) return dpr;
50
+ return Math.min(dpr, BEVEL_SAMPLES / bevel);
51
+ }
52
+
53
+ /** True neutral is the unrepresentable 127.5; 128 is the nearest byte and leaves a residual
54
+ * 0.002 of the scale, far below one step and below a pixel at any usable scale. */
55
+ function encode(displacement: number, scale: number): number {
56
+ const channel = Math.round(255 * (displacement / scale + 0.5));
57
+ return channel < 0 ? 0 : channel > 255 ? 255 : channel;
58
+ }
59
+
60
+ /** Mirrors the shader's `vgBevelSlope`, renormalized into 0..1: curvature concentrated at the
61
+ * rim, the middle left flat. A plain linear ramp from `vgBevelT` alone reads as a chamfer, not a
62
+ * lens — the same reason the shader doesn't use it raw either. */
63
+ function bevelProfile(t: number): number {
64
+ const raw = Math.min(t / Math.sqrt(Math.max(1 - t * t * BEVEL.sphere, BEVEL.floor)), BEVEL.slopeMax);
65
+ return raw / BEVEL.slopeMax;
66
+ }
67
+
68
+ export type DisplacementState = {
69
+ /** Finger response (docs/reference.md §5): the field around the touch deforms, so the silhouette
70
+ * the backdrop bends along deforms with it. */
71
+ touch?: TouchWarp;
72
+ /** Smooth-union neighbours (§5): shapes merging into one and splitting apart. */
73
+ smoothing?: number;
74
+ morph?: MorphShape;
75
+ morph2?: MorphShape;
76
+ };
77
+
78
+ /**
79
+ * The raw pixel buffer, with no canvas involved — the part actually worth unit-testing.
80
+ * `buildDisplacementMap` below is a thin DOM wrapper around this.
81
+ *
82
+ * Encoding: R = x offset, G = y offset, 128 = no shift, one step = `scale/255` px; B = 128
83
+ * (unused), A = 255 (opaque, `feImage` would otherwise composite the map against page black).
84
+ * The offset points INWARD — the rim samples the backdrop from inside the shape, a convex thick
85
+ * lens. Displacement lives only in the bevel band (`bevelDp`); the flat middle is exactly 128/128.
86
+ */
87
+ export function renderDisplacementPixels(
88
+ optics: VireGlassOptics,
89
+ geometry: VireGlassGeometry,
90
+ dpr: number,
91
+ state: DisplacementState = {},
92
+ ): { data: Uint8ClampedArray; width: number; height: number; scale: number } {
93
+ const bevel = bevelDp(geometry, optics);
94
+ const density = mapDensity(geometry, bevel, dpr);
95
+ const width = Math.max(1, Math.round(geometry.width * density));
96
+ const height = Math.max(1, Math.round(geometry.height * density));
97
+ const rawScale = optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN;
98
+ const scale = Math.min(rawScale, MAX_SCALE);
99
+ const smoothing = state.smoothing ?? 0;
100
+
101
+ const data = new Uint8ClampedArray(width * height * 4);
102
+ const halfW = geometry.width / 2;
103
+ const halfH = geometry.height / 2;
104
+
105
+ for (let py = 0; py < height; py += 1) {
106
+ const y = (py + 0.5) / density - halfH;
107
+ for (let px = 0; px < width; px += 1) {
108
+ const x = (px + 0.5) / density - halfW;
109
+ const i = (py * width + px) * 4;
110
+ // Warp the FIELD first, then read the scene at the warped point — the shader's own order.
111
+ const [wx, wy] = state.touch ? touchWarp(x, y, state.touch) : [x, y];
112
+ const sd = sceneDistance(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, state.morph, state.morph2);
113
+ const t = Math.min(Math.max((sd + bevel) / bevel, 0), 1);
114
+ if (t <= 0 || scale <= 0) {
115
+ data[i] = 128;
116
+ data[i + 1] = 128;
117
+ data[i + 2] = 128;
118
+ data[i + 3] = 255;
119
+ continue;
120
+ }
121
+ const [gx, gy] = sceneGradient(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, state.morph, state.morph2);
122
+ // The rim reaches half the scale: the encoding can only span ±scale/2, so the derived
123
+ // displacement is expressed as a fraction of that half-range.
124
+ const magnitude = bevelProfile(t) * (scale / 2);
125
+ const dx = -gx * magnitude;
126
+ const dy = -gy * magnitude;
127
+ // Inverse of the spec's own formula: channel = 255 * (displacement / scale + 0.5).
128
+ data[i] = encode(dx, scale);
129
+ data[i + 1] = encode(dy, scale);
130
+ data[i + 2] = 128;
131
+ data[i + 3] = 255;
132
+ }
133
+ }
134
+
135
+ return { data, width, height, scale };
136
+ }
137
+
138
+ function hasCanvasSupport(): boolean {
139
+ return typeof document !== 'undefined' && typeof document.createElement === 'function';
140
+ }
141
+
142
+ /** Builds the map at device-pixel resolution so it stays sharp once the browser stretches it back
143
+ * over the element's CSS-px box (`feImage` fills the filter region regardless of source size). */
144
+ export function buildDisplacementMap(
145
+ optics: VireGlassOptics,
146
+ geometry: VireGlassGeometry,
147
+ dpr: number,
148
+ state: DisplacementState = {},
149
+ ): DisplacementMap {
150
+ if (!hasCanvasSupport()) {
151
+ throw new Error('vireglass/dom: buildDisplacementMap requires a DOM (canvas)');
152
+ }
153
+ const { data, width, height, scale } = renderDisplacementPixels(optics, geometry, dpr, state);
154
+ const canvas = document.createElement('canvas');
155
+ canvas.width = width;
156
+ canvas.height = height;
157
+ const ctx = canvas.getContext('2d');
158
+ if (!ctx) throw new Error('vireglass/dom: 2D canvas context unavailable');
159
+ const imageData = ctx.createImageData(width, height);
160
+ imageData.data.set(data);
161
+ ctx.putImageData(imageData, 0, 0);
162
+ return { url: canvas.toDataURL('image/png'), width, height, scale };
163
+ }
@@ -0,0 +1,124 @@
1
+ // A row of glass that agrees with itself.
2
+ //
3
+ // Each element probing on its own is not merely wasteful — it is wrong. Neighbouring pieces of one
4
+ // control sit over slightly different patches of backdrop, reach different polarities, and a tab
5
+ // bar ends up with two light glyphs and two dark ones. §3 is explicit that small elements switch
6
+ // WHOLESALE; a group is what "wholesale" means when the element is really four of them.
7
+ //
8
+ // Every decision is the core's (`../group-model`): the lightness plane, the smoothing, the polarity
9
+ // requirement and its hysteresis. This binds them to real elements.
10
+ import { createGroupState, type GroupPlane } from '../group-model';
11
+ import { TOUCH } from '../law';
12
+ import { probeBackdrop } from './probe';
13
+ import type { BackdropSample } from '../adaptation';
14
+ import type { GlassHandle } from './index';
15
+
16
+ export type GroupEntry = {
17
+ el: HTMLElement;
18
+ handle: GlassHandle;
19
+ /** Set by the group: pass it to this member's own `attachGlass` as `onGlow`. */
20
+ onGlow?: (glow: { pageX: number; pageY: number; strength: number } | null) => void;
21
+ /** How strongly this member needs its ink to stay legible. The strictest in the group wins. */
22
+ legibility?: number;
23
+ };
24
+
25
+ export type GlassGroupHandle = {
26
+ /** Re-read every member and settle on one answer for all of them. */
27
+ update(): void;
28
+ /** The polarity the whole group agreed on, 1 light and 0 dark. */
29
+ ink(): number;
30
+ /** The group's current estimate, or null before the first reading. */
31
+ plane(): GroupPlane | null;
32
+ /** Spread a glow from one member to the rest. Members wired through `onGlow` call this for you. */
33
+ carryGlow(from: number, glow: { pageX: number; pageY: number; strength: number } | null): void;
34
+ destroy(): void;
35
+ };
36
+
37
+ export type GlassGroupOptions = {
38
+ /** Where a member reads its backdrop. Defaults to the DOM probe. */
39
+ probe?: (el: HTMLElement) => BackdropSample;
40
+ };
41
+
42
+ export function createGlassGroup(
43
+ members: readonly GroupEntry[],
44
+ opts: GlassGroupOptions = {},
45
+ ): GlassGroupHandle {
46
+ let polarity = 1;
47
+ let destroyed = false;
48
+
49
+ const state = createGroupState({
50
+ plane: () => {},
51
+ flip: (next) => {
52
+ polarity = next;
53
+ // One flip, everyone at once — that is the whole point of the group.
54
+ for (const { handle } of members) handle.update();
55
+ },
56
+ });
57
+
58
+ /**
59
+ * "Starting right under your fingertips, the glow spreads throughout the element and onto any
60
+ * Liquid Glass elements nearby" (§5, 219 @12:11). The element being touched cannot know who is
61
+ * nearby; the group does.
62
+ */
63
+ function carryGlow(from: number, glow: { pageX: number; pageY: number; strength: number } | null): void {
64
+ members.forEach(({ el, handle }, index) => {
65
+ if (index === from) return;
66
+ if (!glow) {
67
+ handle.setNeighbourGlow(null);
68
+ return;
69
+ }
70
+ const rect = el.getBoundingClientRect();
71
+ const cx = rect.left + rect.width / 2;
72
+ const cy = rect.top + rect.height / 2;
73
+ const reach = (Math.min(rect.width, rect.height) / 2) * TOUCH.glowReach;
74
+ const distance = Math.hypot(glow.pageX - cx, glow.pageY - cy);
75
+ // Falls off with distance and stops: a glow that reached the whole screen would read as the
76
+ // page flashing rather than as light travelling through neighbouring glass.
77
+ const falloff = reach > 0 ? Math.max(0, 1 - distance / reach) : 0;
78
+ handle.setNeighbourGlow(falloff <= 0 ? null : { ...glow, strength: glow.strength * falloff });
79
+ });
80
+ }
81
+
82
+ /** Wire each member's own touch to the rest of the group. */
83
+ function connect(): void {
84
+ members.forEach((entry, index) => {
85
+ entry.onGlow = (glow) => carryGlow(index, glow);
86
+ });
87
+ }
88
+
89
+ function update(): void {
90
+ if (destroyed) return;
91
+ const probe = opts.probe ?? probeBackdrop;
92
+ members.forEach(({ el, legibility }, index) => {
93
+ const rect = el.getBoundingClientRect();
94
+ // A member with no box has nothing behind it to read; reporting it anyway would drag the
95
+ // group's estimate toward whatever sits at the page's origin.
96
+ if (rect.width <= 0 || rect.height <= 0) {
97
+ state.release(String(index));
98
+ return;
99
+ }
100
+ state.report(
101
+ String(index),
102
+ rect.left + rect.width / 2,
103
+ rect.top + rect.height / 2,
104
+ probe(el),
105
+ legibility ?? 0,
106
+ );
107
+ });
108
+ }
109
+
110
+ connect();
111
+ update();
112
+
113
+ return {
114
+ update,
115
+ ink: () => polarity,
116
+ plane: () => state.plane(),
117
+ carryGlow,
118
+ destroy() {
119
+ destroyed = true;
120
+ carryGlow(-1, null);
121
+ members.forEach((_, index) => state.release(String(index)));
122
+ },
123
+ };
124
+ }