@shulya/liquid-glass 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@shulya/liquid-glass`
2
2
 
3
- [![npm version](https://img.shields.io/badge/npm-v1.0.1-007aff?style=flat-square)](https://www.npmjs.com/package/@shulya/liquid-glass)
3
+ [![npm version](https://img.shields.io/npm/v/@shulya/liquid-glass?style=flat-square&color=007aff)](https://www.npmjs.com/package/@shulya/liquid-glass)
4
4
  [![Live Demo](https://img.shields.io/badge/Live%20Demo-liquid.shulya.tech-ff2d55?style=flat-square)](https://liquid.shulya.tech)
5
5
  [![GitHub Repo](https://img.shields.io/badge/GitHub-shulya--tech%2Fliquid--glass-181717?style=flat-square&logo=github)](https://github.com/shulya-tech/liquid-glass)
6
6
  [![Zero WebGL](<https://img.shields.io/badge/WebGL-None%20(100%25%20DOM%20%2F%202D%20LUT)-30d158?style=flat-square>)](#why-shulyaliquid-glass)
package/dist/core.cjs CHANGED
@@ -221,7 +221,7 @@ exports.buildOpticalMapURI = function buildOpticalMapURI(width, height, borderRa
221
221
  */
222
222
  exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
223
223
  const options = normalizeOptions(rawOptions);
224
- const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 44) * dpr : 0;
224
+ const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
225
225
  const rimPx = Math.max(1, options.rim * dpr);
226
226
  const ior = options.ior;
227
227
  const heightPx = options.height * dpr;
@@ -259,19 +259,26 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
259
259
  const dstData = dstImg.data;
260
260
 
261
261
  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
- }));
262
+ const prep = elements.map((b) => {
263
+ const halfW = b.w * 0.5 * dpr;
264
+ const halfH = b.h * 0.5 * dpr;
265
+ const rad = (b.borderRadius ?? 0) * dpr;
266
+ const r = Math.min(rad, halfW, halfH);
267
+ const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
268
+ return {
269
+ cx: (b.x + b.w * 0.5) * dpr,
270
+ cy: (b.y + b.h * 0.5) * dpr,
271
+ halfW,
272
+ halfH,
273
+ r,
274
+ isCircle,
275
+ fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
276
+ tintR: b.tint ? b.tint[0] : 15,
277
+ tintG: b.tint ? b.tint[1] : 23,
278
+ tintB: b.tint ? b.tint[2] : 42,
279
+ tintA: b.tint ? b.tint[3] : 0.35
280
+ };
281
+ });
275
282
 
276
283
  const primary = prep[0];
277
284
  const tR = primary.tintR;
@@ -279,8 +286,10 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
279
286
  const tB = primary.tintB;
280
287
  const tA = primary.tintA;
281
288
  const invTA = 1.0 - tA;
289
+ const span = fusionSpan;
290
+ const invSpan = span > 0.001 ? 1.0 / span : 0;
282
291
 
283
- // Pass 1: Boundary evaluation across cluster bounding box
292
+ // Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
284
293
  for (let y = 0; y < boxH; y++) {
285
294
  const py = cMinY + y;
286
295
  const row = y * boxW;
@@ -289,22 +298,36 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
289
298
  let dCluster = Infinity;
290
299
  for (let i = 0; i < prep.length; i++) {
291
300
  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);
301
+ let dShape;
302
+ if (b.isCircle) {
303
+ dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
304
+ } else {
305
+ const qx = Math.abs(px - b.cx) - b.halfW + b.r;
306
+ const qy = Math.abs(py - b.cy) - b.halfH + b.r;
307
+ dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
308
+ }
309
+
295
310
  if (i === 0) {
296
311
  dCluster = dShape;
297
- } else if (b.fusion && fusionSpan > 0) {
298
- dCluster = blendSurfaces(dCluster, dShape, fusionSpan);
299
- } else {
300
- dCluster = Math.min(dCluster, dShape);
312
+ } else if (b.fusion && span > 0.001) {
313
+ const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
314
+ if (diff < span) {
315
+ const pos = diff * invSpan * 255.0;
316
+ const pIdx = Math.min(254, pos | 0);
317
+ const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
318
+ dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
319
+ } else if (dShape < dCluster) {
320
+ dCluster = dShape;
321
+ }
322
+ } else if (dShape < dCluster) {
323
+ dCluster = dShape;
301
324
  }
302
325
  }
303
326
  distGrid[row + x] = dCluster;
304
327
  }
305
328
  }
306
329
 
307
- // Pass 2: O(1) lookup from distGrid + tabular chromatic shift (_T4)
330
+ // Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
308
331
  for (let y = 1; y < boxH - 1; y++) {
309
332
  const row = y * boxW;
310
333
  for (let x = 1; x < boxW - 1; x++) {
@@ -325,8 +348,11 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
325
348
  const innerDist = -d;
326
349
  if (innerDist < rimPx) {
327
350
  const u = innerDist / rimPx;
328
- const gain = sampleTable(_T0, u);
329
- disp = sampleTable(_T4, u);
351
+ const posU = u * 255.0;
352
+ const uIdx = Math.min(254, posU | 0);
353
+ const uFrac = posU - uIdx;
354
+ const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
355
+ disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
330
356
 
331
357
  const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
332
358
  const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
@@ -339,7 +365,8 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
339
365
  offY = Math.round(-gy * shift);
340
366
 
341
367
  if (gy < -0.2) {
342
- sheen = Math.round(sampleTable(_T2, u) * (-gy) * 0.78);
368
+ const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
369
+ sheen = Math.round(sVal * (-gy) * 0.78);
343
370
  }
344
371
  } else {
345
372
  // Fast interior path: zero displacement inside flat region (-d >= rimPx)
@@ -354,7 +381,9 @@ exports.renderFusedCluster = function renderFusedCluster(ctx, sourceCtx, element
354
381
 
355
382
  const absD = Math.abs(d);
356
383
  if (absD < 3.0) {
357
- glint = Math.round(sampleTable(_T1, absD * 0.33) * 0.65);
384
+ const posG = Math.min(254, absD * 0.33 * 255.0);
385
+ const gIdx = posG | 0;
386
+ glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
358
387
  }
359
388
 
360
389
  const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
@@ -448,11 +477,18 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
448
477
  return fallback;
449
478
  }
450
479
 
480
+ function getDevicePixelRatio() {
481
+ return typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 1.5) : 1;
482
+ }
483
+
451
484
  function ensureStageBuffers() {
452
485
  if (typeof document === 'undefined') return false;
486
+ const dpr = getDevicePixelRatio();
453
487
  const docEl = document.documentElement;
454
488
  const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
455
489
  const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
490
+ const bufW = Math.round(vw * dpr);
491
+ const bufH = Math.round(vh * dpr);
456
492
 
457
493
  if (!stageCanvas) {
458
494
  stageCanvas = document.createElement('canvas');
@@ -461,11 +497,11 @@ function ensureStageBuffers() {
461
497
  blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
462
498
  }
463
499
 
464
- if (stageCanvas.width !== vw || stageCanvas.height !== vh) {
465
- stageCanvas.width = vw;
466
- stageCanvas.height = vh;
467
- blurCanvas.width = vw;
468
- blurCanvas.height = vh;
500
+ if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
501
+ stageCanvas.width = bufW;
502
+ stageCanvas.height = bufH;
503
+ blurCanvas.width = bufW;
504
+ blurCanvas.height = bufH;
469
505
  }
470
506
 
471
507
  if (!clusterOverlayCanvas) {
@@ -492,14 +528,20 @@ function ensureStageBuffers() {
492
528
  */
493
529
  function captureViewportStage(neededBlur) {
494
530
  if (!ensureStageBuffers()) return;
495
- const vw = stageCanvas.width;
496
- const vh = stageCanvas.height;
531
+ const dpr = getDevicePixelRatio();
532
+ const bw = stageCanvas.width;
533
+ const bh = stageCanvas.height;
534
+ const vw = Math.round(bw / dpr);
535
+ const vh = Math.round(bh / dpr);
497
536
 
498
- // 1. Base page background
499
- stageCtx.fillStyle = '#040814';
500
- stageCtx.fillRect(0, 0, vw, vh);
537
+ stageCtx.save();
501
538
 
502
- // 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
539
+ // 1. Base page background (detect light/dark body background)
540
+ const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
541
+ stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
542
+ stageCtx.fillRect(0, 0, bw, bh);
543
+
544
+ // 2. Copy any background 2D canvas using physical buffer pixels
503
545
  const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
504
546
  for (let i = 0; i < bgCanvases.length; i++) {
505
547
  const c = bgCanvases[i];
@@ -507,7 +549,13 @@ function captureViewportStage(neededBlur) {
507
549
  try {
508
550
  const cr = c.getBoundingClientRect();
509
551
  if (cr.width > 0 && cr.height > 0) {
510
- stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
552
+ stageCtx.drawImage(
553
+ c,
554
+ Math.round(cr.left * dpr),
555
+ Math.round(cr.top * dpr),
556
+ Math.round(cr.width * dpr),
557
+ Math.round(cr.height * dpr)
558
+ );
511
559
  }
512
560
  } catch {
513
561
  // Ignore cross-origin canvas if any
@@ -515,6 +563,9 @@ function captureViewportStage(neededBlur) {
515
563
  }
516
564
  }
517
565
 
566
+ // Scale context for DOM elements rendered in CSS pixels
567
+ stageCtx.scale(dpr, dpr);
568
+
518
569
  // 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
519
570
  const surfaceEls = document.querySelectorAll(
520
571
  '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 +719,28 @@ function captureViewportStage(neededBlur) {
668
719
  stageCtx.restore();
669
720
  }
670
721
 
671
- // 5. Blur buffer for frosted glass
722
+ stageCtx.restore();
723
+
724
+ // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
672
725
  if (neededBlur > 0) {
673
726
  lastFrostedRadius = neededBlur;
674
- blurCtx.clearRect(0, 0, vw, vh);
675
- blurCtx.filter = `blur(${neededBlur}px) saturate(135%)`;
727
+ blurCtx.clearRect(0, 0, bw, bh);
728
+ const filterStr = `blur(${Math.round(neededBlur * dpr)}px)`;
729
+ blurCtx.filter = filterStr;
676
730
  blurCtx.drawImage(stageCanvas, 0, 0);
677
- blurCtx.filter = 'none';
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));
737
+ blurCtx.imageSmoothingEnabled = true;
738
+ 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';
743
+ }
678
744
  }
679
745
  }
680
746
 
@@ -682,17 +748,19 @@ function captureViewportStage(neededBlur) {
682
748
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
683
749
  */
684
750
  function renderIsolatedController(ctrl, rect) {
685
- const w = Math.max(16, Math.round(rect.width));
686
- const h = Math.max(16, Math.round(rect.height));
751
+ const dpr = getDevicePixelRatio();
752
+ const w = Math.max(16, Math.round(rect.width * dpr));
753
+ const h = Math.max(16, Math.round(rect.height * dpr));
687
754
  const rad = ctrl._getRadius();
755
+ const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
688
756
 
689
757
  if (
690
758
  !ctrl._lut ||
691
759
  ctrl._lut.w !== w ||
692
760
  ctrl._lut.h !== h ||
693
- Math.abs(ctrl._lut.rad - Math.min(rad, w * 0.5, h * 0.5)) > 1.0
761
+ Math.abs(ctrl._lut.rad - radPx) > 1.0
694
762
  ) {
695
- ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, 1);
763
+ ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
696
764
  }
697
765
 
698
766
  const lut = ctrl._lut;
@@ -701,9 +769,9 @@ function renderIsolatedController(ctrl, rect) {
701
769
  ctrl._canvas.height = h;
702
770
  }
703
771
 
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);
772
+ // Use exact un-clamped element position in physical canvas pixels
773
+ const dx = Math.round(rect.left * dpr);
774
+ const dy = Math.round(rect.top * dpr);
707
775
 
708
776
  const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
709
777
  const srcImg = sourceCtx.getImageData(dx, dy, w, h);
@@ -773,7 +841,7 @@ function renderIsolatedController(ctrl, rect) {
773
841
  ctrl._canvas.style.opacity = '1';
774
842
  }
775
843
 
776
- function areRectsTouching(r1, r2, threshold = 42) {
844
+ function areRectsTouching(r1, r2, threshold = 24) {
777
845
  const dx = Math.max(
778
846
  0,
779
847
  Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
@@ -843,7 +911,7 @@ function renderAllActiveLenses() {
843
911
  if (!visible[i].ctrl.options.fusion) continue;
844
912
  for (let j = i + 1; j < n; j++) {
845
913
  if (!visible[j].ctrl.options.fusion) continue;
846
- if (areRectsTouching(visible[i].rect, visible[j].rect, 54)) {
914
+ if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
847
915
  union(i, j);
848
916
  }
849
917
  }
@@ -861,21 +929,13 @@ function renderAllActiveLenses() {
861
929
  for (const group of clusters.values()) {
862
930
  if (group.length === 1) {
863
931
  const { ctrl, rect } = group[0];
864
- if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
865
- ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
866
- }
867
932
  if (rect.bottom > 1 && rect.top < vh) {
868
933
  renderIsolatedController(ctrl, rect);
869
934
  }
870
935
  } else {
871
936
  hasFusedCluster = true;
872
- let anyHasShadow = false;
873
937
  for (const item of group) {
874
938
  item.ctrl._canvas.style.opacity = '0';
875
- if (item.ctrl._savedShadow) {
876
- anyHasShadow = true;
877
- item.ctrl.element.style.boxShadow = 'none';
878
- }
879
939
  }
880
940
  const primaryCtrl = group[group.length - 1].ctrl;
881
941
  const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
@@ -889,25 +949,24 @@ function renderAllActiveLenses() {
889
949
  tint: ctrl._tint
890
950
  }));
891
951
 
952
+ const dpr = getDevicePixelRatio();
892
953
  const box = renderFusedCluster(
893
954
  clusterOverlayCtx,
894
955
  sourceCtx,
895
956
  elements,
896
957
  {
897
958
  ...primaryCtrl.options,
898
- fusionDistance: 64
959
+ fusionDistance: 24
899
960
  },
900
- 1
961
+ dpr
901
962
  );
902
963
 
903
964
  if (box && clusterOverlayCanvas) {
904
965
  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`;
