@shulya/liquid-glass 1.0.3 → 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
 
@@ -221,7 +227,7 @@ exports.buildOpticalMapURI = function buildOpticalMapURI(width, height, borderRa
221
227
  */
222
228
  exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
223
229
  const options = normalizeOptions(rawOptions);
224
- const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 44) * dpr : 0;
230
+ const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
225
231
  const rimPx = Math.max(1, options.rim * dpr);
226
232
  const ior = options.ior;
227
233
  const heightPx = options.height * dpr;
@@ -259,19 +265,26 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
259
265
  const dstData = dstImg.data;
260
266
 
261
267
  const distGrid = new Float32Array(boxW * boxH);
262
- const prep = elements.map((b) => ({
263
- cx: (b.x + b.w * 0.5) * dpr,
264
- cy: (b.y + b.h * 0.5) * dpr,
265
- halfW: b.w * 0.5 * dpr,
266
- halfH: b.h * 0.5 * dpr,
267
- r: Math.min((b.borderRadius ?? 0) * dpr, b.w * 0.5 * dpr, b.h * 0.5 * dpr),
268
- isCircle: b.shape === 'circle',
269
- fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
270
- tintR: b.tint ? b.tint[0] : 15,
271
- tintG: b.tint ? b.tint[1] : 23,
272
- tintB: b.tint ? b.tint[2] : 42,
273
- tintA: b.tint ? b.tint[3] : 0.35
274
- }));
268
+ const prep = elements.map((b) => {
269
+ const halfW = b.w * 0.5 * dpr;
270
+ const halfH = b.h * 0.5 * dpr;
271
+ const rad = (b.borderRadius ?? 0) * dpr;
272
+ const r = Math.min(rad, halfW, halfH);
273
+ const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
274
+ return {
275
+ cx: (b.x + b.w * 0.5) * dpr,
276
+ cy: (b.y + b.h * 0.5) * dpr,
277
+ halfW,
278
+ halfH,
279
+ r,
280
+ isCircle,
281
+ fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
282
+ tintR: b.tint ? b.tint[0] : 15,
283
+ tintG: b.tint ? b.tint[1] : 23,
284
+ tintB: b.tint ? b.tint[2] : 42,
285
+ tintA: b.tint ? b.tint[3] : 0.35
286
+ };
287
+ });
275
288
 
276
289
  const primary = prep[0];
277
290
  const tR = primary.tintR;
@@ -279,8 +292,10 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
279
292
  const tB = primary.tintB;
280
293
  const tA = primary.tintA;
281
294
  const invTA = 1.0 - tA;
295
+ const span = fusionSpan;
296
+ const invSpan = span > 0.001 ? 1.0 / span : 0;
282
297
 
283
- // Pass 1: Boundary evaluation across cluster bounding box
298
+ // Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
284
299
  for (let y = 0; y < boxH; y++) {
285
300
  const py = cMinY + y;
286
301
  const row = y * boxW;
@@ -289,22 +304,36 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
289
304
  let dCluster = Infinity;
290
305
  for (let i = 0; i < prep.length; i++) {
291
306
  const b = prep[i];
292
- const dShape = b.isCircle
293
- ? evalCircle(px - b.cx, py - b.cy, b.halfW)
294
- : evalRoundedRect(px - b.cx, py - b.cy, b.halfW, b.halfH, b.r);
307
+ let dShape;
308
+ if (b.isCircle) {
309
+ dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
310
+ } else {
311
+ const qx = Math.abs(px - b.cx) - b.halfW + b.r;
312
+ const qy = Math.abs(py - b.cy) - b.halfH + b.r;
313
+ dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
314
+ }
315
+
295
316
  if (i === 0) {
296
317
  dCluster = dShape;
297
- } else if (b.fusion && fusionSpan > 0) {
298
- dCluster = blendSurfaces(dCluster, dShape, fusionSpan);
299
- } else {
300
- dCluster = Math.min(dCluster, dShape);
318
+ } else if (b.fusion && span > 0.001) {
319
+ const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
320
+ if (diff < span) {
321
+ const pos = diff * invSpan * 255.0;
322
+ const pIdx = Math.min(254, pos | 0);
323
+ const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
324
+ dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
325
+ } else if (dShape < dCluster) {
326
+ dCluster = dShape;
327
+ }
328
+ } else if (dShape < dCluster) {
329
+ dCluster = dShape;
301
330
  }
302
331
  }
303
332
  distGrid[row + x] = dCluster;
304
333
  }
