vireglass 2.3.1 → 3.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 (58) hide show
  1. package/README.md +37 -6
  2. package/dist/{adapters-BOPIRMnL.d.cts → adapters--bXcGvhW.d.cts} +2 -2
  3. package/dist/{adapters-B_amRBqc.d.ts → adapters-BlMhc6pI.d.ts} +2 -2
  4. package/dist/{chunk-MMPK6DCK.js → chunk-CPSIY2DZ.js} +58 -8
  5. package/dist/chunk-CPSIY2DZ.js.map +1 -0
  6. package/dist/{chunk-UDPJLNFW.js → chunk-RWUR3OV6.js} +4 -4
  7. package/dist/{chunk-UDPJLNFW.js.map → chunk-RWUR3OV6.js.map} +1 -1
  8. package/dist/{chunk-CTTZNZFV.js → chunk-VLDD655U.js} +3 -3
  9. package/dist/{chunk-CTTZNZFV.js.map → chunk-VLDD655U.js.map} +1 -1
  10. package/dist/{chunk-V47YU75P.js → chunk-YAN2OK4H.js} +31 -38
  11. package/dist/chunk-YAN2OK4H.js.map +1 -0
  12. package/dist/{chunk-SLRPHDV7.js → chunk-Z4FKYY47.js} +3 -3
  13. package/dist/{chunk-SLRPHDV7.js.map → chunk-Z4FKYY47.js.map} +1 -1
  14. package/dist/dom.cjs +180 -55
  15. package/dist/dom.cjs.map +1 -1
  16. package/dist/dom.d.cts +72 -25
  17. package/dist/dom.d.ts +72 -25
  18. package/dist/dom.js +116 -54
  19. package/dist/dom.js.map +1 -1
  20. package/dist/{geometry-RhKd7CM5.d.cts → geometry-ip9WSlZv.d.cts} +1 -1
  21. package/dist/{geometry-GvXtt-_S.d.ts → geometry-pabkYV7w.d.ts} +1 -1
  22. package/dist/{group-model-z4N27qsC.d.ts → group-model-CQiI5cCe.d.ts} +1 -1
  23. package/dist/{group-model-DwtixGhG.d.cts → group-model-DX2_W9J_.d.cts} +1 -1
  24. package/dist/index.cjs +92 -40
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +6 -6
  27. package/dist/index.d.ts +6 -6
  28. package/dist/index.js +5 -5
  29. package/dist/law.cjs +56 -6
  30. package/dist/law.cjs.map +1 -1
  31. package/dist/law.d.cts +54 -4
  32. package/dist/law.d.ts +54 -4
  33. package/dist/law.js +1 -1
  34. package/dist/{material-CnUmdKg1.d.ts → material-D9m1EN47.d.cts} +9 -6
  35. package/dist/{material-CnUmdKg1.d.cts → material-D9m1EN47.d.ts} +9 -6
  36. package/dist/react.d.cts +1 -1
  37. package/dist/react.d.ts +1 -1
  38. package/dist/reference.d.cts +2 -2
  39. package/dist/reference.d.ts +2 -2
  40. package/dist/reference.js +2 -2
  41. package/dist/web/index.cjs +91 -39
  42. package/dist/web/index.cjs.map +1 -1
  43. package/dist/web/index.d.cts +3 -3
  44. package/dist/web/index.d.ts +3 -3
  45. package/dist/web/index.js +4 -4
  46. package/docs/dom.md +49 -3
  47. package/docs/law.md +1 -1
  48. package/package.json +1 -1
  49. package/src/dom/body.ts +93 -39
  50. package/src/dom/index.ts +49 -20
  51. package/src/dom/probe.ts +1 -1
  52. package/src/dom/rim.ts +63 -28
  53. package/src/dom/style-hold.ts +8 -0
  54. package/src/law.ts +54 -4
  55. package/src/lens-shader.ts +26 -33
  56. package/src/material.ts +10 -7
  57. package/dist/chunk-MMPK6DCK.js.map +0 -1
  58. package/dist/chunk-V47YU75P.js.map +0 -1
@@ -1,6 +1,6 @@
1
- import { b as VireGlassMorph, c as VireGlassTouch, V as VireGlassAccent } from '../adapters-BOPIRMnL.cjs';
2
- import { V as VireGlassGeometry } from '../geometry-RhKd7CM5.cjs';
3
- import { V as VireGlassOptics, o as VireGlassDebugMode } from '../material-CnUmdKg1.cjs';
1
+ import { b as VireGlassMorph, c as VireGlassTouch, V as VireGlassAccent } from '../adapters--bXcGvhW.cjs';
2
+ import { V as VireGlassGeometry } from '../geometry-ip9WSlZv.cjs';
3
+ import { V as VireGlassOptics, o as VireGlassDebugMode } from '../material-D9m1EN47.cjs';
4
4
 
5
5
  declare const PROBE_GRID_WIDTH = 48;
6
6
  declare const PROBE_GRID_HEIGHT = 96;
@@ -1,6 +1,6 @@
1
- import { b as VireGlassMorph, c as VireGlassTouch, V as VireGlassAccent } from '../adapters-B_amRBqc.js';
2
- import { V as VireGlassGeometry } from '../geometry-GvXtt-_S.js';
3
- import { V as VireGlassOptics, o as VireGlassDebugMode } from '../material-CnUmdKg1.js';
1
+ import { b as VireGlassMorph, c as VireGlassTouch, V as VireGlassAccent } from '../adapters-BlMhc6pI.js';
2
+ import { V as VireGlassGeometry } from '../geometry-pabkYV7w.js';
3
+ import { V as VireGlassOptics, o as VireGlassDebugMode } from '../material-D9m1EN47.js';
4
4
 