966
+ clusterOverlayCanvas.style.filter = 'none';
967
+ clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
968
+ clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
969
+ clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
911
970
  }
912
971
  }
913
972
  }
package/dist/core.mjs CHANGED
@@ -221,7 +221,7 @@ export function buildOpticalMapURI(width, height, borderRadius, rawOptions = {})
221
221
  */
222
222
  export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
223
223
  const options = normalizeOptions(rawOptions);
224
- const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 44) * dpr : 0;
224
+ const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 24) * dpr : 0;
225
225
  const rimPx = Math.max(1, options.rim * dpr);
226
226
  const ior = options.ior;
227
227
  const heightPx = options.height * dpr;
@@ -259,19 +259,26 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
259
259
  const dstData = dstImg.data;
260
260
 
261
261
  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
- }));
262
+ const prep = elements.map((b) => {
263
+ const halfW = b.w * 0.5 * dpr;
264
+ const halfH = b.h * 0.5 * dpr;
265
+ const rad = (b.borderRadius ?? 0) * dpr;
266
+ const r = Math.min(rad, halfW, halfH);
267
+ const isCircle = b.shape === 'circle' || (Math.abs(halfW - halfH) < 1.0 && Math.abs(r - halfW) < 1.0);
268
+ return {
269
+ cx: (b.x + b.w * 0.5) * dpr,
270
+ cy: (b.y + b.h * 0.5) * dpr,
271
+ halfW,
272
+ halfH,
273
+ r,
274
+ isCircle,
275
+ fusion: b.fusion !== undefined ? Boolean(b.fusion) : options.fusion,
276
+ tintR: b.tint ? b.tint[0] : 15,
277
+ tintG: b.tint ? b.tint[1] : 23,
278
+ tintB: b.tint ? b.tint[2] : 42,
279
+ tintA: b.tint ? b.tint[3] : 0.35
280
+ };
281
+ });
275
282
 