305
334
  }
306
335
 
307
- // Pass 2: O(1) lookup from distGrid + tabular chromatic shift (_T4)
336
+ // Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
308
337
  for (let y = 1; y < boxH - 1; y++) {
309
338
  const row = y * boxW;
310
339
  for (let x = 1; x < boxW - 1; x++) {
@@ -325,8 +354,11 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
325
354
  const innerDist = -d;
326
355
  if (innerDist < rimPx) {
327
356
  const u = innerDist / rimPx;
328
- const gain = sampleTable(_T0, u);
329
- disp = sampleTable(_T4, u);
357
+ const posU = u * 255.0;
358
+ const uIdx = Math.min(254, posU | 0);
359
+ const uFrac = posU - uIdx;
360
+ const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
361
+ disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
330
362
 
331
363
  const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
332
364
  const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
@@ -339,7 +371,8 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
339
371
  offY = Math.round(-gy * shift);
340
372
 
341
373
  if (gy < -0.2) {
342
- sheen = Math.round(sampleTable(_T2, u) * (-gy) * 0.78);
374
+ const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
375
+ sheen = Math.round(sVal * (-gy) * 0.78);
343
376
  }
344
377
  } else {
345
378
  // Fast interior path: zero displacement inside flat region (-d >= rimPx)
@@ -354,7 +387,9 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
354
387
 
355
388
  const absD = Math.abs(d);
356
389
  if (absD < 3.0) {
357
- glint = Math.round(sampleTable(_T1, absD * 0.33) * 0.65);
390
+ const posG = Math.min(254, absD * 0.33 * 255.0);
391
+ const gIdx = posG | 0;
392
+ glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
358
393
  }
359
394
 
360
395
  const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
@@ -448,11 +483,27 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
448
483
  return fallback;
449
484
  }
450
485
 
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);
497
+ }
498
+
451
499
  function ensureStageBuffers() {
452
500
  if (typeof document === 'undefined') return false;
501
+ const dpr = getDevicePixelRatio();
453
502
  const docEl = document.documentElement;
454
503
  const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
455
504
  const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
505
+ const bufW = Math.round(vw * dpr);
506
+ const bufH = Math.round(vh * dpr);
456
507
 
457
508
  if (!stageCanvas) {
458
509
  stageCanvas = document.createElement('canvas');
@@ -461,11 +512,11 @@ function ensureStageBuffers() {
461
512
  blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
462
513
  }
463
514
 
464
- if (stageCanvas.width !== vw || stageCanvas.height !== vh) {
465
- stageCanvas.width = vw;
466
- stageCanvas.height = vh;
467
- blurCanvas.width = vw;
468
- blurCanvas.height = vh;
515
+ if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
516
+ stageCanvas.width = bufW;
517
+ stageCanvas.height = bufH;
518
+ blurCanvas.width = bufW;
519
+ blurCanvas.height = bufH;
469
520
  }
470
521
 
471
522
  if (!clusterOverlayCanvas) {
@@ -492,14 +543,20 @@ function ensureStageBuffers() {
492
543
  */
493
544
  function captureViewportStage(neededBlur) {
494
545
  if (!ensureStageBuffers()) return;
495
- const vw = stageCanvas.width;
496
- const vh = stageCanvas.height;
546
+ const dpr = getDevicePixelRatio();
547
+ const bw = stageCanvas.width;
548
+ const bh = stageCanvas.height;
549
+ const vw = Math.round(bw / dpr);
550
+ const vh = Math.round(bh / dpr);
497
551
 
498
- // 1. Base page background
499
- stageCtx.fillStyle = '#040814';
500
- stageCtx.fillRect(0, 0, vw, vh);
552
+ stageCtx.save();
501
553
 
502
- // 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
554
+ // 1. Base page background (detect light/dark body background)
555
+ const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
556
+ stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
557
+ stageCtx.fillRect(0, 0, bw, bh);
558
+
559
+ // 2. Copy any background 2D canvas using physical buffer pixels
503
560
  const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
504
561
  for (let i = 0; i < bgCanvases.length; i++) {
505
562
  const c = bgCanvases[i];
@@ -507,7 +564,13 @@ function captureViewportStage(neededBlur) {
507
564
  try {
508
565
  const cr = c.getBoundingClientRect();
509
566
  if (cr.width > 0 && cr.height > 0) {
510
- stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
567
+ stageCtx.drawImage(
568
+ c,
569
+ Math.round(cr.left * dpr),
570
+ Math.round(cr.top * dpr),
571
+ Math.round(cr.width * dpr),
572
+ Math.round(cr.height * dpr)
573
+ );
511
574
  }
512
575
  } catch {
513
576
  // Ignore cross-origin canvas if any
@@ -515,6 +578,9 @@ function captureViewportStage(neededBlur) {
515
578
  }
516
579
  }
517
580
 
581
+ // Scale context for DOM elements rendered in CSS pixels
582
+ stageCtx.scale(dpr, dpr);
583
+
518
584
  // 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
519
585
  const surfaceEls = document.querySelectorAll(
520
586
  'main div.rounded-2xl, main div.rounded-xl, main a.rounded-2xl, main a.rounded-xl, main button, main a.inline-flex, main input, main textarea, main span.rounded-lg, main span.rounded-full'
@@ -668,13 +734,53 @@ function captureViewportStage(neededBlur) {
668
734
  stageCtx.restore();
669
735
  }
670
736
 
671
- // 5. Blur buffer for frosted glass
737
+ stageCtx.restore();
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
+
765
+ // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
672
766
  if (neededBlur > 0) {
673
767
  lastFrostedRadius = neededBlur;
674
- blurCtx.clearRect(0, 0, vw, vh);
675
- blurCtx.filter = `blur(${neededBlur}px) saturate(135%)`;
676
- blurCtx.drawImage(stageCanvas, 0, 0);
677
- blurCtx.filter = 'none';
768
+ blurCtx.clearRect(0, 0, bw, bh);
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));
779
+ blurCtx.imageSmoothingEnabled = true;
780
+ blurCtx.imageSmoothingQuality = 'high';
781
+ blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
782
+ blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
783
+ }
678
784
  }
679
785
  }
680
786
 
@@ -682,17 +788,19 @@ function captureViewportStage(neededBlur) {
682
788
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
683
789
  */
684
790
  function renderIsolatedController(ctrl, rect) {
685
- const w = Math.max(16, Math.round(rect.width));
686
- const h = Math.max(16, Math.round(rect.height));
791
+ const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
792
+ const w = Math.max(16, Math.round(rect.width * dpr));
793
+ const h = Math.max(16, Math.round(rect.height * dpr));
687
794
  const rad = ctrl._getRadius();
795
+ const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
688
796
 
689
797
  if (
690
798
  !ctrl._lut ||
691
799
  ctrl._lut.w !== w ||
692
800
  ctrl._lut.h !== h ||
693
- Math.abs(ctrl._lut.rad - Math.min(rad, w * 0.5, h * 0.5)) > 1.0
801
+ Math.abs(ctrl._lut.rad - radPx) > 1.0
694
802
  ) {
695
- ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, 1);
803
+ ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
696
804
  }
697
805
 
698
806
  const lut = ctrl._lut;
@@ -701,9 +809,9 @@ function renderIsolatedController(ctrl, rect) {
701
809
  ctrl._canvas.height = h;
702
810
  }
703
811
 
704
- // Use exact un-clamped element position so dragging near/past mobile viewport edges never shifts the image
705
- const dx = Math.round(rect.left);
706
- const dy = Math.round(rect.top);
812
+ // Use exact un-clamped element position in physical canvas pixels
813
+ const dx = Math.round(rect.left * dpr);
814
+ const dy = Math.round(rect.top * dpr);
707
815
 
708
816
  const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
709
817
  const srcImg = sourceCtx.getImageData(dx, dy, w, h);
@@ -773,7 +881,7 @@ function renderIsolatedController(ctrl, rect) {
773
881
  ctrl._canvas.style.opacity = '1';
774
882
  }
775
883
 
776
- function areRectsTouching(r1, r2, threshold = 42) {
884
+ function areRectsTouching(r1, r2, threshold = 24) {
777
885
  const dx = Math.max(
778
886
  0,
779
887
  Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
@@ -843,7 +951,7 @@ function renderAllActiveLenses() {
843
951
  if (!visible[i].ctrl.options.fusion) continue;
844
952
  for (let j = i + 1; j < n; j++) {
845
953
  if (!visible[j].ctrl.options.fusion) continue;
846
- if (areRectsTouching(visible[i].rect, visible[j].rect, 54)) {
954
+ if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
847
955
  union(i, j);
848
956
  }
849
957
  }
@@ -861,21 +969,13 @@ function renderAllActiveLenses() {
861
969
  for (const group of clusters.values()) {
862
970
  if (group.length === 1) {
863
971
  const { ctrl, rect } = group[0];
864
- if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
865
- ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
866
- }
867
972
  if (rect.bottom > 1 && rect.top < vh) {
868
973
  renderIsolatedController(ctrl, rect);
869
974
  }
870
975
  } else {
871
976
  hasFusedCluster = true;
872
- let anyHasShadow = false;
873
977
  for (const item of group) {
874
978
  item.ctrl._canvas.style.opacity = '0';
875
- if (item.ctrl._savedShadow) {
876
- anyHasShadow = true;
877
- item.ctrl.element.style.boxShadow = 'none';
878
- }
879
979
  }
880
980
  const primaryCtrl = group[group.length - 1].ctrl;
881
981
  const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
@@ -886,28 +986,40 @@ function renderAllActiveLenses() {
886
986
  h: rect.height,
887
987
  borderRadius: ctrl._getRadius(),
888
988
  fusion: true,
889
- tint: ctrl._tint
989
+ tint: ctrl._tint,
990
+ dpr: ctrl.options.dpr
890
991
  }));
891
992
 
993
+ const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
994
+ const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
892
995
  const box = renderFusedCluster(
893
996
  clusterOverlayCtx,
894
997
  sourceCtx,
895
998
  elements,
896
999
  {
897
1000
  ...primaryCtrl.options,
898
- fusionDistance: 64
1001
+ fusionDistance: 24
899
1002
  },
900
- 1
1003
+ dpr
901
1004
  );
902
1005
 
903
1006
  if (box && clusterOverlayCanvas) {
904
1007
  clusterOverlayCanvas.style.display = 'block';
905
- clusterOverlayCanvas.style.filter = anyHasShadow
906
- ? 'drop-shadow(3px 14px 18px rgba(0, 0, 0, 0.16))'
907
- : 'none';
908
- clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX}px, ${box.cMinY}px, 0)`;
909
- clusterOverlayCanvas.style.width = `${box.boxW}px`;
910
- clusterOverlayCanvas.style.height = `${box.boxH}px`;
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
+
1020
+ clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
1021
+ clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
1022
+ clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
911
1023
  }
912
1024
  }
913
1025
  }
@@ -1077,11 +1189,11 @@ const LiquidGlassController = exports.LiquidGlassController = class LiquidGlassC
1077
1189
  changed = true;
1078
1190
  }
1079
1191
 
1080
- for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
1192
+ for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
1081
1193
  if (merged[key] !== this.options[key]) {
1082
1194
  this.options[key] = merged[key];
1083
1195
  changed = true;
1084
- if (key === 'rim' || key === 'ior' || key === 'height') {
1196
+ if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
1085
1197
  lutChanged = true;
1086
1198
  }
1087
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
 
@@ -221,7 +227,7 @@ export function buildOpticalMapURI(width, height, borderRadius, rawOptions = {})
221
227
  */
222
228
  export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
223
229
  const options = normalizeOptions(rawOptions);
224
- const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 44) * dpr : 0;
230
+ const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
225
231
  const rimPx = Math.max(1, options.rim * dpr);
226
232
  const ior = options.ior;
227
233
  const heightPx = options.height * dpr;
@@ -259,19 +265,26 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
259
265
  const dstData = dstImg.data;
260
266
 
261
267
  const distGrid = new Float32Array(boxW * boxH);
262
- const prep = elements.map((b) => ({
263
- cx: (b.x + b.w * 0.5) * dpr,
264
- cy: (b.y + b.h * 0.5) * dpr,
265
- halfW: b.w * 0.5 * dpr,
266
- halfH: b.h * 0.5 * dpr,
267
- r: Math.min((b.borderRadius ?? 0) * dpr, b.w * 0.5 * dpr, b.h * 0.5 * dpr),
268
- isCircle: b.shape === 'circle',
269
- fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
270
- tintR: b.tint ? b.tint[0] : 15,
271
- tintG: b.tint ? b.tint[1] : 23,
272
- tintB: b.tint ? b.tint[2] : 42,
273
- tintA: b.tint ? b.tint[3] : 0.35
274
- }));
268
+ const prep = elements.map((b) => {
269
+ const halfW = b.w * 0.5 * dpr;
270
+ const halfH = b.h * 0.5 * dpr;
271
+ const rad = (b.borderRadius ?? 0) * dpr;
272
+ const r = Math.min(rad, halfW, halfH);
273
+ const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
274
+ return {
275
+ cx: (b.x + b.w * 0.5) * dpr,
276
+ cy: (b.y + b.h * 0.5) * dpr,
277
+ halfW,
278
+ halfH,
279
+ r,
280
+ isCircle,
281
+ fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
282
+ tintR: b.tint ? b.tint[0] : 15,
283
+ tintG: b.tint ? b.tint[1] : 23,
284
+ tintB: b.tint ? b.tint[2] : 42,
285
+ tintA: b.tint ? b.tint[3] : 0.35
286
+ };
287
+ });
275
288
 
276
289
  const primary = prep[0];
277
290
  const tR = primary.tintR;
@@ -279,8 +292,10 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
279
292
  const tB = primary.tintB;
280
293
  const tA = primary.tintA;
281
294
  const invTA = 1.0 - tA;
295
+ const span = fusionSpan;
296
+ const invSpan = span > 0.001 ? 1.0 / span : 0;
282
297
 
283
- // Pass 1: Boundary evaluation across cluster bounding box
298
+ // Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
284
299
  for (let y = 0; y < boxH; y++) {
285
300
  const py = cMinY + y;
286
301
  const row = y * boxW;
@@ -289,22 +304,36 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
289
304
  let dCluster = Infinity;
290
305
  for (let i = 0; i < prep.length; i++) {
291
306
  const b = prep[i];
292
- const dShape = b.isCircle
293
- ? evalCircle(px - b.cx, py - b.cy, b.halfW)
294
- : evalRoundedRect(px - b.cx, py - b.cy, b.halfW, b.halfH, b.r);
307
+ let dShape;
308
+ if (b.isCircle) {
309
+ dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
310
+ } else {
311
+ const qx = Math.abs(px - b.cx) - b.halfW + b.r;
312
+ const qy = Math.abs(py - b.cy) - b.halfH + b.r;
313
+ dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
314
+ }
315
+
295
316
  if (i === 0) {
296
317
  dCluster = dShape;
297
- } else if (b.fusion && fusionSpan > 0) {
298
- dCluster = blendSurfaces(dCluster, dShape, fusionSpan);
299
- } else {
300
- dCluster = Math.min(dCluster, dShape);
318
+ } else if (b.fusion && span > 0.001) {
319
+ const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
320
+ if (diff < span) {
321
+ const pos = diff * invSpan * 255.0;
322
+ const pIdx = Math.min(254, pos | 0);
323
+ const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
324
+ dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
325
+ } else if (dShape < dCluster) {
326
+ dCluster = dShape;
327
+ }
328
+ } else if (dShape < dCluster) {
329
+ dCluster = dShape;
301
330
  }
302
331
  }
303
332
  distGrid[row + x] = dCluster;
304
333
  }
305
334
  }
306
335
 
307
- // Pass 2: O(1) lookup from distGrid + tabular chromatic shift (_T4)
336
+ // Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
308
337
  for (let y = 1; y < boxH - 1; y++) {
309
338
  const row = y * boxW;
310
339
  for (let x = 1; x < boxW - 1; x++) {
@@ -325,8 +354,11 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
325
354
  const innerDist = -d;
326
355
  if (innerDist < rimPx) {
327
356
  const u = innerDist / rimPx;
328
- const gain = sampleTable(_T0, u);
329
- disp = sampleTable(_T4, u);
357
+ const posU = u * 255.0;
358
+ const uIdx = Math.min(254, posU | 0);
359
+ const uFrac = posU - uIdx;
360
+ const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
361
+ disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
330
362
 
331
363
  const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
332
364
  const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
@@ -339,7 +371,8 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
339
371
  offY = Math.round(-gy * shift);
340
372
 
341
373
  if (gy < -0.2) {
342
- sheen = Math.round(sampleTable(_T2, u) * (-gy) * 0.78);
374
+ const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
375
+ sheen = Math.round(sVal * (-gy) * 0.78);
343
376
  }
344
377
  } else {
345
378
  // Fast interior path: zero displacement inside flat region (-d >= rimPx)
@@ -354,7 +387,9 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
354
387
 
355
388
  const absD = Math.abs(d);
356
389
  if (absD < 3.0) {
357
- glint = Math.round(sampleTable(_T1, absD * 0.33) * 0.65);
390
+ const posG = Math.min(254, absD * 0.33 * 255.0);
391
+ const gIdx = posG | 0;
392
+ glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
358
393
  }
359
394
 
360
395
  const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
@@ -448,11 +483,27 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
448
483
  return fallback;
449
484
  }
450
485
 
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);
497
+ }
498
+
451
499
  function ensureStageBuffers() {
452
500
  if (typeof document === 'undefined') return false;
501
+ const dpr = getDevicePixelRatio();
453
502
  const docEl = document.documentElement;
454
503
  const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
455
504
  const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
505
+ const bufW = Math.round(vw * dpr);
506
+ const bufH = Math.round(vh * dpr);
456
507
 
457
508
  if (!stageCanvas) {
458
509
  stageCanvas = document.createElement('canvas');
@@ -461,11 +512,11 @@ function ensureStageBuffers() {
461
512
  blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
462
513
  }
463
514
 
464
- if (stageCanvas.width !== vw || stageCanvas.height !== vh) {
465
- stageCanvas.width = vw;
466
- stageCanvas.height = vh;
467
- blurCanvas.width = vw;
468
- blurCanvas.height = vh;
515
+ if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
516
+ stageCanvas.width = bufW;
517
+ stageCanvas.height = bufH;
518
+ blurCanvas.width = bufW;
519
+ blurCanvas.height = bufH;
469
520
  }
470
521
 
471
522
  if (!clusterOverlayCanvas) {
@@ -492,14 +543,20 @@ function ensureStageBuffers() {
492
543
  */
493
544
  function captureViewportStage(neededBlur) {
494
545
  if (!ensureStageBuffers()) return;
495
- const vw = stageCanvas.width;
496
- const vh = stageCanvas.height;
546
+ const dpr = getDevicePixelRatio();
547
+ const bw = stageCanvas.width;
548
+ const bh = stageCanvas.height;
549
+ const vw = Math.round(bw / dpr);
550
+ const vh = Math.round(bh / dpr);
497
551
 
498
- // 1. Base page background
499
- stageCtx.fillStyle = '#040814';
500
- stageCtx.fillRect(0, 0, vw, vh);
552
+ stageCtx.save();
501
553
 
502
- // 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
554
+ // 1. Base page background (detect light/dark body background)
555
+ const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
556
+ stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
557
+ stageCtx.fillRect(0, 0, bw, bh);
558
+
559
+ // 2. Copy any background 2D canvas using physical buffer pixels
503
560
  const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
504
561
  for (let i = 0; i < bgCanvases.length; i++) {
505
562
  const c = bgCanvases[i];
@@ -507,7 +564,13 @@ function captureViewportStage(neededBlur) {
507
564
  try {
508
565
  const cr = c.getBoundingClientRect();
509
566
  if (cr.width > 0 && cr.height > 0) {
510
- stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
567
+ stageCtx.drawImage(
568
+ c,
569
+ Math.round(cr.left * dpr),
570
+ Math.round(cr.top * dpr),
571
+ Math.round(cr.width * dpr),
572
+ Math.round(cr.height * dpr)
573
+ );
511
574
  }
512
575
  } catch {
513
576
  // Ignore cross-origin canvas if any
@@ -515,6 +578,9 @@ function captureViewportStage(neededBlur) {
515
578
  }
516
579
  }
517
580
 
581
+ // Scale context for DOM elements rendered in CSS pixels
582
+ stageCtx.scale(dpr, dpr);
583
+
518
584
  // 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
519
585
  const surfaceEls = document.querySelectorAll(
520
586
  'main div.rounded-2xl, main div.rounded-xl, main a.rounded-2xl, main a.rounded-xl, main button, main a.inline-flex, main input, main textarea, main span.rounded-lg, main span.rounded-full'
@@ -668,13 +734,53 @@ function captureViewportStage(neededBlur) {
668
734
  stageCtx.restore();
669
735
  }
670
736
 
671
- // 5. Blur buffer for frosted glass
737
+ stageCtx.restore();
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
+
765
+ // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
672
766
  if (neededBlur > 0) {
673
767
  lastFrostedRadius = neededBlur;
674
- blurCtx.clearRect(0, 0, vw, vh);
675
- blurCtx.filter = `blur(${neededBlur}px) saturate(135%)`;
676
- blurCtx.drawImage(stageCanvas, 0, 0);
677
- blurCtx.filter = 'none';
768
+ blurCtx.clearRect(0, 0, bw, bh);
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));
779
+ blurCtx.imageSmoothingEnabled = true;
780
+ blurCtx.imageSmoothingQuality = 'high';
781
+ blurCtx.drawImage(stageCanvas, 0, 0, pw, ph);
782
+ blurCtx.drawImage(blurCanvas, 0, 0, pw, ph, 0, 0, bw, bh);
783
+ }
678
784
  }
679
785
  }
680
786
 
@@ -682,17 +788,19 @@ function captureViewportStage(neededBlur) {
682
788
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
683
789
  */
684
790
  function renderIsolatedController(ctrl, rect) {
685
- const w = Math.max(16, Math.round(rect.width));
686
- const h = Math.max(16, Math.round(rect.height));
791
+ const dpr = getDevicePixelRatio(ctrl.options.dpr, rect.width);
792
+ const w = Math.max(16, Math.round(rect.width * dpr));
793
+ const h = Math.max(16, Math.round(rect.height * dpr));
687
794
  const rad = ctrl._getRadius();
795
+ const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
688
796
 
689
797
  if (
690
798
  !ctrl._lut ||
691
799
  ctrl._lut.w !== w ||
692
800
  ctrl._lut.h !== h ||
693
- Math.abs(ctrl._lut.rad - Math.min(rad, w * 0.5, h * 0.5)) > 1.0
801
+ Math.abs(ctrl._lut.rad - radPx) > 1.0
694
802
  ) {
695
- ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, 1);
803
+ ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
696
804
  }
697
805
 
698
806
  const lut = ctrl._lut;
@@ -701,9 +809,9 @@ function renderIsolatedController(ctrl, rect) {
701
809
  ctrl._canvas.height = h;
702
810
  }
703
811
 
704
- // Use exact un-clamped element position so dragging near/past mobile viewport edges never shifts the image
705
- const dx = Math.round(rect.left);
706
- const dy = Math.round(rect.top);
812
+ // Use exact un-clamped element position in physical canvas pixels
813
+ const dx = Math.round(rect.left * dpr);
814
+ const dy = Math.round(rect.top * dpr);
707
815
 
708
816
  const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
709
817
  const srcImg = sourceCtx.getImageData(dx, dy, w, h);
@@ -773,7 +881,7 @@ function renderIsolatedController(ctrl, rect) {
773
881
  ctrl._canvas.style.opacity = '1';
774
882
  }
775
883
 
776
- function areRectsTouching(r1, r2, threshold = 42) {
884
+ function areRectsTouching(r1, r2, threshold = 24) {
777
885
  const dx = Math.max(
778
886
  0,
779
887
  Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
@@ -843,7 +951,7 @@ function renderAllActiveLenses() {
843
951
  if (!visible[i].ctrl.options.fusion) continue;
844
952
  for (let j = i + 1; j < n; j++) {
845
953
  if (!visible[j].ctrl.options.fusion) continue;
846
- if (areRectsTouching(visible[i].rect, visible[j].rect, 54)) {
954
+ if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
847
955
  union(i, j);
848
956
  }
849
957
  }
@@ -861,21 +969,13 @@ function renderAllActiveLenses() {
861
969
  for (const group of clusters.values()) {
862
970
  if (group.length === 1) {
863
971
  const { ctrl, rect } = group[0];
864
- if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
865
- ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
866
- }
867
972
  if (rect.bottom > 1 && rect.top < vh) {
868
973
  renderIsolatedController(ctrl, rect);
869
974
  }
870
975
  } else {
871
976
  hasFusedCluster = true;
872
- let anyHasShadow = false;
873
977
  for (const item of group) {
874
978
  item.ctrl._canvas.style.opacity = '0';
875
- if (item.ctrl._savedShadow) {
876
- anyHasShadow = true;
877
- item.ctrl.element.style.boxShadow = 'none';
878
- }
879
979
  }
880
980
  const primaryCtrl = group[group.length - 1].ctrl;
881
981
  const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
@@ -886,28 +986,40 @@ function renderAllActiveLenses() {
886
986
  h: rect.height,
887
987
  borderRadius: ctrl._getRadius(),
888
988
  fusion: true,
889
- tint: ctrl._tint
989
+ tint: ctrl._tint,
990
+ dpr: ctrl.options.dpr
890
991
  }));
891
992
 
993
+ const maxClusterWidth = Math.max(...group.map(g => g.rect.width));
994
+ const dpr = getDevicePixelRatio(primaryCtrl.options.dpr, maxClusterWidth);
892
995
  const box = renderFusedCluster(
893
996
  clusterOverlayCtx,
894
997
  sourceCtx,
895
998
  elements,
896
999
  {
897
1000
  ...primaryCtrl.options,
898
- fusionDistance: 64
1001
+ fusionDistance: 24
899
1002
  },
900
- 1
1003
+ dpr
901
1004
  );
902
1005
 
903
1006
  if (box && clusterOverlayCanvas) {
904
1007
  clusterOverlayCanvas.style.display = 'block';
905
- clusterOverlayCanvas.style.filter = anyHasShadow
906
- ? 'drop-shadow(3px 14px 18px rgba(0, 0, 0, 0.16))'
907
- : 'none';
908
- clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX}px, ${box.cMinY}px, 0)`;
909
- clusterOverlayCanvas.style.width = `${box.boxW}px`;
910
- clusterOverlayCanvas.style.height = `${box.boxH}px`;
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
+
1020
+ clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
1021
+ clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
1022
+ clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
911
1023
  }
912
1024
  }
913
1025
  }
@@ -1077,11 +1189,11 @@ export class LiquidGlassController {
1077
1189
  changed = true;
1078
1190
  }
1079
1191
 
1080
- for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
1192
+ for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion', 'dpr']) {
1081
1193
  if (merged[key] !== this.options[key]) {
1082
1194
  this.options[key] = merged[key];
1083
1195
  changed = true;
1084
- if (key === 'rim' || key === 'ior' || key === 'height') {
1196
+ if (key === 'rim' || key === 'ior' || key === 'height' || key === 'dpr') {
1085
1197
  lutChanged = true;
1086
1198
  }
1087
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.3",
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>;