5
5
  declare const PROBE_GRID_WIDTH = 48;
6
6
  declare const PROBE_GRID_HEIGHT = 96;
package/dist/web/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { toGLSL, SURFACE_SHADER } from '../chunk-UDPJLNFW.js';
2
- import { LENS_SHADER, toLensProps, toSurfaceUniforms, toDeviceSurfaceUniforms, DYNAMIC_UNIFORMS, ICON_UNIFORMS, OVERLAY_UNIFORMS } from '../chunk-V47YU75P.js';
3
- import { lensPadDp, surfacePadDp, shadowOpacity } from '../chunk-SLRPHDV7.js';
4
- import '../chunk-MMPK6DCK.js';
1
+ import { toGLSL, SURFACE_SHADER } from '../chunk-RWUR3OV6.js';
2
+ import { LENS_SHADER, toLensProps, toSurfaceUniforms, toDeviceSurfaceUniforms, DYNAMIC_UNIFORMS, ICON_UNIFORMS, OVERLAY_UNIFORMS } from '../chunk-YAN2OK4H.js';
3
+ import { lensPadDp, surfacePadDp, shadowOpacity } from '../chunk-Z4FKYY47.js';
4
+ import '../chunk-CPSIY2DZ.js';
5
5
 
6
6
  // src/web/gl.ts