276
283
  const primary = prep[0];
277
284
  const tR = primary.tintR;
@@ -279,8 +286,10 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
279
286
  const tB = primary.tintB;
280
287
  const tA = primary.tintA;
281
288
  const invTA = 1.0 - tA;
289
+ const span = fusionSpan;
290
+ const invSpan = span > 0.001 ? 1.0 / span : 0;
282
291
 
283
- // Pass 1: Boundary evaluation across cluster bounding box
292
+ // Pass 1: Boundary evaluation across cluster bounding box (Inlined fast LUT math)
284
293
  for (let y = 0; y < boxH; y++) {
285
294
  const py = cMinY + y;
286
295
  const row = y * boxW;
@@ -289,22 +298,36 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
289
298
  let dCluster = Infinity;
290
299
  for (let i = 0; i < prep.length; i++) {
291
300
  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);
301
+ let dShape;
302
+ if (b.isCircle) {
303
+ dShape = Math.hypot(px - b.cx, py - b.cy) - b.halfW;
304
+ } else {
305
+ const qx = Math.abs(px - b.cx) - b.halfW + b.r;
306
+ const qy = Math.abs(py - b.cy) - b.halfH + b.r;
307
+ dShape = Math.min(Math.max(qx, qy), 0.0) + Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) - b.r;
308
+ }
309
+
295
310
  if (i === 0) {
296
311
  dCluster = dShape;
297
- } else if (b.fusion && fusionSpan > 0) {
298
- dCluster = blendSurfaces(dCluster, dShape, fusionSpan);
299
- } else {
300
- dCluster = Math.min(dCluster, dShape);
312
+ } else if (b.fusion && span > 0.001) {
313
+ const diff = dCluster > dShape ? dCluster - dShape : dShape - dCluster;
314
+ if (diff < span) {
315
+ const pos = diff * invSpan * 255.0;
316
+ const pIdx = Math.min(254, pos | 0);
317
+ const wWeight = _T3[pIdx] + (_T3[pIdx + 1] - _T3[pIdx]) * (pos - pIdx);
318
+ dCluster = (dCluster < dShape ? dCluster : dShape) - wWeight * span;
319
+ } else if (dShape < dCluster) {
320
+ dCluster = dShape;
321
+ }
322
+ } else if (dShape < dCluster) {
323
+ dCluster = dShape;
301
324
  }
