@shulya/liquid-glass 1.0.4 → 1.0.5

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/dist/core.cjs CHANGED
@@ -13,14 +13,16 @@ const OPTICAL_DEFAULTS = exports.OPTICAL_DEFAULTS = Object.freeze({
13
13
  ior: 1.46,
14
14
  height: 18,
15
15
  frosted: 0,
16
- fusion: true
16
+ fusion: true,
17
+ dpr: 0
17
18
  });
18
19
 
19
20
  const OPTICAL_LIMITS = exports.OPTICAL_LIMITS = Object.freeze({
20
21
  rim: Object.freeze({ min: 4, max: 48 }),
21
22
  ior: Object.freeze({ min: 1.05, max: 2.20 }),
22
23
  height: Object.freeze({ min: 2, max: 50 }),
23
- frosted: Object.freeze({ min: 0, max: 32 })
24
+ frosted: Object.freeze({ min: 0, max: 32 }),
25
+ dpr: Object.freeze({ min: 0.5, max: 3.0 })
24
26
  });
25
27
 
26
28
  function clampNumber(val, fallback, min, max) {
@@ -57,7 +59,11 @@ exports.normalizeOptions = function normalizeOptions(options = {}) {
57
59
  OPTICAL_LIMITS.frosted.min,
58
60
  OPTICAL_LIMITS.frosted.max
59
61
  ),
60
- fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion
62
+ fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion,
63
+ dpr:
64
+ typeof options.dpr === 'number' && options.dpr > 0
65
+ ? clampNumber(options.dpr, 1, OPTICAL_LIMITS.dpr.min, OPTICAL_LIMITS.dpr.max)
66
+ : 0
61
67
  };
62
68
  }
63
69
 
@@ -477,8 +483,17 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
477
483
  return fallback;
478
484
  }
479
485
 
480
- function getDevicePixelRatio() {
481
- return typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 1.5) : 1;
486
+ function getDevicePixelRatio(customDpr = 0, elementWidth = 0) {
487
+ if (typeof customDpr === 'number' && customDpr > 0) {
488
+ return Math.max(0.5, Math.min(3.0, customDpr));
489
+ }
490
+ if (typeof window === 'undefined') return 1;
491
+ const sysDpr = window.devicePixelRatio || 1;
492
+ // Automatically use 1.0 for wide elements (headers, banners >= 700px) to guarantee 120 FPS
493
+ if (elementWidth >= 700) {
494
+ return 1;
495
+ }
496
+ return Math.min(sysDpr, 1.5);
482
497
  }
483
498
 