7
7
  var FULLSCREEN_TRIANGLE_VERTEX_SOURCE = `#version 300 es
package/docs/dom.md CHANGED
@@ -41,8 +41,8 @@ checks the centre is neutral.
41
41
  | §1 refraction, magnification | `feDisplacementMap` along a baked displacement map |
42
42
  | §1 dispersion | three displacement passes, one per channel, at the shader's own index shifts |
43
43
  | §1 diffraction, §3 interference | one baked hue map, multiplied in by an arithmetic composite |
44
- | §2 rim light, two arcs, dark edge | a masked conic gradient on `::after`, angles and weights from the lens's key-light lobe |
45
- | §3 body, legibility, presence | `resolveBody` — the lens's own law, one value per element |
44
+ | §2 rim light, two arcs, dark edge | a masked conic gradient on `::after`: a light line round the whole contour with the lens's key-light lobe on it, over a dark edge; strength from the ambient lightness |
45
+ | §3 body, legibility, presence | `resolveBody` and `bodyLayer` — the lens's own law, composed into one translucent layer per element, painted as `background-color` |
46
46
  | §3 ink polarity | `shouldInkBeLight`, the same function every platform uses |
47
47
  | §4 adaptive shadow | `box-shadow`, density from `shadowOpacityFrom` |
48
48
  | §5 touch, press, drag, wave, rise | `createDeform` driving the same `touchWarp` the shader uses |
@@ -57,6 +57,51 @@ lobe, its exponent, the 0.45 opposing arc and the 0.22 dark edge are the lens's
57
57
  spectral hue is baked rather than evaluated, because a filter graph has no way to run a function
58
58
  per pixel but can multiply by an image.
59
59
 
60
+ ## Measured against the reference
61
+
62
+ The controls of A 2:17 (over dark video), M 2:52 (over sky), A 2:18 (grass), M 18:26 (a flower),
63
+ M 18:30 (the same under reduced transparency) and M 11:58 (text) were cut from the reference frames,
64
+ the control's own footprint filled smoothly from its surround, and a 44 dp circle or capsule of
65
+ `VIREGLASS_CONTROL_MATERIAL` laid over each at device pixel ratio 3, with the footprint's mean colour
66
+ as `sample`. Body and surround are OKLab medians of the rings 0.15 to 0.45 R inside the rim and 0.15
67
+ to 0.55 R outside it; the rim is the brightest of 24 angular bins (90th percentile) over the surround.
68
+ The frames are local and are not in the repository.
69
+
70
+ Body lightness over the surround (ΔL) and rim over the surround, reference, then 2.3.1, then this
71
+ release:
72
+
73
+ | control | ref ΔL | 2.3.1 | now | ref rim | 2.3.1 | now |
74
+ |---|---|---|---|---|---|---|
75
+ | A 2:17 skip back | +0.086 | +0.023 | +0.090 | 0.240 | 0.707 | 0.253 |
76
+ | A 2:17 pause | +0.071 | +0.038 | +0.117 | 0.257 | 0.762 | 0.310 |
77
+ | A 2:17 skip forward | +0.045 | +0.027 | +0.114 | 0.268 | 0.796 | 0.320 |
78
+ | A 2:17 Continue | +0.080 | +0.076 | +0.143 | 0.256 | 0.759 | 0.317 |
79
+ | A 2:17 Info | +0.126 | +0.018 | +0.088 | 0.268 | 0.704 | 0.236 |
80
+ | M 2:52 next, over sky | +0.076 | +0.002 | +0.025 | 0.246 | 0.329 | 0.240 |
81
+ | A 2:18 more, over grass (a) | +0.232 | +0.016 | +0.061 | 0.372 | 0.523 | 0.352 |
82
+ | M 18:26 flower (a) | +0.040 | -0.019 | +0.004 | 0.291 | 0.293 | 0.203 |
83
+ | M 18:30 reduced transparency (a) | +0.169 | -0.127 | -0.367 | 0.187 | 0.301 | 0.192 |
84
+ | M 11:58 bar over text (a) | +0.049 | -0.044 | -0.040 | 0.114 | 0.114 | 0.063 |
85
+
86
+ (a) the backdrop under the control is not in the frame, so the surround is only a guess for it.
87
+
88
+ The body is lighter than the surround on every smooth row, as in the reference, and within 0.05 of
89
+ it on skip back, pause and Info; skip forward and Continue are 0.07 and 0.06 above, and sky is 0.051
90
+ below. Chroma is within 0.04 of the reference on every row. The rim is within 0.08 on every smooth
91
+ row. The medium's own lift (matte, pull, concentration of light) sets the body now; the control
92
+ material no longer asks for a presence floor on top of it. A presence that brings the sky inside 0.05
93
+ puts two dark rows further out, and the reference's own spread (+0.045 to +0.126 over similar
94
+ backdrops) is wider than the tolerance, so one constant cannot sit inside all of them.
95
+
96
+ Reduced transparency (M 18:30) is not held to the reference: the backdrop's lightness sits at the
97
+ ink polarity's switch, the ink stays light, and a body at the density that mode asks for goes to its
98
+ pole, a dark plate where the reference is a light one.
99
+
100
+ The WebGL2 lens and this path were measured on the same flat backdrops (the footprint means above)
101
+ with the same material: body within 0.019 of each other everywhere, rim within 0.021 over dark,
102
+ 0.053 over sky and grass and 0.024 over near-white, the lens fainter over light. Both are inside the
103
+ rim tolerance.
104
+
60
105
  ## The probe reads styles, not pixels
61
106
 
62
107
  Adaptation needs to know what is behind the element, and the same door that stops the lens reading
@@ -68,7 +113,8 @@ Compositing is not optional. Taking the first layer with any alpha at all reads
68
113
  exist — over a dark page the ink goes dark and the label disappears.
69
114
 
70
115
  It is exact for colour-defined surfaces and blind to images, video and iframes. Hosts pass their
71
- own sample there; an app already knows its cover art's accent.
116
+ own sample there; an app already knows its cover art's accent, and `setSample` changes it later
117
+ without re-attaching.
72
118
 
73
119
  ## What it costs
74
120
 
package/docs/law.md CHANGED
@@ -42,7 +42,7 @@ It checks three things, and reports a fourth:
42
42
  - and it names every unmeasured value, every run — and fails if this page or the README states a
43
43
  different number, because a count written out in prose goes stale the moment a value is added.
44
44
 
45
- Today that last line reads **43 of 121**. That is the real state of the model, and it is meant to
45
+ Today that last line reads **51 of 130**. That is the real state of the model, and it is meant to
46
46
  be uncomfortable. It was 9 until the constants hiding inside `optics.ts`, `geometry.ts` and
47
47
  `touch-response.ts` were brought in — the number did not get worse, the instrument got honest.
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vireglass",
3
- "version": "2.3.1",
3
+ "version": "3.0.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",
package/src/dom/body.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // The glass BODY (docs/reference.md §3): how dense the medium has to be over this particular
2
2
  // backdrop, and which way it tints.
3
3
  //
4
- // The law is the lens shader's, lines 444–482, ported to one value per element instead of one per
4
+ // The law is the lens shader's, ported to one value per element instead of one per
5
5
  // pixel — the DOM path has a single backdrop sample, not a plane. Constants keep their names from
6
6
  // the shader so the two stay comparable.
7
- import { BODY } from '../law';
7
+ import { BODY, MEDIUM } from '../law';
8
8
  import type { VireGlassOptics } from '../material';
9
9
 
10
10
  const {
@@ -14,8 +14,10 @@ const {
14
14
  tintLight: TINT_LIGHT,
15
15
  groundSpan: GROUND_SPAN,
16
16
  busyEdge: BUSY_EDGE,
17
- darkSideFrom: DARK_SIDE_FROM,
18
- darkSideTo: DARK_SIDE_TO,
17
+ inkBusyFrom: INK_BUSY_FROM,
18
+ inkBusyTo: INK_BUSY_TO,
19
+ inkPoleTo: INK_POLE_TO,
20
+ presenceDemand: PRESENCE_DEMAND,
19
21
  maxDemand: MAX_DEMAND,
20
22
  } = BODY;
21
23
 
@@ -35,8 +37,22 @@ export type GlassBody = {
35
37
  density: number;
36
38
  /** Lightness the body tints toward. */
37
39
  tintLuma: number;
40
+ /** The part of the density the ink's legibility asked for, and the pole it asked toward. */
41
+ need: number;
42
+ pole: number;
38
43
  };
39
44
 
45
+ /**
46
+ * The body is lighter than its surroundings whatever the glyph on it is: glass concentrates light
47
+ * (M 2:29), and the reference keeps a lighter body under a white glyph over sky as much as over
48
+ * dark. It goes to the ink's own pole only where the ink's legibility is what holds the density —
49
+ * or a system setting has raised it past anything a presence floor asks for.
50
+ */
51
+ function tintFor(density: number, need: number, pole: number): number {
52
+ const asked = Math.max(clamp(need / Math.max(density, 1e-4), 0, 1), smoothstep(PRESENCE_DEMAND, INK_POLE_TO, density));
53
+ return mix(TINT_LIGHT, pole, asked);
54
+ }
55
+
40
56
  /**
41
57
  * `ink` is POLARITY, 1 light and 0 dark, matching the shader's uniform. The requirements are
42
58
  * evaluated at both ends and blended by it — computing at the current polarity alone makes the
@@ -54,11 +70,7 @@ export function resolveBody(optics: VireGlassOptics, sample: BodyBackdrop, ink:
54
70
  const mean = clamp(sample.luma, 0, 1);
55
71
  const busy = Math.max(sample.busy, 0);
56
72
 
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);
73
+ const pole = mix(TINT_LIGHT, TINT_DARK, pol);
62
74
 
63
75
  // How much medium it takes to push the lightness under the ink past its threshold, measured
64
76
  // from whichever edge of the backdrop's spread is closer to the ink — a light ink drowns in a
@@ -68,46 +80,88 @@ export function resolveBody(optics: VireGlassOptics, sample: BodyBackdrop, ink:
68
80
  const inkLo = Math.max(mean - edge, 0);
69
81
  const needForLight = inkHi > capLight ? clamp((inkHi - capLight) / Math.max(inkHi - TINT_DARK, 1e-4), 0, MAX_DEMAND) : 0;
70
82
  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;
83
+ // Over a flat backdrop the ink asks for nothing: polarity flips before it drowns. It is spread
84
+ // under the ink that the body has to answer.
85
+ const structured = smoothstep(INK_BUSY_FROM, INK_BUSY_TO, busy);
86
+ const needForInk = mix(needForDark, needForLight, pol) * demand * structured;
72
87
 
73
88
  // Fine texture the body has to stop being a window for, the same way roughness does it.
74
89
  const ground = busy * optics.legibility * GROUND_SPAN;
75
90
 
76
- return { density: Math.max(Math.max(optics.bodyDensity, ground), needForInk), tintLuma };
91
+ const density = Math.max(Math.max(optics.bodyDensity, ground), needForInk);
92
+ return { density, tintLuma: tintFor(density, needForInk, pole), need: needForInk, pole };
77
93
  }
78
94
 
79
95
  /**
80
96
  * 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. It is a MINIMUM, not an
82
- * addition, so a body that already stands apart is left alone.
97
+ * disappears — right for a piece of background, wrong for a control. It is a MINIMUM on the
98
+ * density, toward light whatever the ink, and it does not read the backdrop: the lift it gives is
99
+ * `presence` times the room left under the light pole, so it fades out as the room does and has no
100
+ * step anywhere on the scale.
101
+ */
102
+ export function withPresence(body: GlassBody, optics: VireGlassOptics, _sample?: BodyBackdrop): GlassBody {
103
+ if (optics.presence <= 0) return body;
104
+ const density = Math.max(body.density, Math.min(optics.presence / TINT_LIGHT, PRESENCE_DEMAND));
105
+ return { ...body, density, tintLuma: tintFor(density, body.need, body.pole) };
106
+ }
107
+
108
+ export type BodyLayer = {
109
+ /** Straight (not premultiplied) colour, 0…1 per channel. */
110
+ r: number;
111
+ g: number;
112
+ b: number;
113
+ alpha: number;
114
+ };
115
+
116
+ /** The lens's `vgHue`: colour without lightness, and white below the lightness it has none at. */
117
+ function hueOf(c: readonly [number, number, number]): [number, number, number] {
118
+ const l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
119
+ if (l < 0.02) return [1, 1, 1];
120
+ return [clamp(c[0] / l, 0, 2), clamp(c[1] / l, 0, 2), clamp(c[2] / l, 0, 2)];
121
+ }
122
+
123
+ /**
124
+ * The body as ONE translucent layer over the backdrop, which is the form the DOM can draw it in.
83
125
  *
84
- * The direction is the body's own. The previous version decided "lighter over dark, darker over
85
- * light" from the backdrop alone, and then, when the body disagreed, clamped `tintLuma` to the
86
- * backdrop's mean — the one value that separates from nothing. Light ink over a dark backdrop puts
87
- * the body at 0.07 against a backdrop of 0.12: already separating, downward, and the clamp pulled
88
- * it back onto the backdrop. Measured on the default material at presence 0.08: exactly 0.0000 at
89
- * backdrops of 0.12, 0.20 and 0.35.
126
+ * The lens does not paint a body, it transforms what it refracts, in a chain that stays affine in
127
+ * the backdrop: a mix toward the tint at `density`, the matte lift, the medium's pull toward its
128
+ * own lightness, the ambient spill and the concentration of light (§3, `MEDIUM`). Composing it
129
+ * gives `out = A * backdrop + B` per channel, and a layer of colour `B / (1 - A)` at opacity
130
+ * `1 - A` over the backdrop is that, except where the colour clamps to 0..1: over a saturated dark
131
+ * backdrop (dark blue) a channel is off by up to 0.024.
90
132
  *
91
- * What the demand is DIVIDED by is deliberately the distance to the tint's pole rather than to the
92
- * body's own tint. Dividing by the latter is arithmetically tighter and asks for far more density
93
- * wherever the two are close — enough to drive it to the ceiling over a dark backdrop and stop the
94
- * glass being a window at all, which check:optics says plainly. Presence is a floor on visibility,
95
- * not a licence to go opaque.
133
+ * This is what lifts the body over a dark backdrop. The tint alone, under light ink, is darker
134
+ * than the dark it sits on; the medium's pull, the matte lift and the concentration are what make
135
+ * it read lighter, as in the reference (M 2:29, A 2:17).
136
+ *
137
+ * `appear` multiplies the layers exactly where the lens's `u_appear` does, so `optics` is the
138
+ * element's optics BEFORE `applyAppear`: scaling both would count it twice.
96
139
  */
97
- export function withPresence(body: GlassBody, optics: VireGlassOptics, sample: BodyBackdrop): GlassBody {
98
- if (optics.presence <= 0) return body;
99
- const mean = clamp(sample.luma, 0, 1);
100
- // Whichever side the body is already on; with nothing to go on, the side with more room.
101
- const above = Math.abs(body.tintLuma - mean) > 1e-4 ? body.tintLuma > mean : mean < 0.5;
102
- const pole = above ? TINT_LIGHT : TINT_DARK;
103
- const separation = Math.min(optics.presence, Math.abs(pole - mean));
104
- const reached = Math.abs(body.tintLuma - mean) * body.density;
105
- if (reached >= separation) return body;
106
- const needed = separation / Math.max(Math.abs(pole - mean), 1e-4);
107
- return {
108
- density: Math.max(body.density, Math.min(needed, BODY.presenceDemand)),
109
- // Left where the tint law put it, unless it landed exactly on the backdrop — which is the
110
- // case that used to be created here rather than avoided.
111
- tintLuma: Math.abs(body.tintLuma - mean) > 1e-4 ? body.tintLuma : pole,
140
+ export function bodyLayer(
141
+ optics: VireGlassOptics,
142
+ sample: BodyBackdrop,
143
+ ink: number,
144
+ ambient: readonly [number, number, number],
145
+ appear = 1,
146
+ ): BodyLayer {
147
+ const a = clamp(appear, 0, 1);
148
+ const body = withPresence(resolveBody(optics, sample, ink), optics, sample);
149
+ const hue = hueOf(ambient);
150
+ const pickup = clamp(optics.colorPickup, 0, 1);
151
+ const density = clamp(body.density * a, 0, 1);
152
+ const matte = optics.legibility * MEDIUM.matteLift * a;
153
+ const pull = MEDIUM.pull * a;
154
+ const spill = optics.edgeLight * MEDIUM.ambientSpill * a;
155
+ const lift = MEDIUM.concentrate * a;
156
+
157
+ const alpha = 1 - (1 - lift) * (1 - pull) * (1 - density);
158
+ const channel = (i: 0 | 1 | 2): number => {
159
+ const tintHue = mix(1, hue[i], pickup);
160
+ const added =
161
+ (1 - pull) * (density * tintHue * body.tintLuma + matte * tintHue) +
162
+ pull * hue[i] * MEDIUM.luma +
163
+ ambient[i] * spill;
164
+ return alpha > 1e-6 ? clamp(((1 - lift) * added + lift) / alpha, 0, 1) : 0;
112
165
  };
166
+ return { r: channel(0), g: channel(1), b: channel(2), alpha };
113
167
  }
package/src/dom/index.ts CHANGED
@@ -20,7 +20,7 @@ import { resolveOptics, VIREGLASS_MATERIAL, type VireGlassMaterial, type VireGla
20
20
  import { probeBackdrop } from './probe';
21
21
  import { supportsSvgBackdropFilter } from './support';
22
22
  import { boxShadowCss } from './shadow';
23
- import { resolveBody, withPresence } from './body';
23
+ import { bodyLayer, resolveBody, withPresence } from './body';
24
24
  import { systemAccessibility, watchAccessibility } from './preferences';
25
25
  import { applyAccessibility, contrastRimLuma, elasticAllowed, type VireGlassAccessibility } from '../accessibility';
26
26
  import { applyGlassScale, GLASS_SCALE_DEFAULT } from '../glass-scale';
@@ -30,6 +30,7 @@ import type { VireGlassAccent } from '../adapters';
30
30
  import { concentricRadius } from '../concentric';
31
31
  import { RIM_WIDTH_PX, contrastRimCss, rimGradientCss } from './rim';
32
32
  import { GLASS_ATTR, warnIfNested } from './nesting';
33
+ import { holdProperty } from './style-hold';
33
34
  import { REST_LIGHT } from '../adapters';
34
35
  import { refractionStrength } from '../optics';
35
36
  import { DISPERSION, TOUCH } from '../law';
@@ -289,7 +290,7 @@ function rgbCss(r: number, g: number, b: number): string {
289
290
 
290
291
  /**
291
292
  * The body, from the model's own law (`./body`, ported from the lens shader §3): density is what
292
- * legibility and presence demand over THIS backdrop, `tintLuma` is the lightness it tints toward,
293
+ * legibility and presence demand over THIS backdrop, the tint is the lightness it tints toward,
293
294
  * and the hue is the surroundings' — glass has no colour of its own, only the colour of what lies
294
295
  * beneath it. A flat fill in place of a tint is what breaks the material.
295
296
  */
@@ -297,22 +298,22 @@ function bodyCss(
297
298
  optics: VireGlassOptics,
298
299
  sample: BackdropSample,
299
300
  ink: number,
300
- accent?: VireGlassAccent,
301
+ accent: VireGlassAccent | undefined,
302
+ appear: number,
301
303
  ): string {
302
- const body = withPresence(resolveBody(optics, sample, ink), optics, sample);
303
-
304
304
  // A tinted element takes its colour from the accent rather than from its surroundings, but it is
305
305
  // still the MEDIUM that is coloured: the amount stays short of one so the content keeps coming
306
306
  // through. A fill at full opacity is the thing §7 says breaks the material.
307
307
  if (accent) {
308
+ const settled = applyAppear(optics, appear);
309
+ const body = withPresence(resolveBody(settled, sample, ink), settled, sample);
308
310
  const [r, g, b] = accentTone(accent.color, sample.luma);
309
311
  const amount = Math.max(accentAmount(accent), body.density);
310
312
  return `rgba(${to255(r)}, ${to255(g)}, ${to255(b)}, ${amount.toFixed(3)})`;
311
313
  }
312
314
 
313
- const [r, g, b] = ambientFrom(sample);
314
- const hue = (c: number) => body.tintLuma + (c - body.tintLuma) * optics.colorPickup;
315
- return `rgba(${to255(hue(r))}, ${to255(hue(g))}, ${to255(hue(b))}, ${body.density.toFixed(3)})`;
315
+ const layer = bodyLayer(optics, sample, ink, ambientFrom(sample), appear);
316
+ return `rgba(${to255(layer.r)}, ${to255(layer.g)}, ${to255(layer.b)}, ${layer.alpha.toFixed(3)})`;
316
317
  }
317
318
 
318
319
  export type AttachGlassOptions = {
@@ -354,9 +355,19 @@ export type AttachGlassOptions = {
354
355
  onGlow?: (glow: { pageX: number; pageY: number; strength: number } | null) => void;
355
356
  /** Pointer response (§5). On by default. */
356
357
  interactive?: boolean;
357
- /** Overrides the probe entirely — images and video are invisible to it, and the host app
358
- * already knows its own cover-art accent. */
358
+ /**
359
+ * Overrides the probe entirely. The probe reads declared backgrounds, so images, video and
360
+ * canvases are invisible to it; a host that knows its backdrop (a cover's accent, a gradient)
361
+ * states it here, and changes it later with `setSample`. `statsFromSamples` builds one from
362
+ * a handful of colours.
363
+ */
359
364
  sample?: BackdropSample;
365
+ /**
366
+ * Whether `attachGlass` paints the body as the element's `background-color`. On by default;
367
+ * turn it off if the host owns that property — `--vireglass-body-color` is always emitted
368
+ * either way and carries the same colour.
369
+ */
370
+ body?: boolean;
360
371
  };
361
372
 
362
373
  /**
@@ -395,6 +406,11 @@ export type GlassHandle = {
395
406
  /** A glow that started on a neighbour, in page coordinates (§5). Null clears it. */
396
407
  setNeighbourGlow(glow: { pageX: number; pageY: number; strength: number } | null): void;
397
408
  setMorph(morph: GlassMorph | null): void;
409
+ /**
410
+ * Replaces the backdrop the glass adapts to, without re-attaching, and repaints. The same
411
+ * object as `sample` in the options; `null` goes back to probing declared backgrounds.
412
+ */
413
+ setSample(sample: BackdropSample | null): void;
398
414
  destroy(): void;
399
415
  };
400
416
 
@@ -418,6 +434,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
418
434
  const onlyWhenTouched = opts.variant === 'interactive';
419
435
  let appear = onlyWhenTouched ? 0 : (opts.appear ?? 1);
420
436
  let accent = opts.accent;
437
+ let supplied = opts.sample;
421
438
  // Read once at attach, then followed: a setting turned on while the page is open has to reach
422
439
  // the material, not wait for a reload.
423
440
  let a11y = systemAccessibility();
@@ -436,6 +453,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
436
453
  el.setAttribute(GLASS_ATTR, '');
437
454
  // `inset: 0` on the rim needs a positioned ancestor. Only taken over when the host left it at
438
455
  // the initial value, and put back on destroy.
456
+ const restoreBackground = holdProperty(el.style, 'background-color');
439
457
  const positionWasStatic = getComputedStyle(el).position === 'static';
440
458
  if (positionWasStatic) el.style.setProperty('position', 'relative');
441
459
 
@@ -445,13 +463,16 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
445
463
  * edge of the scale under increased contrast, because the user needs contrast more than they
446
464
  * need the aesthetic.
447
465
  */
448
- function optics(patch?: Partial<VireGlassMaterial>): VireGlassOptics {
466
+ function settledOptics(patch?: Partial<VireGlassMaterial>): VireGlassOptics {
449
467
  const base = resolveOptics(patch ?? opts.material);
450
468
  const scaled = applyGlassScale(base, opts.scale ?? GLASS_SCALE_DEFAULT);
451
- const settled = applyAccessibility(scaled, opts.accessibility ?? a11y);
469
+ return applyAccessibility(scaled, opts.accessibility ?? a11y);
470
+ }
471
+
472
+ function optics(patch?: Partial<VireGlassMaterial>): VireGlassOptics {
452
473
  // Appearance last, and by modulating the lens rather than by fading the element: §1 is
453
474
  // explicit that opacity is the wrong instrument for this.
454
- return applyAppear(settled, appear);
475
+ return applyAppear(settledOptics(patch), appear);
455
476
  }
456
477
 
457
478
  function morphState() {
@@ -470,7 +491,10 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
470
491
  if (destroyed) return;
471
492
  const geometry = opts.geometry ?? measureGeometry(el);
472
493
  const opticsNow = optics();
473
- const sample = opts.sample ?? probeBackdrop(el);
494
+ const sample = supplied ?? probeBackdrop(el);
495
+ const light = shouldInkBeLight(sample, wasLight);
496
+ wasLight = light;
497
+ const body = opts.body === false ? null : bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear);
474
498
 
475
499
  if (svgSupported) {
476
500
  // The map depends only on geometry, optics and density — never on the backdrop. Rebuilding
@@ -495,11 +519,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
495
519
  } else {
496
520
  el.style.setProperty('backdrop-filter', fallbackFilterCss(opticsNow));
497
521
  el.style.setProperty('-webkit-backdrop-filter', fallbackFilterCss(opticsNow));
498
- el.style.backgroundColor = bodyCss(opticsNow, sample, wasLight ? 1 : 0, accent);
499
522
  }
523
+ if (body) el.style.backgroundColor = body;
500
524
 
501
- const light = shouldInkBeLight(sample, wasLight);
502
- wasLight = light;
503
525
  const [tr, tg, tb] = ambientFrom(sample);
504
526
  el.style.setProperty('--vireglass-ink', light ? '1' : '0');
505
527
  el.style.setProperty('--vireglass-tint', `${tr} ${tg} ${tb}`);
@@ -511,7 +533,10 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
511
533
  const inkLevel = Math.round((light ? INK_LIGHT : INK_DARK) * 255);
512
534
  el.style.setProperty('--vireglass-ink-color', `rgb(${inkLevel} ${inkLevel} ${inkLevel})`);
513
535
  el.style.setProperty('--vireglass-tint-color', rgbCss(tr, tg, tb));
514
- el.style.setProperty('--vireglass-body-color', bodyCss(opticsNow, sample, light ? 1 : 0, accent));
536
+ el.style.setProperty(
537
+ '--vireglass-body-color',
538
+ body ?? bodyCss(settledOptics(), sample, light ? 1 : 0, accent, appear),
539
+ );
515
540
 
516
541
  // Rim and shadow follow the SAMPLE, not just the geometry, so they sit outside the map's
517
542
  // cache key: the environment they take their colour and density from moves under a scroll
@@ -616,7 +641,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
616
641
  el.style.setProperty('--vireglass-glow-opacity', '0');
617
642
  return;
618
643
  }
619
- const [gr, gg, gb] = ambientFrom(opts.sample ?? probeBackdrop(el));
644
+ const [gr, gg, gb] = ambientFrom(supplied ?? probeBackdrop(el));
620
645
  const cx = ((x + geometry.width / 2) / geometry.width) * 100;
621
646
  const cy = ((y + geometry.height / 2) / geometry.height) * 100;
622
647
  const reach = halfMinDp(geometry) * TOUCH_RADIUS_FRACTION;
@@ -725,7 +750,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
725
750
  mapKey = '';
726
751
  el.style.removeProperty('backdrop-filter');
727
752
  el.style.removeProperty('-webkit-backdrop-filter');
728
- el.style.removeProperty('background-color');
753
+ if (opts.body !== false) restoreBackground();
729
754
  el.style.removeProperty('--vireglass-ink');
730
755
  el.style.removeProperty('--vireglass-tint');
731
756
  el.style.removeProperty('--vireglass-body-density');
@@ -774,6 +799,10 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
774
799
  mapKey = '';
775
800
  apply();
776
801
  },
802
+ setSample(next) {
803
+ supplied = next ?? undefined;
804
+ apply();
805
+ },
777
806
  destroy,
778
807
  };
779
808
  attached.set(el, handle);
package/src/dom/probe.ts CHANGED
@@ -40,7 +40,7 @@ function luma(r: number, g: number, b: number): number {
40
40
  * formula here would mean the ink-polarity decision quietly drifts between renderers on the same
41
41
  * page. `samples` are 0..255 per channel, matching what `parseColor` returns.
42
42
  */
43
- export function statsFromSamples(samples: readonly RgbColor[]): BackdropSample {
43
+ export function statsFromSamples(samples: readonly (Omit<RgbColor, 'a'> & { a?: number })[]): BackdropSample {
44
44
  const n = samples.length;
45
45
  if (n === 0) return { luma: 0.5, busy: 0, lo: 0.5, hi: 0.5, r: 0.5, g: 0.5, b: 0.5 };
46
46
 
package/src/dom/rim.ts CHANGED
@@ -1,15 +1,20 @@
1
- // Rim light (docs/reference.md §2): a hairline along the silhouette — "not a band and not a
2
- // bevel" — carrying two opposing arcs, the rest of it outlined by a thin dark line, and its colour
3
- // taken from the environment.
1
+ // Rim light (docs/reference.md §2): a hairline along the whole silhouette — "not a band and not a
2
+ // bevel" — brighter toward the key light, with a second, weaker arc opposite it, outlined by a thin
3
+ // dark line, its colour taken from the environment and its strength from how light that is.
4
4
  //
5
- // Every number here is the lens shader's own. The key-light lobe is `lens-shader.ts` line 375
6
- // verbatim (exponent 3, opposing arc at 0.45), and the dark edge is its line 528 (a 0.22
7
- // darkening at the silhouette). The only thing that differs is the primitive: a conic gradient
8
- // sampled at fixed angles instead of a per-pixel normal.
5
+ // Every number here is the lens shader's own, read from the law. The only thing that differs is
6
+ // the primitive: a conic gradient sampled at fixed angles instead of a per-pixel normal.
9
7
  import { RIM } from '../law';
10
8
  import type { VireGlassOptics } from '../material';
11
9
 
12
- const { lobeExponent: LOBE_EXPONENT, opposingArc: OPPOSING_ARC, darkEdge: DARK_EDGE } = RIM;
10
+ const {
11
+ lobeExponent: LOBE_EXPONENT,
12
+ opposingArc: OPPOSING_ARC,
13
+ darkEdge: DARK_EDGE,
14
+ contour: CONTOUR,
15
+ whiteShare: WHITE_SHARE,
16
+ reflectanceRef: REFLECTANCE_REF,
17
+ } = RIM;
13
18
 
14
19
  /** One stop every 7.5°. Fine enough that a cubic lobe reads as a smooth arc rather than a fan. */
15
20
  const STOPS = 48;
@@ -39,13 +44,45 @@ const to255 = (v: number) => Math.round(clamp01(v) * 255);
39
44
 
40
45
  export type RimAmbient = readonly [number, number, number];
41
46
 
47
+ const mixTo = (a: number, b: number, t: number) => a + (b - a) * t;
48
+
49
+ function smoothstep(edge0: number, edge1: number, x: number): number {
50
+ const t = clamp01((x - edge0) / (edge1 - edge0));
51
+ return t * t * (3 - 2 * t);
52
+ }
53
+
54
+ /** Lightness of an ambient colour, the weights the lens uses. */
55
+ export function ambientLuma(ambient: RimAmbient): number {
56
+ return 0.2126 * ambient[0] + 0.7152 * ambient[1] + 0.0722 * ambient[2];
57
+ }
58
+
59
+ /** How strongly the line is drawn over a surround of this lightness (§2, §6). */
60
+ export function rimStrength(luma: number): number {
61
+ return mixTo(RIM.strengthDark, RIM.strengthLight, smoothstep(RIM.ambientFrom, RIM.ambientTo, luma));
62
+ }
63
+
42
64
  /**
43
- * The gradient for the hairline. Colour comes from the environment — "over a yellow flower the rim
44
- * is yellow, over sky it is blue" (§2) — lifted toward white where the key light lands. Away from
45
- * both arcs only the dark edge remains.
65
+ * The line's opacity where the rim's normal sits `deltaDeg` from the light: a floor all the way
66
+ * round and the key-light lobe on top of it, all of it scaled by the medium's reflectance up to
67
+ * the reference material's.
68
+ */
69
+ export function rimLineAlpha(deltaDeg: number, optics: VireGlassOptics, luma: number): number {
70
+ const reflect = Math.min(optics.fresnel / REFLECTANCE_REF, 1);
71
+ const damping = optics.fresnel > 0 ? optics.specular / optics.fresnel : 0;
72
+ const lobe = rimLobe(deltaDeg, damping);
73
+ return clamp01(rimStrength(luma) * reflect * (CONTOUR + (1 - CONTOUR) * lobe));
74
+ }
75
+
76
+ /**
77
+ * The gradient for the hairline: a light line round the whole contour, brighter toward the key
78
+ * light, drawn over the dark edge. Its colour comes from the environment — "over a yellow flower
79
+ * the rim is yellow, over sky it is blue" (§2) — lifted toward white, and its strength follows how
80
+ * light the environment is.
46
81
  *
47
82
  * The opposing arc carries an iridescent fringe (§2), so `iridescence` pulls its channels apart
48
83
  * rather than lifting them together.
84
+ *
85
+ * Two layers, the line first: `--vireglass-rim` is a background list and paints as one.
49
86
  */
50
87
  export function rimGradientCss(
51
88
  optics: VireGlassOptics,
@@ -56,31 +93,29 @@ export function rimGradientCss(
56
93
  const present = clamp01(appear);
57
94
  const start = lightConicAngle(light);
58
95
  const [ar, ag, ab] = ambient;
59
- const stops: string[] = [];
96
+ const luma = ambientLuma(ambient);
97
+ const line: string[] = [];
60
98
 
61
99
  for (let i = 0; i <= STOPS; i += 1) {
62
100
  const angle = (i / STOPS) * 360;
63
- const key = rimLobe(angle, optics.specular);
64
101
  const facing = Math.cos((angle * Math.PI) / 180);
65
102
  // The far arc is where dispersion shows as a fringe; the near one is a clean specular.
66
103
  const fringe = facing < 0 ? optics.iridescence * OPPOSING_ARC : 0;
67
- const lift = clamp01(key * optics.fresnel);
68
-
69
- const r = ar + (1 - ar) * clamp01(lift + fringe);
70
- const g = ag + (1 - ag) * lift;
71
- const b = ab + (1 - ab) * clamp01(lift - fringe);
72
-
73
- // Scaled, not floored: the dark edge is the one part of the rim that survives facing away from
74
- // the light, so leaving it unscaled leaves a hairline drawn around an element that is not there.
75
- const alpha = clamp01(DARK_EDGE + lift * (1 - DARK_EDGE)) * present;
76
- // Below the arcs the line is the dark edge itself, not a dim copy of the environment.
77
- const colour = lift > 0.001
78
- ? `rgba(${to255(r)},${to255(g)},${to255(b)},${alpha.toFixed(3)})`
79
- : `rgba(0,0,0,${alpha.toFixed(3)})`;
80
- stops.push(`${colour} ${angle.toFixed(1)}deg`);
104
+
105
+ const r = ar + (1 - ar) * clamp01(WHITE_SHARE + fringe);
106
+ const g = ag + (1 - ag) * WHITE_SHARE;
107
+ const b = ab + (1 - ab) * clamp01(WHITE_SHARE - fringe);
108
+ const alpha = rimLineAlpha(angle, optics, luma) * present;
109
+ line.push(`rgba(${to255(r)},${to255(g)},${to255(b)},${alpha.toFixed(3)}) ${angle.toFixed(1)}deg`);
81
110
  }
82
111
 
83
- return `conic-gradient(from ${start.toFixed(1)}deg, ${stops.join(', ')})`;
112
+ // Scaled by presence, not floored: the dark edge would otherwise outline an element that is not
113
+ // there yet.
114
+ const dark = `rgba(0,0,0,${(DARK_EDGE * present).toFixed(3)})`;
115
+ return (
116
+ `conic-gradient(from ${start.toFixed(1)}deg, ${line.join(', ')}), ` +
117
+ `conic-gradient(${dark} 0deg, ${dark} 360deg)`
118
+ );
84
119
  }
85
120
 
86
121
  export const RIM_WIDTH_PX = RIM.widthPx;