@boyernick/standard-ui-react 0.4.2-canary.60 → 0.4.2-canary.61

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": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.60",
3
+ "version": "0.4.2-canary.61",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/carousel.tsx CHANGED
@@ -562,19 +562,7 @@ export const CarouselDots = ({ className, ...props }: CarouselDotsProps) => {
562
562
  <Glass
563
563
  data-glass-radius="css"
564
564
  className="glass-optical flex items-center gap-1 rounded-full px-3 py-2.5"
565
- config={{
566
- blurAmount: 0,
567
- refraction: 0.12,
568
- zRadius: 3,
569
- chromAberration: 0,
570
- tintStrength: 0,
571
- edgeHighlight: 0.045,
572
- specular: 0.02,
573
- fresnel: 1,
574
- shadowOpacity: 0.065,
575
- shadowSpread: 12,
576
- shadowOffsetY: 3,
577
- }}
565
+ config={{ shadow: 0.06 }}
578
566
  >
579
567
  {scrollSnaps.map((_, index) => {
580
568
  const isSelected = index === selectedIndex
package/src/glass.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import type { GlassConfig } from "./lib/liquidglass/index.js";
4
+ import type { GlassConfig } from "./lib/lens-glass/config";
5
5
  import { cn } from "./lib/cn";
6
6
  import { scheduleGlassRenderer } from "./lib/glass-lifecycle";
7
7
  import { attachGlassLighting } from "./lib/glass-lighting";
@@ -10,19 +10,19 @@ import { Button, type ButtonProps } from "./button";
10
10
  export type GlassState = "pending" | "live" | "off";
11
11
 
12
12
  export interface GlassRootProps extends React.ComponentPropsWithoutRef<"div"> {
13
- /** Default LiquidGlass shader configuration shared by direct Glass children. */
14
- defaults?: Partial<GlassConfig>;
13
+ /** Default glass material configuration shared by direct Glass children. */
14
+ defaults?: GlassConfig;
15
15
  /** Disable WebGL rendering while preserving the underlying DOM. */
16
16
  disabled?: boolean;
17
17
  /** Move glass-optical reflections with the pointer; respects reduced motion. */
18
18
  interactiveLighting?: boolean;
19
- /** Called when the LiquidGlass renderer changes state. */
19
+ /** Called when the glass renderer changes state. */
20
20
  onGlassStateChange?: (state: GlassState) => void;
21
21
  }
22
22
 
23
23
  export interface GlassProps extends React.ComponentPropsWithoutRef<"div"> {
24
- /** Per-pane LiquidGlass shader configuration. */
25
- config?: Partial<GlassConfig>;
24
+ /** Per-pane glass material configuration. */
25
+ config?: GlassConfig;
26
26
  }
27
27
 
28
28
  function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
@@ -34,11 +34,13 @@ function setForwardedRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
34
34
  }
35
35
 
36
36
  /**
37
- * A positioned rendering root for LiquidGlass.
37
+ * A positioned rendering root for the glass material.
38
38
  *
39
- * Glass panes must be direct children of this element. Background content must
40
- * also live inside the root because LiquidGlass samples the root's children,
41
- * not the root's own background.
39
+ * Glass panes must be direct children of this element. Each pane refracts the
40
+ * images, video and canvases inside the root that are behind it, drawn where
41
+ * they are each frame, over the root's children's backgrounds and a ground
42
+ * colour: `--glass-ground` on the root, else the nearest opaque ancestor
43
+ * background.
42
44
  */
43
45
  export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
44
46
  function GlassRoot(
@@ -101,27 +103,18 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
101
103
  }
102
104
 
103
105
  report("pending");
104
- let renderer: { markChanged(): void } | undefined;
105
- // Image load does not bubble and is not a DOM mutation. Lazy-loaded
106
- // images otherwise leave the renderer's initial empty scene cached.
107
- const refresh = () => renderer?.markChanged();
108
- root.addEventListener("load", refresh, true);
109
106
 
110
107
  const lifecycle = scheduleGlassRenderer(
111
108
  startupRef.current,
112
109
  async () => {
113
- const { LiquidGlass } = await import("./lib/liquidglass/index.js");
114
- await document.fonts.ready;
115
- return LiquidGlass.init({
110
+ const { createLensGlass } = await import("./lib/lens-glass/renderer");
111
+ return createLensGlass({
116
112
  root,
117
113
  glassElements,
118
- defaults: JSON.parse(defaultsKey) as Partial<GlassConfig>,
114
+ defaults: JSON.parse(defaultsKey) as GlassConfig,
119
115
  });
120
116
  },
121
- (instance) => {
122
- renderer = instance;
123
- report("live");
124
- },
117
+ () => report("live"),
125
118
  (error) => {
126
119
  console.warn("GlassRoot: renderer initialization failed.", error);
127
120
  report("off");
@@ -129,11 +122,7 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
129
122
  );
130
123
  startupRef.current = lifecycle.settled;
131
124
 
132
- return () => {
133
- root.removeEventListener("load", refresh, true);
134
- renderer = undefined;
135
- lifecycle.dispose();
136
- };
125
+ return () => lifecycle.dispose();
137
126
  }, [defaultsKey, disabled]);
138
127
 
139
128
  return (
@@ -150,8 +139,8 @@ export const GlassRoot = React.forwardRef<HTMLDivElement, GlassRootProps>(
150
139
  );
151
140
 
152
141
  /**
153
- * A LiquidGlass pane. It must be rendered as a direct child of GlassRoot.
154
- * `config` maps directly to LiquidGlass's per-element `data-config` JSON.
142
+ * A glass pane. It must be rendered as a direct child of GlassRoot.
143
+ * `config` is serialized to the pane's `data-config` attribute.
155
144
  */
156
145
  export const Glass = React.forwardRef<HTMLDivElement, GlassProps>(function Glass(
157
146
  { className, config, children, ...props },
@@ -174,7 +163,7 @@ GlassRoot.displayName = "GlassRoot";
174
163
  Glass.displayName = "Glass";
175
164
 
176
165
  export interface GlassButtonProps extends Omit<ButtonProps, "variant"> {
177
- config?: Partial<GlassConfig>;
166
+ config?: GlassConfig;
178
167
  }
179
168
 
180
169
  /** A native StandardUI button, rendered as a direct child of GlassRoot. */
@@ -185,17 +174,9 @@ export function GlassButton({ config, ...props }: GlassButtonProps) {
185
174
  variant="glass"
186
175
  data-slot="glass"
187
176
  data-glass-radius="css"
188
- data-config={JSON.stringify({
189
- blurAmount: 0.12,
190
- refraction: 0.25,
191
- zRadius: 6,
192
- chromAberration: 0.02,
193
- shadowOpacity: 0.15,
194
- ...config,
195
- button: true,
196
- })}
177
+ data-config={JSON.stringify({ ...config, button: true })}
197
178
  />
198
179
  );
199
180
  }
200
181
 
201
- export type { GlassConfig } from "./lib/liquidglass/index.js";
182
+ export type { GlassConfig } from "./lib/lens-glass/config";
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The StandardUI glass material: a lens with a refracting bezel, colour
3
+ * dispersion, a lit rim, frost and grain, over a dimmed ground.
4
+ *
5
+ * Every length is in CSS pixels. Unset values scale with the pane, so the
6
+ * same material reads on a 36px icon button and a 400px panel.
7
+ */
8
+ export interface GlassConfig {
9
+ /** Width of the refracting band inside the edge. Default: 30% of the short side, at most 48. */
10
+ bezel?: number;
11
+ /** How far the bezel bends the ground, at the rim. Default: ¾ of the bezel. */
12
+ bend?: number;
13
+ /** Red channel's share of the bend (colour dispersion). Default 1.2. */
14
+ dispersionRed?: number;
15
+ /** Blue channel's share of the bend. Default 0.8. */
16
+ dispersionBlue?: number;
17
+ /** Rim light intensity. Default 0.5. */
18
+ rim?: number;
19
+ /** Rim light width. Default 3.5, less on small panes. */
20
+ rimWidth?: number;
21
+ /** Specular sheen on the lit side of the bezel. Default 0.35. */
22
+ specular?: number;
23
+ /** Frost blur, as a Gaussian sigma. Default 3. */
24
+ frost?: number;
25
+ /** Film grain. Default 0.07. */
26
+ grain?: number;
27
+ /** Multiplier on the ground seen through the glass (1 = undimmed). Default 0.8. */
28
+ dim?: number;
29
+ /** Fraction of the height where the glass starts fading out. Default 1 (no fade). */
30
+ fadeStart?: number;
31
+ /** Fraction of the height where the glass has faded out. Default 1 (no fade). */
32
+ fadeEnd?: number;
33
+ /** Contact shadow under the pane. Default 0.18. */
34
+ shadow?: number;
35
+ /**
36
+ * Corner radius. Default: the element's computed border radius.
37
+ */
38
+ cornerRadius?: number;
39
+ /**
40
+ * Corner shape: 2 is a circular arc, ~4.2 a continuous "squircle" corner.
41
+ * Fully rounded panes (circles and pills) are always circular. Default 4.2.
42
+ */
43
+ cornerPower?: number;
44
+ /** Hover brightens the rim; pressing flattens the bend. Default false. */
45
+ button?: boolean;
46
+
47
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
48
+ blurAmount?: number;
49
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
50
+ refraction?: number;
51
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
52
+ chromAberration?: number;
53
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
54
+ edgeHighlight?: number;
55
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
56
+ fresnel?: number;
57
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
58
+ distortion?: number;
59
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
60
+ zRadius?: number;
61
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
62
+ opacity?: number;
63
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
64
+ saturation?: number;
65
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
66
+ tintStrength?: number;
67
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
68
+ brightness?: number;
69
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
70
+ shadowOpacity?: number;
71
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
72
+ shadowSpread?: number;
73
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
74
+ shadowOffsetY?: number;
75
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
76
+ floating?: boolean;
77
+ /** @deprecated LiquidGlass option, ignored by the lens material. */
78
+ bevelMode?: number;
79
+ }
80
+
81
+ /** Everything the shader needs for one pane, fully resolved. */
82
+ export interface ResolvedGlass {
83
+ width: number;
84
+ height: number;
85
+ radius: number;
86
+ power: number;
87
+ bezel: number;
88
+ bend: number;
89
+ dispersionRed: number;
90
+ dispersionBlue: number;
91
+ rim: number;
92
+ rimWidth: number;
93
+ specular: number;
94
+ frost: number;
95
+ grain: number;
96
+ dim: number;
97
+ fadeStart: number;
98
+ fadeEnd: number;
99
+ shadow: number;
100
+ button: boolean;
101
+ }
102
+
103
+ /** How far outside the pane the ground must be sampled. */
104
+ export function samplePadding(glass: ResolvedGlass) {
105
+ return Math.ceil(glass.bend * Math.max(glass.dispersionRed, 1) + glass.frost * 2 + 2);
106
+ }
107
+
108
+ const finite = (value: unknown): value is number =>
109
+ typeof value === "number" && Number.isFinite(value);
110
+
111
+ const pick = (value: unknown, fallback: number, min: number, max: number) =>
112
+ finite(value) ? Math.min(max, Math.max(min, value)) : fallback;
113
+
114
+ /**
115
+ * Resolve a pane's configuration against its size and computed radius.
116
+ * Unset lengths scale with the short side, so small controls keep the
117
+ * material's proportions rather than a 48px bezel that swallows them.
118
+ */
119
+ export function resolveGlass(
120
+ config: GlassConfig,
121
+ width: number,
122
+ height: number,
123
+ cssRadius: number,
124
+ ): ResolvedGlass {
125
+ const short = Math.max(1, Math.min(width, height));
126
+ const radius = pick(config.cornerRadius, cssRadius, 0, short / 2);
127
+ // A pane rounded all the way is a circle or a pill: keep it one.
128
+ const fullyRounded = radius >= short / 2 - 0.5;
129
+ const power = fullyRounded ? 2 : pick(config.cornerPower, 4.2, 2, 8);
130
+ const bezel = pick(config.bezel, Math.min(48, short * 0.3), 0, short / 2);
131
+ return {
132
+ width,
133
+ height,
134
+ radius: fullyRounded ? short / 2 : radius,
135
+ power,
136
+ bezel,
137
+ bend: pick(config.bend, bezel * 0.75, 0, 96),
138
+ dispersionRed: pick(config.dispersionRed, 1.2, 1, 2),
139
+ dispersionBlue: pick(config.dispersionBlue, 0.8, 0, 1),
140
+ rim: pick(config.rim, 0.5, 0, 2),
141
+ rimWidth: pick(config.rimWidth, Math.min(3.5, Math.max(1.25, short * 0.04)), 0.5, 12),
142
+ specular: pick(config.specular, 0.35, 0, 2),
143
+ frost: pick(config.frost, 3, 0, 16),
144
+ grain: pick(config.grain, 0.07, 0, 0.5),
145
+ dim: pick(config.dim, 0.8, 0, 1.5),
146
+ fadeStart: pick(config.fadeStart, 1, 0, 1),
147
+ fadeEnd: pick(config.fadeEnd, 1, 0, 1),
148
+ shadow: pick(config.shadow, 0.18, 0, 1),
149
+ button: config.button === true,
150
+ };
151
+ }
152
+
153
+ /** Parse a `data-config` attribute; anything but a JSON object is ignored. */
154
+ export function parseConfig(value: string | null | undefined): GlassConfig {
155
+ if (!value) return {};
156
+ try {
157
+ const parsed: unknown = JSON.parse(value);
158
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
159
+ ? (parsed as GlassConfig)
160
+ : {};
161
+ } catch {
162
+ return {};
163
+ }
164
+ }
165
+
166
+ export interface Box {
167
+ x: number;
168
+ y: number;
169
+ width: number;
170
+ height: number;
171
+ }
172
+
173
+ export function intersect(a: Box, b: Box): Box | null {
174
+ const x = Math.max(a.x, b.x);
175
+ const y = Math.max(a.y, b.y);
176
+ const right = Math.min(a.x + a.width, b.x + b.width);
177
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
178
+ return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
179
+ }
180
+
181
+ /** A CSS `object-position` component: a percentage, a length, or a keyword. */
182
+ function position(value: string | undefined, free: number) {
183
+ const token = (value ?? "50%").trim();
184
+ if (token === "left" || token === "top") return 0;
185
+ if (token === "right" || token === "bottom") return free;
186
+ if (token === "center") return free / 2;
187
+ if (token.endsWith("%")) return (parseFloat(token) / 100) * free;
188
+ const px = parseFloat(token);
189
+ return Number.isFinite(px) ? px : free / 2;
190
+ }
191
+
192
+ /**
193
+ * Where a replaced element's content is painted inside its box, per CSS
194
+ * `object-fit` and `object-position`. Returns the content rectangle in the
195
+ * box's coordinate space (it may overflow the box; callers clip).
196
+ */
197
+ export function objectFitRect(
198
+ box: { width: number; height: number },
199
+ natural: { width: number; height: number },
200
+ fit: string,
201
+ objectPosition: string,
202
+ ): Box {
203
+ const { width: bw, height: bh } = box;
204
+ const nw = natural.width || bw;
205
+ const nh = natural.height || bh;
206
+ let width = bw;
207
+ let height = bh;
208
+ if (fit === "contain" || fit === "cover" || fit === "scale-down") {
209
+ const scale =
210
+ fit === "cover" ? Math.max(bw / nw, bh / nh) : Math.min(bw / nw, bh / nh);
211
+ const clamped = fit === "scale-down" ? Math.min(1, scale) : scale;
212
+ width = nw * clamped;
213
+ height = nh * clamped;
214
+ } else if (fit === "none") {
215
+ width = nw;
216
+ height = nh;
217
+ }
218
+ const [px, py] = objectPosition.split(/\s+/);
219
+ return {
220
+ x: position(px, bw - width),
221
+ y: position(py, bh - height),
222
+ width,
223
+ height,
224
+ };
225
+ }