302
325
  }
303
326
  distGrid[row + x] = dCluster;
304
327
  }
305
328
  }
306
329
 
307
- // Pass 2: O(1) lookup from distGrid + tabular chromatic shift (_T4)
330
+ // Pass 2: Fast vectorized lookup from distGrid + tabular chromatic shift (_T4)
308
331
  for (let y = 1; y < boxH - 1; y++) {
309
332
  const row = y * boxW;
310
333
  for (let x = 1; x < boxW - 1; x++) {
@@ -325,8 +348,11 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
325
348
  const innerDist = -d;
326
349
  if (innerDist < rimPx) {
327
350
  const u = innerDist / rimPx;
328
- const gain = sampleTable(_T0, u);
329
- disp = sampleTable(_T4, u);
351
+ const posU = u * 255.0;
352
+ const uIdx = Math.min(254, posU | 0);
353
+ const uFrac = posU - uIdx;
354
+ const gain = _T0[uIdx] + (_T0[uIdx + 1] - _T0[uIdx]) * uFrac;
355
+ disp = _T4[uIdx] + (_T4[uIdx + 1] - _T4[uIdx]) * uFrac;
330
356
 
331
357
  const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
332
358
  const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
@@ -339,7 +365,8 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
339
365
  offY = Math.round(-gy * shift);
340
366
 
341
367
  if (gy < -0.2) {
342
- sheen = Math.round(sampleTable(_T2, u) * (-gy) * 0.78);
368
+ const sVal = _T2[uIdx] + (_T2[uIdx + 1] - _T2[uIdx]) * uFrac;
369
+ sheen = Math.round(sVal * (-gy) * 0.78);
343
370
  }
344
371
  } else {
345
372
  // Fast interior path: zero displacement inside flat region (-d >= rimPx)
@@ -354,7 +381,9 @@ export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dp
354
381
 
355
382
  const absD = Math.abs(d);
356
383
  if (absD < 3.0) {
357
- glint = Math.round(sampleTable(_T1, absD * 0.33) * 0.65);
384
+ const posG = Math.min(254, absD * 0.33 * 255.0);
385
+ const gIdx = posG | 0;
386
+ glint = Math.round((_T1[gIdx] + (_T1[gIdx + 1] - _T1[gIdx]) * (posG - gIdx)) * 0.65);
358
387
  }
359
388
 
360
389
  const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
@@ -448,11 +477,18 @@ function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)
448
477
  return fallback;
449
478
  }
450
479
 
480
+ function getDevicePixelRatio() {
481
+ return typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 1.5) : 1;
482
+ }
483
+
451
484
  function ensureStageBuffers() {
452
485
  if (typeof document === 'undefined') return false;
486
+ const dpr = getDevicePixelRatio();
453
487
  const docEl = document.documentElement;
454
488
  const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
455
489
  const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
490
+ const bufW = Math.round(vw * dpr);
491
+ const bufH = Math.round(vh * dpr);
456
492
 
457
493
  if (!stageCanvas) {
458
494
  stageCanvas = document.createElement('canvas');
@@ -461,11 +497,11 @@ function ensureStageBuffers() {
461
497
  blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
462
498
  }
463
499
 
464
- if (stageCanvas.width !== vw || stageCanvas.height !== vh) {
465
- stageCanvas.width = vw;
466
- stageCanvas.height = vh;
467
- blurCanvas.width = vw;
468
- blurCanvas.height = vh;
500
+ if (stageCanvas.width !== bufW || stageCanvas.height !== bufH) {
501
+ stageCanvas.width = bufW;
502
+ stageCanvas.height = bufH;
503
+ blurCanvas.width = bufW;
504
+ blurCanvas.height = bufH;
469
505
  }
470
506
 
471
507
  if (!clusterOverlayCanvas) {
@@ -492,14 +528,20 @@ function ensureStageBuffers() {
492
528
  */
493
529
  function captureViewportStage(neededBlur) {
494
530
  if (!ensureStageBuffers()) return;
495
- const vw = stageCanvas.width;
496
- const vh = stageCanvas.height;
531
+ const dpr = getDevicePixelRatio();
532
+ const bw = stageCanvas.width;
533
+ const bh = stageCanvas.height;
534
+ const vw = Math.round(bw / dpr);
535
+ const vh = Math.round(bh / dpr);
497
536
 
498
- // 1. Base page background
499
- stageCtx.fillStyle = '#040814';
500
- stageCtx.fillRect(0, 0, vw, vh);
537
+ stageCtx.save();
501
538
 
502
- // 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
539
+ // 1. Base page background (detect light/dark body background)
540
+ const bodyBg = typeof document !== 'undefined' ? window.getComputedStyle(document.body).backgroundColor : '';
541
+ stageCtx.fillStyle = bodyBg && bodyBg !== 'transparent' && bodyBg !== 'rgba(0, 0, 0, 0)' ? bodyBg : '#040814';
542
+ stageCtx.fillRect(0, 0, bw, bh);
543
+
544
+ // 2. Copy any background 2D canvas using physical buffer pixels
503
545
  const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
504
546
  for (let i = 0; i < bgCanvases.length; i++) {
505
547
  const c = bgCanvases[i];
@@ -507,7 +549,13 @@ function captureViewportStage(neededBlur) {
507
549
  try {
508
550
  const cr = c.getBoundingClientRect();
509
551
  if (cr.width > 0 && cr.height > 0) {
510
- stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
552
+ stageCtx.drawImage(
553
+ c,
554
+ Math.round(cr.left * dpr),
555
+ Math.round(cr.top * dpr),
556
+ Math.round(cr.width * dpr),
557
+ Math.round(cr.height * dpr)
558
+ );
511
559
  }
512
560
  } catch {
513
561
  // Ignore cross-origin canvas if any
@@ -515,6 +563,9 @@ function captureViewportStage(neededBlur) {
515
563
  }
516
564
  }
517
565
 
566
+ // Scale context for DOM elements rendered in CSS pixels
567
+ stageCtx.scale(dpr, dpr);
568
+
518
569
  // 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
519
570
  const surfaceEls = document.querySelectorAll(
520
571
  '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 +719,28 @@ function captureViewportStage(neededBlur) {
668
719
  stageCtx.restore();
669
720
  }
670
721
 
671
- // 5. Blur buffer for frosted glass
722
+ stageCtx.restore();
723
+
724
+ // 5. Blur buffer for frosted glass (Robust across all mobile WebKit & Blink engines)
672
725
  if (neededBlur > 0) {
673
726
  lastFrostedRadius = neededBlur;
674
- blurCtx.clearRect(0, 0, vw, vh);
675
- blurCtx.filter = `blur(${neededBlur}px) saturate(135%)`;
727
+ blurCtx.clearRect(0, 0, bw, bh);
728
+ const filterStr = `blur(${Math.round(neededBlur * dpr)}px)`;
729
+ blurCtx.filter = filterStr;
676
730
  blurCtx.drawImage(stageCanvas, 0, 0);
677
- blurCtx.filter = 'none';
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));
737
+ blurCtx.imageSmoothingEnabled = true;
738
+ 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';
743
+ }
678
744
  }
679
745
  }
680
746
 
@@ -682,17 +748,19 @@ function captureViewportStage(neededBlur) {
682
748
  * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
683
749
  */
684
750
  function renderIsolatedController(ctrl, rect) {
685
- const w = Math.max(16, Math.round(rect.width));
686
- const h = Math.max(16, Math.round(rect.height));
751
+ const dpr = getDevicePixelRatio();
752
+ const w = Math.max(16, Math.round(rect.width * dpr));
753
+ const h = Math.max(16, Math.round(rect.height * dpr));
687
754
  const rad = ctrl._getRadius();
755
+ const radPx = Math.min(rad * dpr, w * 0.5, h * 0.5);
688
756
 
689
757
  if (
690
758
  !ctrl._lut ||
691
759
  ctrl._lut.w !== w ||
692
760
  ctrl._lut.h !== h ||
693
- Math.abs(ctrl._lut.rad - Math.min(rad, w * 0.5, h * 0.5)) > 1.0
761
+ Math.abs(ctrl._lut.rad - radPx) > 1.0
694
762
  ) {
695
- ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, 1);
763
+ ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, dpr);
696
764
  }
697
765
 
698
766
  const lut = ctrl._lut;
@@ -701,9 +769,9 @@ function renderIsolatedController(ctrl, rect) {
701
769
  ctrl._canvas.height = h;
702
770
  }
703
771
 
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);
772
+ // Use exact un-clamped element position in physical canvas pixels
773
+ const dx = Math.round(rect.left * dpr);
774
+ const dy = Math.round(rect.top * dpr);
707
775
 
708
776
  const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
709
777
  const srcImg = sourceCtx.getImageData(dx, dy, w, h);
@@ -773,7 +841,7 @@ function renderIsolatedController(ctrl, rect) {
773
841
  ctrl._canvas.style.opacity = '1';
774
842
  }
775
843
 
776
- function areRectsTouching(r1, r2, threshold = 42) {
844
+ function areRectsTouching(r1, r2, threshold = 24) {
777
845
  const dx = Math.max(
778
846
  0,
779
847
  Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
@@ -843,7 +911,7 @@ function renderAllActiveLenses() {
843
911
  if (!visible[i].ctrl.options.fusion) continue;
844
912
  for (let j = i + 1; j < n; j++) {
845
913
  if (!visible[j].ctrl.options.fusion) continue;
846
- if (areRectsTouching(visible[i].rect, visible[j].rect, 54)) {
914
+ if (areRectsTouching(visible[i].rect, visible[j].rect, 24)) {
847
915
  union(i, j);
848
916
  }
849
917
  }
@@ -861,21 +929,13 @@ function renderAllActiveLenses() {
861
929
  for (const group of clusters.values()) {
862
930
  if (group.length === 1) {
863
931
  const { ctrl, rect } = group[0];
864
- if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
865
- ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
866
- }
867
932
  if (rect.bottom > 1 && rect.top < vh) {
868
933
  renderIsolatedController(ctrl, rect);
869
934
  }
870
935
  } else {
871
936
  hasFusedCluster = true;
872
- let anyHasShadow = false;
873
937
  for (const item of group) {
874
938
  item.ctrl._canvas.style.opacity = '0';
875
- if (item.ctrl._savedShadow) {
876
- anyHasShadow = true;
877
- item.ctrl.element.style.boxShadow = 'none';
878
- }
879
939
  }
880
940
  const primaryCtrl = group[group.length - 1].ctrl;
881
941
  const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
@@ -889,25 +949,24 @@ function renderAllActiveLenses() {
889
949
  tint: ctrl._tint
890
950
  }));
891
951
 
952
+ const dpr = getDevicePixelRatio();
892
953
  const box = renderFusedCluster(
893
954
  clusterOverlayCtx,
894
955
  sourceCtx,
895
956
  elements,
896
957
  {
897
958
  ...primaryCtrl.options,
898
- fusionDistance: 64
959
+ fusionDistance: 24
899
960
  },
900
- 1
961
+ dpr
901
962
  );
902
963
 
903
964
  if (box && clusterOverlayCanvas) {
904
965
  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`;
966
+ clusterOverlayCanvas.style.filter = 'none';
967
+ clusterOverlayCanvas.style.transform = `translate3d(${box.cMinX / dpr}px, ${box.cMinY / dpr}px, 0)`;
968
+ clusterOverlayCanvas.style.width = `${box.boxW / dpr}px`;
969
+ clusterOverlayCanvas.style.height = `${box.boxH / dpr}px`;
911
970
  }
912
971
  }
913
972
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shulya/liquid-glass",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",