484
499
  function ensureStageBuffers() {
@@ -721,25 +736,50 @@ function captureViewportStage(neededBlur) {
721
736
 
722
737
  stageCtx.restore();
723
738
 
739
+ let supportsCanvasFilterBlur = null;
740
+ function checkCanvasFilterBlur() {
741
+ if (supportsCanvasFilterBlur !== null) return supportsCanvasFilterBlur;
742
+ if (typeof document === 'undefined') return false;
743
+ try {
744
+ const testC = document.createElement('canvas');
745
+ testC.width = 4;
746
+ testC.height = 4;
747
+ const testCtx = testC.getContext('2d');
748
+ if (!testCtx || typeof testCtx.filter !== 'string') {
749
+ return (supportsCanvasFilterBlur = false);
750
+ }
751
+ testCtx.fillStyle = '#000';
752
+ testCtx.fillRect(0, 0, 4, 4);
753
+ testCtx.filter = 'blur(2px)';
754
+ testCtx.fillStyle = '#fff';
755
+ testCtx.fillRect(0, 0, 2, 2);
756
+ testCtx.filter = 'none';
757
+ const imgData = testCtx.getImageData(0, 0, 4, 4).data;
758
+ supportsCanvasFilterBlur = imgData[15 * 4] > 0 || imgData[14 * 4] > 0 || imgData[11 * 4] > 0;
759
+ return supportsCanvasFilterBlur;
760
+ } catch {
761
+ return (supportsCanvasFilterBlur = false);
762
+ }
763
+ }
764
+
724
765
  // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
725
766
  if (neededBlur > 0) {
726
767
  lastFrostedRadius = neededBlur;
727
768
  blurCtx.clearRect(0, 0, bw, bh);
728
- const filterStr = `blur(${Math.round(neededBlur * dpr)}px)`;
729
- blurCtx.filter = filterStr;
730
- blurCtx.drawImage(stageCanvas, 0, 0);
731
-
732
- // Safari iOS fallback if CanvasRenderingContext2D.filter is unsupported or ignored
733
- if (!blurCtx.filter || blurCtx.filter === 'none') {
734
- const scale = Math.max(0.08, 1.0 / (1.0 + neededBlur * 0.45));
735
- const sw = Math.max(4, Math.round(bw * scale));
736
- const sh = Math.max(4, Math.round(bh * scale));
769
+
770
+ if (checkCanvasFilterBlur()) {
771
+ blurCtx.filter = `blur(${Math.round(neededBlur * dpr)}px)`;
772
+ blurCtx.drawImage(stageCanvas, 0, 0);
773
+ blurCtx.filter = 'none';
774
+ } else {
775
+ // Hardware-accelerated multi-step pyramid downscale/upscale blur for iOS Safari and mobile WebKit
776
+ const factor = Math.max(0.04, 1.0 / (1.0 + neededBlur * 0.45));
777
+ const pw = Math.max(4, Math.round(bw * factor));
778
+ const ph = Math.max(4, Math.round(bh * factor));
737
779
  blurCtx.imageSmoothingEnabled = true;
738
780
  blurCtx.imageSmoothingQuality = 'high';
739
- blurCtx.drawImage(stageCanvas, 0, 0, sw, sh);
740
- blurCtx.drawImage(blurCanvas, 0, 0, sw, sh, 0, 0, bw, bh);
741
- } else {
742
- blurCtx.filter = 'none';
781
+ blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
782
+ blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
743
783
  }
744
784
  }
745
785
  }
@@ -748,7 +788,7 @@ function captureViewportStage(neededBlur) {
748
788
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
749
789
  */
750
790
  function renderIsolatedController(ctrl, rect) {
751
- const dpr = getDevicePixelRatio();
791
+ const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
752
792
  const w = Math.max(16, Math.round(rect.width * dpr));
753
793
  const h = Math.max(16, Math.round(rect.height * dpr));
754
794
  const rad = ctrl._getRadius();
@@ -946,10 +986,12 @@ function renderAllActiveLenses() {
946
986
  h: rect.height,
947
987
  borderRadius: ctrl._getRadius(),
948
988
  fusion: true,
949
- tint: ctrl._tint
989
+ tint: ctrl._tint,
990
+ dpr: ctrl.options.dpr
950
991
  }));
951
992
 
952
- const dpr = getDevicePixelRatio();
993
+ const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
994
+ const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
953
995
  const box = renderFusedCluster(
954
996
  clusterOverlayCtx,
955
997
  sourceCtx,
@@ -964,6 +1006,17 @@ function renderAllActiveLenses() {
964
1006
  if (box && clusterOverlayCanvas) {
965
1007
  clusterOverlayCanvas.style.display = 'block';
966
1008
  clusterOverlayCanvas.style.filter = 'none';
1009
+
1010
+ // Match z-index to highest element in the cluster so it never pops above floating menus/controls
1011
+ let maxZ = 20;
1012
+ for (const item of group) {
1013
+ if (item.ctrl && item.ctrl.element) {
1014
+ const z = parseInt(window.getComputedStyle(item.ctrl.element).zIndex, 10);
1015
+ if (!Number.isNaN(z) && z > maxZ) maxZ = z;
1016
+ }
1017
+ }
1018
+ clusterOverlayCanvas.style.zIndex = String(maxZ);
1019
+
967
1020
  clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
968
1021
  clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
969
1022
  clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
@@ -1136,11 +1189,11 @@ const LiquidGlassController = exports.LiquidGlassController = class LiquidGlassC
1136
1189
  changed = true;
1137
1190
  }
1138
1191
 
1139
- for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
1192
+ for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
1140
1193
  if (merged[key] !== this.options[key]) {
1141
1194
  this.options[key] = merged[key];
1142
1195
  changed = true;
1143
- if (key === 'rim' || key === 'ior' || key === 'height') {
1196
+ if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
1144
1197
  lutChanged = true;
1145
1198
  }
1146
1199
  }
package/dist/core.mjs CHANGED
@@ -13,14 +13,16 @@ export const OPTICAL_DEFAULTS = Object.freeze({
13
13
  ior: 1.46,
14
14
  height: 18,
15
15
  frosted: 0,
16
- fusion: true
16
+ fusion: true,
17
+ dpr: 0
17
18
  });
18
19
 
19
20
  export const OPTICAL_LIMITS = Object.freeze({
20
21
  rim: Object.freeze({ min: 4, max: 48 }),
21
22
  ior: Object.freeze({ min: 1.05, max: 2.20 }),
22
23
  height: Object.freeze({ min: 2, max: 50 }),
23
- frosted: Object.freeze({ min: 0, max: 32 })
24
+ frosted: Object.freeze({ min: 0, max: 32 }),
25
+ dpr: Object.freeze({ min: 0.5, max: 3.0 })
24
26
  });
25
27
 
26
28
  function clampNumber(val, fallback, min, max) {
@@ -57,7 +59,11 @@ export function normalizeOptions(options = {}) {
57
59
  OPTICAL_LIMITS.frosted.min,
58
60
  OPTICAL_LIMITS.frosted.max
59
61
  ),
60
- fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion
62
+ fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion,
63
+ dpr:
64
+ typeof options.dpr === 'number' && options.dpr > 0
65
+ ? clampNumber(options.dpr, 1, OPTICAL_LIMITS.dpr.min, OPTICAL_LIMITS.dpr.max)
66
+ : 0
61
67
  };
62
68
  }
63
69
 
@@ -477,8 +483,17 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
477
483
  return fallback;
478
484
  }
479
485
 
480
- function getDevicePixelRatio() {
481
- return typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 1.5) : 1;
486
+ function getDevicePixelRatio(customDpr = 0, elementWidth = 0) {
487
+ if (typeof customDpr === 'number' && customDpr > 0) {
488
+ return Math.max(0.5, Math.min(3.0, customDpr));
489
+ }
490
+ if (typeof window === 'undefined') return 1;
491
+ const sysDpr = window.devicePixelRatio || 1;
492
+ // Automatically use 1.0 for wide elements (headers, banners >= 700px) to guarantee 120 FPS
493
+ if (elementWidth >= 700) {
494
+ return 1;
495
+ }
496
+ return Math.min(sysDpr, 1.5);
482
497
  }
483
498
 
484
499
  function ensureStageBuffers() {
@@ -721,25 +736,50 @@ function captureViewportStage(neededBlur) {
721
736
 
722
737
  stageCtx.restore();
723
738
 
739
+ let supportsCanvasFilterBlur = null;
740
+ function checkCanvasFilterBlur() {
741
+ if (supportsCanvasFilterBlur !== null) return supportsCanvasFilterBlur;
742
+ if (typeof document === 'undefined') return false;
743
+ try {
744
+ const testC = document.createElement('canvas');
745
+ testC.width = 4;
746
+ testC.height = 4;
747
+ const testCtx = testC.getContext('2d');
748
+ if (!testCtx || typeof testCtx.filter !== 'string') {
749
+ return (supportsCanvasFilterBlur = false);
750
+ }
751
+ testCtx.fillStyle = '#000';
752
+ testCtx.fillRect(0, 0, 4, 4);
753
+ testCtx.filter = 'blur(2px)';
754
+ testCtx.fillStyle = '#fff';
755
+ testCtx.fillRect(0, 0, 2, 2);
756
+ testCtx.filter = 'none';
757
+ const imgData = testCtx.getImageData(0, 0, 4, 4).data;
758
+ supportsCanvasFilterBlur = imgData[15 * 4] > 0 || imgData[14 * 4] > 0 || imgData[11 * 4] > 0;
759
+ return supportsCanvasFilterBlur;
760
+ } catch {
761
+ return (supportsCanvasFilterBlur = false);
762
+ }
763
+ }
764
+
724
765
  // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
725
766
  if (neededBlur > 0) {
726
767
  lastFrostedRadius = neededBlur;
727
768
  blurCtx.clearRect(0, 0, bw, bh);
728
- const filterStr = `blur(${Math.round(neededBlur * dpr)}px)`;
729
- blurCtx.filter = filterStr;
730
- blurCtx.drawImage(stageCanvas, 0, 0);
731
-
732
- // Safari iOS fallback if CanvasRenderingContext2D.filter is unsupported or ignored
733
- if (!blurCtx.filter || blurCtx.filter === 'none') {
734
- const scale = Math.max(0.08, 1.0 / (1.0 + neededBlur * 0.45));
735
- const sw = Math.max(4, Math.round(bw * scale));
736
- const sh = Math.max(4, Math.round(bh * scale));
769
+
770
+ if (checkCanvasFilterBlur()) {
771
+ blurCtx.filter = `blur(${Math.round(neededBlur * dpr)}px)`;
772
+ blurCtx.drawImage(stageCanvas, 0, 0);
773
+ blurCtx.filter = 'none';
774
+ } else {
775
+ // Hardware-accelerated multi-step pyramid downscale/upscale blur for iOS Safari and mobile WebKit
776
+ const factor = Math.max(0.04, 1.0 / (1.0 + neededBlur * 0.45));
777
+ const pw = Math.max(4, Math.round(bw * factor));
778
+ const ph = Math.max(4, Math.round(bh * factor));
737
779
  blurCtx.imageSmoothingEnabled = true;
738
780
  blurCtx.imageSmoothingQuality = 'high';
739
- blurCtx.drawImage(stageCanvas, 0, 0, sw, sh);
740
- blurCtx.drawImage(blurCanvas, 0, 0, sw, sh, 0, 0, bw, bh);
741
- } else {
742
- blurCtx.filter = 'none';
781
+ blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
782
+ blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
743
783
  }
744
784
  }
745
785
  }
@@ -748,7 +788,7 @@ function captureViewportStage(neededBlur) {
748
788
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
749
789
  */
750
790
  function renderIsolatedController(ctrl, rect) {
751
- const dpr = getDevicePixelRatio();
791
+ const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
752
792
  const w = Math.max(16, Math.round(rect.width * dpr));
753
793
  const h = Math.max(16, Math.round(rect.height * dpr));
754
794
  const rad = ctrl._getRadius();
@@ -946,10 +986,12 @@ function renderAllActiveLenses() {
946
986
  h: rect.height,
947
987
  borderRadius: ctrl._getRadius(),
948
988
  fusion: true,
949
- tint: ctrl._tint
989
+ tint: ctrl._tint,
990
+ dpr: ctrl.options.dpr
950
991
  }));
951
992
 
952
- const dpr = getDevicePixelRatio();
993
+ const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
994
+ const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
953
995
  const box = renderFusedCluster(
954
996
  clusterOverlayCtx,
955
997
  sourceCtx,
@@ -964,6 +1006,17 @@ function renderAllActiveLenses() {
964
1006
  if (box && clusterOverlayCanvas) {
965
1007
  clusterOverlayCanvas.style.display = 'block';
966
1008
  clusterOverlayCanvas.style.filter = 'none';
1009
+
1010
+ // Match z-index to highest element in the cluster so it never pops above floating menus/controls
1011
+ let maxZ = 20;
1012
+ for (const item of group) {
1013
+ if (item.ctrl && item.ctrl.element) {
1014
+ const z = parseInt(window.getComputedStyle(item.ctrl.element).zIndex, 10);
1015
+ if (!Number.isNaN(z) && z > maxZ) maxZ = z;
1016
+ }
1017
+ }
1018
+ clusterOverlayCanvas.style.zIndex = String(maxZ);
1019
+
967
1020
  clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
968
1021
  clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
969
1022
  clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
@@ -1136,11 +1189,11 @@ export class LiquidGlassController {
1136
1189
  changed = true;
1137
1190
  }
1138
1191
 
1139
- for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
1192
+ for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
1140
1193
  if (merged[key] !== this.options[key]) {
1141
1194
  this.options[key] = merged[key];
1142
1195
  changed = true;
1143
- if (key === 'rim' || key === 'ior' || key === 'height') {
1196
+ if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
1144
1197
  lutChanged = true;
1145
1198
  }
1146
1199
  }
package/dist/react.cjs CHANGED
@@ -31,7 +31,8 @@ exports.useLiquidGlass = function useLiquidGlass(options = {}) {
31
31
  height: options.height,
32
32
  frosted: options.frosted,
33
33
  fusion: options.fusion,
34
- enabled: options.enabled
34
+ enabled: options.enabled,
35
+ dpr: options.dpr
35
36
  });
36
37
  }
37
38
  }, [
@@ -40,7 +41,8 @@ exports.useLiquidGlass = function useLiquidGlass(options = {}) {
40
41
  options.height,
41
42
  options.frosted,
42
43
  options.fusion,
43
- options.enabled
44
+ options.enabled,
45
+ options.dpr
44
46
  ]);
45
47
 
46
48
  return ref;
@@ -57,6 +59,7 @@ const LiquidGlass = exports.LiquidGlass = forwardRef(function LiquidGlass(
57
59
  frosted = OPTICAL_DEFAULTS.frosted,
58
60
  fusion = OPTICAL_DEFAULTS.fusion,
59
61
  enabled = true,
62
+ dpr = OPTICAL_DEFAULTS.dpr,
60
63
  as: Component = 'div',
61
64
  style,
62
65
  className,
@@ -71,7 +74,8 @@ const LiquidGlass = exports.LiquidGlass = forwardRef(function LiquidGlass(
71
74
  height,
72
75
  frosted,
73
76
  fusion,
74
- enabled
77
+ enabled,
78
+ dpr
75
79
  });
76
80
 
77
81
  const setRefs = useCallback(
package/dist/react.mjs CHANGED
@@ -30,7 +30,8 @@ export function useLiquidGlass(options = {}) {
30
30
  height: options.height,
31
31
  frosted: options.frosted,
32
32
  fusion: options.fusion,
33
- enabled: options.enabled
33
+ enabled: options.enabled,
34
+ dpr: options.dpr
34
35
  });
35
36
  }
36
37
  }, [
@@ -39,7 +40,8 @@ export function useLiquidGlass(options = {}) {
39
40
  options.height,
40
41
  options.frosted,
41
42
  options.fusion,
42
- options.enabled
43
+ options.enabled,
44
+ options.dpr
43
45
  ]);
44
46
 
45
47
  return ref;
@@ -56,6 +58,7 @@ export const LiquidGlass = forwardRef(function LiquidGlass(
56
58
  frosted = OPTICAL_DEFAULTS.frosted,
57
59
  fusion = OPTICAL_DEFAULTS.fusion,
58
60
  enabled = true,
61
+ dpr = OPTICAL_DEFAULTS.dpr,
59
62
  as: Component = 'div',
60
63
  style,
61
64
  className,
@@ -70,7 +73,8 @@ export const LiquidGlass = forwardRef(function LiquidGlass(
70
73
  height,
71
74
  frosted,
72
75
  fusion,
73
- enabled
76
+ enabled,
77
+ dpr
74
78
  });
75
79
 
76
80
  const setRefs = useCallback(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shulya/liquid-glass",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "High-performance, zero-WebGL Liquid Glass optical UI engine with pre-computed empirical lookup tables for React, Vue, Svelte, Angular, SolidJS, and Vanilla JS.",
5
5
  "author": "Shulya (https://shulya.tech)",
6
6
  "homepage": "https://liquid.shulya.tech",
package/types/index.d.ts CHANGED
@@ -30,6 +30,12 @@ export interface LiquidGlassOptions {
30
30
  */
31
31
  enabled?: boolean;
32
32
 
33
+ /**
34
+ * Optional custom device pixel ratio override (e.g. 1.0 for full-width navbars to maximize FPS, or 1.5–2.0 for high-DPI retina sharpness).
35
+ * Defaults to 0 (automatic: 1.5 for compact UI elements, 1.0 for elements >= 700px wide).
36
+ */
37
+ dpr?: number;
38
+
33
39
  /**
34
40
  * Optional border radius override in pixels
35
41
  */
@@ -42,6 +48,7 @@ export declare const OPTICAL_DEFAULTS: Readonly<{
42
48
  height: 18;
43
49
  frosted: 0;
44
50
  fusion: true;
51
+ dpr: 0;
45
52
  }>;
46
53
 
47
54
  export declare const OPTICAL_LIMITS: Readonly<{
@@ -49,6 +56,7 @@ export declare const OPTICAL_LIMITS: Readonly<{
49
56
  ior: Readonly<{ min: 1.05; max: 2.2 }>;
50
57
  height: Readonly<{ min: 2; max: 50 }>;
51
58
  frosted: Readonly<{ min: 0; max: 32 }>;
59
+ dpr: Readonly<{ min: 0.5; max: 3.0 }>;
52
60
  }>;
53
61
 
54
62
  export declare function normalizeOptions(options?: LiquidGlassOptions): Required<LiquidGlassOptions>;