@shulya/liquid-glass 1.0.1 → 1.0.2

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/src/core.js DELETED
@@ -1,1126 +0,0 @@
1
- /**
2
- * Core Empirical Surface Engine (Pure 2D Canvas + TypedArray LUT & Cluster Fusion)
3
- * Zero WebGL / Zero SVG / Zero PNG — Direct ImageData Optical Compositor
4
- */
5
-
6
- import { _T0, _T1, _T2, _T3, _T4 } from './tables.js';
7
-
8
- /**
9
- * Default parameter values and strict min/max clamping bounds
10
- */
11
- export const OPTICAL_DEFAULTS = Object.freeze({
12
- rim: 16,
13
- ior: 1.46,
14
- height: 18,
15
- frosted: 0,
16
- fusion: true
17
- });
18
-
19
- export const OPTICAL_LIMITS = Object.freeze({
20
- rim: Object.freeze({ min: 4, max: 48 }),
21
- ior: Object.freeze({ min: 1.05, max: 2.20 }),
22
- height: Object.freeze({ min: 2, max: 50 }),
23
- frosted: Object.freeze({ min: 0, max: 32 })
24
- });
25
-
26
- function clampNumber(val, fallback, min, max) {
27
- const num = typeof val === 'number' && !Number.isNaN(val) ? val : fallback;
28
- return Math.max(min, Math.min(max, num));
29
- }
30
-
31
- /**
32
- * Sanitizes and clamps optical configuration parameters to valid physical bounds
33
- */
34
- export function normalizeOptions(options = {}) {
35
- return {
36
- rim: clampNumber(
37
- options.rim,
38
- OPTICAL_DEFAULTS.rim,
39
- OPTICAL_LIMITS.rim.min,
40
- OPTICAL_LIMITS.rim.max
41
- ),
42
- ior: clampNumber(
43
- options.ior,
44
- OPTICAL_DEFAULTS.ior,
45
- OPTICAL_LIMITS.ior.min,
46
- OPTICAL_LIMITS.ior.max
47
- ),
48
- height: clampNumber(
49
- options.height,
50
- OPTICAL_DEFAULTS.height,
51
- OPTICAL_LIMITS.height.min,
52
- OPTICAL_LIMITS.height.max
53
- ),
54
- frosted: clampNumber(
55
- options.frosted,
56
- OPTICAL_DEFAULTS.frosted,
57
- OPTICAL_LIMITS.frosted.min,
58
- OPTICAL_LIMITS.frosted.max
59
- ),
60
- fusion: options.fusion !== undefined ? Boolean(options.fusion) : OPTICAL_DEFAULTS.fusion
61
- };
62
- }
63
-
64
- /**
65
- * Fast linear interpolation from a 256-entry precomputed Float32Array table
66
- * @param {Float32Array} table - 256-entry empirical LUT
67
- * @param {number} u - normalized coordinate in [0, 1]
68
- * @returns {number}
69
- */
70
- export function sampleTable(table, u) {
71
- if (u <= 0) return table[0];
72
- if (u >= 1) return table[255];
73
- const pos = u * 255.0;
74
- const idx = pos | 0;
75
- if (idx >= 255) return table[255];
76
- const frac = pos - idx;
77
- return table[idx] + (table[idx + 1] - table[idx]) * frac;
78
- }
79
-
80
- /**
81
- * Empirical boundary distance for a rounded rectangle
82
- */
83
- export function evalRoundedRect(px, py, halfW, halfH, rad) {
84
- const r = Math.min(rad, halfW, halfH);
85
- const qx = Math.abs(px) - halfW + r;
86
- const qy = Math.abs(py) - halfH + r;
87
- return (
88
- Math.min(Math.max(qx, qy), 0.0) +
89
- Math.hypot(Math.max(qx, 0.0), Math.max(qy, 0.0)) -
90
- r
91
- );
92
- }
93
-
94
- /**
95
- * Empirical boundary distance for a circle
96
- */
97
- export function evalCircle(px, py, r) {
98
- return Math.hypot(px, py) - r;
99
- }
100
-
101
- /**
102
- * Tabular surface coalescence using precomputed _T3 empirical bridge weights
103
- */
104
- export function blendSurfaces(d1, d2, span) {
105
- if (span <= 0.001) return Math.min(d1, d2);
106
- const diff = Math.abs(d1 - d2);
107
- if (diff >= span) return Math.min(d1, d2);
108
- const weight = sampleTable(_T3, diff / span);
109
- return Math.min(d1, d2) - weight * span;
110
- }
111
-
112
- /**
113
- * Precomputes the static per-pixel TypedArray LUT for an isolated lens element
114
- */
115
- export function buildElementLUT(w, h, rad, rawOptions = {}, dpr = 1) {
116
- const options = normalizeOptions(rawOptions);
117
- const total = w * h;
118
- const offsets = new Int16Array(total * 2);
119
- const dispMap = new Float32Array(total);
120
- const glints = new Uint8Array(total);
121
- const alphas = new Uint8Array(total);
122
- const topSheen = new Uint8Array(total);
123
- const rimList = [];
124
-
125
- const halfW = w * 0.5;
126
- const halfH = h * 0.5;
127
- const rimPx = Math.max(1, options.rim * dpr);
128
- const ior = options.ior;
129
- const heightPx = options.height * dpr;
130
- const rPx = Math.min(rad * dpr, halfW, halfH);
131
-
132
- const innerSafeX = Math.max(0, halfW - rPx - rimPx - 2);
133
- const innerSafeY = Math.max(0, halfH - rPx - rimPx - 2);
134
-
135
- for (let y = 0; y < h; y++) {
136
- const py = y - halfH;
137
- const isSafeY = Math.abs(py) < innerSafeY;
138
- const row = y * w;
139
- for (let x = 0; x < w; x++) {
140
- const px = x - halfW;
141
- const idx = row + x;
142
-
143
- if (isSafeY && Math.abs(px) < innerSafeX) {
144
- alphas[idx] = 255;
145
- continue;
146
- }
147
-
148
- const d = evalRoundedRect(px, py, halfW, halfH, rPx);
149
- if (d > 1.0) continue;
150
-
151
- const alpha = Math.max(0, Math.min(1, 0.5 - d));
152
- alphas[idx] = Math.round(alpha * 255);
153
-
154
- if (d < 0) {
155
- const innerDist = -d;
156
- if (innerDist < rimPx) {
157
- const u = innerDist / rimPx;
158
- const gain = sampleTable(_T0, u);
159
- dispMap[idx] = sampleTable(_T4, u);
160
-
161
- const dx =
162
- evalRoundedRect(px + 1, py, halfW, halfH, rPx) -
163
- evalRoundedRect(px - 1, py, halfW, halfH, rPx);
164
- const dy =
165
- evalRoundedRect(px, py + 1, halfW, halfH, rPx) -
166
- evalRoundedRect(px, py - 1, halfW, halfH, rPx);
167
- const len = Math.hypot(dx, dy) + 1e-6;
168
- const gx = dx / len;
169
- const gy = dy / len;
170
-
171
- const shift = gain * (ior - 1.0) * heightPx;
172
- offsets[idx * 2] = Math.round(-gx * shift);
173
- offsets[idx * 2 + 1] = Math.round(-gy * shift);
174
-
175
- if (gy < -0.2) {
176
- topSheen[idx] = Math.round(sampleTable(_T2, u) * (-gy));
177
- }
178
- rimList.push(idx);
179
- }
180
- } else {
181
- rimList.push(idx);
182
- }
183
-
184
- const absD = Math.abs(d);
185
- if (absD < 3.0) {
186
- glints[idx] = Math.round(sampleTable(_T1, absD * 0.33));
187
- }
188
- }
189
- }
190
-
191
- return {
192
- w,
193
- h,
194
- rad: rPx,
195
- offsets,
196
- dispMap,
197
- glints,
198
- alphas,
199
- topSheen,
200
- rimIndices: new Uint32Array(rimList)
201
- };
202
- }
203
-
204
- /**
205
- * Legacy helper kept for API compatibility (no SVG or PNG used by controller)
206
- */
207
- export function buildOpticalMapURI(width, height, borderRadius, rawOptions = {}) {
208
- const lut = buildElementLUT(
209
- Math.max(16, Math.round(width)),
210
- Math.max(16, Math.round(height)),
211
- borderRadius,
212
- rawOptions,
213
- 1
214
- );
215
- return { lut, scale: 1 };
216
- }
217
-
218
- /**
219
- * Renders a cluster of touching LiquidGlass elements onto a 2D canvas context
220
- * using precomputed empirical tables (_T0.._T4) and O(1) distGrid gradients.
221
- */
222
- export function renderFusedCluster(ctx, sourceCtx, elements, rawOptions = {}, dpr = 1) {
223
- const options = normalizeOptions(rawOptions);
224
- const fusionSpan = options.fusion ? (rawOptions.fusionDistance ?? 44) * dpr : 0;
225
- const rimPx = Math.max(1, options.rim * dpr);
226
- const ior = options.ior;
227
- const heightPx = options.height * dpr;
228
-
229
- let minX = Infinity,
230
- minY = Infinity,
231
- maxX = -Infinity,
232
- maxY = -Infinity;
233
-
234
- for (const el of elements) {
235
- minX = Math.min(minX, (el.x - 16) * dpr);
236
- minY = Math.min(minY, (el.y - 16) * dpr);
237
- maxX = Math.max(maxX, (el.x + el.w + 16) * dpr);
238
- maxY = Math.max(maxY, (el.y + el.h + 16) * dpr);
239
- }
240
-
241
- const cMinX = Math.max(0, Math.floor(minX));
242
- const cMinY = Math.max(0, Math.floor(minY));
243
- const cMaxX = Math.min(sourceCtx.canvas.width, Math.ceil(maxX));
244
- const cMaxY = Math.min(sourceCtx.canvas.height, Math.ceil(maxY));
245
-
246
- const boxW = cMaxX - cMinX;
247
- const boxH = cMaxY - cMinY;
248
- if (boxW <= 2 || boxH <= 2) return;
249
-
250
- const srcImg = sourceCtx.getImageData(cMinX, cMinY, boxW, boxH);
251
- const srcData = srcImg.data;
252
-
253
- if (ctx.canvas.width !== boxW || ctx.canvas.height !== boxH) {
254
- ctx.canvas.width = boxW;
255
- ctx.canvas.height = boxH;
256
- }
257
-
258
- const dstImg = ctx.createImageData(boxW, boxH);
259
- const dstData = dstImg.data;
260
-
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
- }));
275
-
276
- const primary = prep[0];
277
- const tR = primary.tintR;
278
- const tG = primary.tintG;
279
- const tB = primary.tintB;
280
- const tA = primary.tintA;
281
- const invTA = 1.0 - tA;
282
-
283
- // Pass 1: Boundary evaluation across cluster bounding box
284
- for (let y = 0; y < boxH; y++) {
285
- const py = cMinY + y;
286
- const row = y * boxW;
287
- for (let x = 0; x < boxW; x++) {
288
- const px = cMinX + x;
289
- let dCluster = Infinity;
290
- for (let i = 0; i < prep.length; i++) {
291
- 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);
295
- if (i === 0) {
296
- dCluster = dShape;
297
- } else if (b.fusion && fusionSpan > 0) {
298
- dCluster = blendSurfaces(dCluster, dShape, fusionSpan);
299
- } else {
300
- dCluster = Math.min(dCluster, dShape);
301
- }
302
- }
303
- distGrid[row + x] = dCluster;
304
- }
305
- }
306
-
307
- // Pass 2: O(1) lookup from distGrid + tabular chromatic shift (_T4)
308
- for (let y = 1; y < boxH - 1; y++) {
309
- const row = y * boxW;
310
- for (let x = 1; x < boxW - 1; x++) {
311
- const idx = row + x;
312
- const d = distGrid[idx];
313
- if (d > 1.0) continue;
314
-
315
- const edgeAlpha = Math.max(0, Math.min(1, 0.5 - d));
316
- if (edgeAlpha <= 0) continue;
317
-
318
- let offX = 0,
319
- offY = 0,
320
- glint = 0,
321
- sheen = 0,
322
- disp = 0;
323
-
324
- if (d < 0) {
325
- const innerDist = -d;
326
- if (innerDist < rimPx) {
327
- const u = innerDist / rimPx;
328
- const gain = sampleTable(_T0, u);
329
- disp = sampleTable(_T4, u);
330
-
331
- const dx = distGrid[row + x + 1] - distGrid[row + x - 1];
332
- const dy = distGrid[(y + 1) * boxW + x] - distGrid[(y - 1) * boxW + x];
333
- const len = Math.hypot(dx, dy) + 1e-6;
334
- const gx = dx / len;
335
- const gy = dy / len;
336
-
337
- const shift = gain * (ior - 1.0) * heightPx;
338
- offX = Math.round(-gx * shift);
339
- offY = Math.round(-gy * shift);
340
-
341
- if (gy < -0.2) {
342
- sheen = Math.round(sampleTable(_T2, u) * (-gy) * 0.78);
343
- }
344
- } else {
345
- // Fast interior path: zero displacement inside flat region (-d >= rimPx)
346
- const sIdx = idx * 4;
347
- dstData[sIdx] = Math.min(255, Math.round(srcData[sIdx] * invTA + tR * tA));
348
- dstData[sIdx + 1] = Math.min(255, Math.round(srcData[sIdx + 1] * invTA + tG * tA));
349
- dstData[sIdx + 2] = Math.min(255, Math.round(srcData[sIdx + 2] * invTA + tB * tA));
350
- dstData[sIdx + 3] = 255;
351
- continue;
352
- }
353
- }
354
-
355
- const absD = Math.abs(d);
356
- if (absD < 3.0) {
357
- glint = Math.round(sampleTable(_T1, absD * 0.33) * 0.65);
358
- }
359
-
360
- const sxG = Math.max(0, Math.min(boxW - 1, x + offX));
361
- const syG = Math.max(0, Math.min(boxH - 1, y + offY));
362
- const sxR = Math.max(0, Math.min(boxW - 1, x + Math.round(offX * (1.0 + disp))));
363
- const syR = Math.max(0, Math.min(boxH - 1, y + Math.round(offY * (1.0 + disp))));
364
- const sxB = Math.max(0, Math.min(boxW - 1, x + Math.round(offX * (1.0 - disp))));
365
- const syB = Math.max(0, Math.min(boxH - 1, y + Math.round(offY * (1.0 - disp))));
366
-
367
- const rBg = srcData[(syR * boxW + sxR) * 4];
368
- const gBg = srcData[(syG * boxW + sxG) * 4 + 1];
369
- const bBg = srcData[(syB * boxW + sxB) * 4 + 2];
370
-
371
- const totalLum = sheen + glint;
372
- const pIdx = idx * 4;
373
- if (tA < 0.15) {
374
- dstData[pIdx] = Math.min(255, Math.round(rBg * invTA + tR * tA + totalLum));
375
- dstData[pIdx + 1] = Math.min(255, Math.round(gBg * invTA + tG * tA + totalLum));
376
- dstData[pIdx + 2] = Math.min(255, Math.round(bBg * invTA + tB * tA + totalLum));
377
- } else {
378
- dstData[pIdx] = Math.min(255, Math.round(rBg * invTA + tR * tA + totalLum * 0.72));
379
- dstData[pIdx + 1] = Math.min(255, Math.round(gBg * invTA + tG * tA + totalLum * 0.88));
380
- dstData[pIdx + 2] = Math.min(255, Math.round(bBg * invTA + tB * tA + totalLum));
381
- }
382
- dstData[pIdx + 3] = Math.round(edgeAlpha * 255);
383
- }
384
- }
385
-
386
- ctx.putImageData(dstImg, 0, 0);
387
- return { cMinX, cMinY, boxW, boxH };
388
- }
389
-
390
- // ============================================================================
391
- // STAGE COMPOSITOR & ISLAND CLUSTER MANAGER (100% Pure 2D Canvas, 0 SVG, 0 PNG)
392
- // ============================================================================
393
-
394
- const controllerRegistry = new Set();
395
- let stageCanvas = null;
396
- let stageCtx = null;
397
- let blurCanvas = null;
398
- let blurCtx = null;
399
- let clusterOverlayCanvas = null;
400
- let clusterOverlayCtx = null;
401
- let rafScheduled = false;
402
- let rafId = 0;
403
- let listenersAttached = false;
404
- let lastFrostedRadius = -1;
405
-
406
- function parseCssRgba(str) {
407
- if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') {
408
- return [38, 66, 138, 0.52];
409
- }
410
- const m = str.match(/rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
411
- if (m) {
412
- return [
413
- parseFloat(m[1]) || 0,
414
- parseFloat(m[2]) || 0,
415
- parseFloat(m[3]) || 0,
416
- m[4] !== undefined ? parseFloat(m[4]) : 1.0
417
- ];
418
- }
419
- return [38, 66, 138, 0.52];
420
- }
421
-
422
- function getReadableFillColor(bgCol, fallback = 'rgba(30, 41, 59, 0.72)') {
423
- if (!bgCol || bgCol === 'transparent' || bgCol === 'rgba(0, 0, 0, 0)') {
424
- return fallback;
425
- }
426
- if (bgCol.startsWith('rgb')) {
427
- const m = bgCol.match(/rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
428
- if (m) {
429
- const r = Math.min(255, Math.round(parseFloat(m[1]) + 18));
430
- const g = Math.min(255, Math.round(parseFloat(m[2]) + 24));
431
- const b = Math.min(255, Math.round(parseFloat(m[3]) + 38));
432
- const a = m[4] !== undefined ? Math.max(0.68, parseFloat(m[4])) : 0.85;
433
- return `rgba(${r}, ${g}, ${b}, ${a})`;
434
- }
435
- return bgCol;
436
- }
437
- // Tailwind v4 oklab/oklch/color-mix fallback for slate-900/40 matte cards
438
- return fallback;
439
- }
440
-
441
- function getReadableStrokeColor(borderCol, fallback = 'rgba(148, 163, 184, 0.55)') {
442
- if (!borderCol || borderCol === 'transparent' || borderCol === 'rgba(0, 0, 0, 0)') {
443
- return fallback;
444
- }
445
- if (borderCol.startsWith('rgb')) {
446
- return borderCol;
447
- }
448
- return fallback;
449
- }
450
-
451
- function ensureStageBuffers() {
452
- if (typeof document === 'undefined') return false;
453
- const docEl = document.documentElement;
454
- const vw = Math.max(1, window.innerWidth, docEl ? docEl.clientWidth : 0);
455
- const vh = Math.max(1, window.innerHeight, docEl ? docEl.clientHeight : 0);
456
-
457
- if (!stageCanvas) {
458
- stageCanvas = document.createElement('canvas');
459
- stageCtx = stageCanvas.getContext('2d', { willReadFrequently: true });
460
- blurCanvas = document.createElement('canvas');
461
- blurCtx = blurCanvas.getContext('2d', { willReadFrequently: true });
462
- }
463
-
464
- if (stageCanvas.width !== vw || stageCanvas.height !== vh) {
465
- stageCanvas.width = vw;
466
- stageCanvas.height = vh;
467
- blurCanvas.width = vw;
468
- blurCanvas.height = vh;
469
- }
470
-
471
- if (!clusterOverlayCanvas) {
472
- clusterOverlayCanvas = document.createElement('canvas');
473
- clusterOverlayCanvas.className = 'lg-cluster-canvas';
474
- clusterOverlayCanvas.style.cssText = `
475
- position: fixed;
476
- top: 0;
477
- left: 0;
478
- pointer-events: none;
479
- z-index: 48;
480
- display: none;
481
- `;
482
- document.body.appendChild(clusterOverlayCanvas);
483
- clusterOverlayCtx = clusterOverlayCanvas.getContext('2d');
484
- }
485
-
486
- return true;
487
- }
488
-
489
- /**
490
- * Captures the live viewport background (background canvas + visible images & typography)
491
- * onto stageCanvas so isolated & fused lenses can refract live pixels in < 0.2ms
492
- */
493
- function captureViewportStage(neededBlur) {
494
- if (!ensureStageBuffers()) return;
495
- const vw = stageCanvas.width;
496
- const vh = stageCanvas.height;
497
-
498
- // 1. Base page background
499
- stageCtx.fillStyle = '#040814';
500
- stageCtx.fillRect(0, 0, vw, vh);
501
-
502
- // 2. Copy any background 2D canvas using its exact getBoundingClientRect() viewport box
503
- const bgCanvases = document.querySelectorAll('canvas:not(.lg-lens-canvas):not(.lg-cluster-canvas)');
504
- for (let i = 0; i < bgCanvases.length; i++) {
505
- const c = bgCanvases[i];
506
- if (c.width > 0 && c.height > 0) {
507
- try {
508
- const cr = c.getBoundingClientRect();
509
- if (cr.width > 0 && cr.height > 0) {
510
- stageCtx.drawImage(c, cr.left, cr.top, cr.width, cr.height);
511
- }
512
- } catch {
513
- // Ignore cross-origin canvas if any
514
- }
515
- }
516
- }
517
-
518
- // 3. Draw visible block surfaces (cards, buttons, pills, inputs) inside <main> and <footer>
519
- const surfaceEls = document.querySelectorAll(
520
- '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'
521
- );
522
- for (let i = 0; i < surfaceEls.length; i++) {
523
- const el = surfaceEls[i];
524
- const r = el.getBoundingClientRect();
525
- // Capture visible cards/buttons across the entire viewport (both top header and bottom privacy banner)
526
- if (r.bottom < -20 || r.top > vh + 20 || r.width <= 2 || r.height <= 2) continue;
527
- const style = window.getComputedStyle(el);
528
- const rad = Math.min(parseFloat(style.borderRadius) || 12, r.width * 0.5, r.height * 0.5);
529
- const cls = typeof el.className === 'string' ? el.className : '';
530
-
531
- stageCtx.save();
532
- stageCtx.beginPath();
533
- if (typeof stageCtx.roundRect === 'function') {
534
- stageCtx.roundRect(r.left, r.top, r.width, r.height, rad);
535
- } else {
536
- stageCtx.rect(r.left, r.top, r.width, r.height);
537
- }
538
-
539
- const bgImg = style.backgroundImage;
540
- const inlineBg = el.style && el.style.background;
541
- if ((bgImg && bgImg.includes('gradient')) || (inlineBg && inlineBg.includes('gradient'))) {
542
- const g = stageCtx.createLinearGradient(r.left, r.top, r.right, r.bottom);
543
- g.addColorStop(0, '#fb923c');
544
- g.addColorStop(0.5, '#ec4899');
545
- g.addColorStop(1, '#a855f7');
546
- stageCtx.fillStyle = g;
547
- stageCtx.fill();
548
- } else if (el.style && el.style.backgroundColor) {
549
- stageCtx.fillStyle = el.style.backgroundColor;
550
- stageCtx.fill();
551
- } else if (cls.includes('bg-slate-950')) {
552
- stageCtx.fillStyle = 'rgba(15, 23, 42, 0.92)';
553
- stageCtx.fill();
554
- } else {
555
- // Matte slate-900/40 cards & buttons — render with clear contrast against #040814
556
- stageCtx.fillStyle = 'rgba(30, 41, 59, 0.88)';
557
- stageCtx.fill();
558
- }
559
-
560
- const bWidth = parseFloat(style.borderTopWidth) || 1.5;
561
- stageCtx.lineWidth = Math.max(1.5, bWidth);
562
- if (el.style && el.style.borderColor) {
563
- stageCtx.strokeStyle = el.style.borderColor;
564
- } else {
565
- stageCtx.strokeStyle = 'rgba(148, 163, 184, 0.82)';
566
- }
567
- stageCtx.stroke();
568
- stageCtx.restore();
569
- }
570
-
571
- // 4. Draw visible images inside <main> (rounded to parent border-radius if inside a card)
572
- const imgs = document.querySelectorAll('main img');
573
- for (let i = 0; i < imgs.length; i++) {
574
- const img = imgs[i];
575
- if (!img.complete || img.naturalWidth === 0) continue;
576
- const r = img.getBoundingClientRect();
577
- if (r.bottom < -20 || r.top > vh + 20 || r.right < 0 || r.left > vw) continue;
578
- try {
579
- stageCtx.save();
580
- const parent = img.parentElement;
581
- const pStyle = parent ? window.getComputedStyle(parent) : null;
582
- const pRad = pStyle ? parseFloat(pStyle.borderRadius) || 0 : 0;
583
- if (pRad > 0 && typeof stageCtx.roundRect === 'function') {
584
- stageCtx.beginPath();
585
- stageCtx.roundRect(r.left, r.top, r.width, r.height, pRad);
586
- stageCtx.clip();
587
- }
588
- stageCtx.drawImage(img, r.left, r.top, r.width, r.height);
589
- stageCtx.restore();
590
- } catch {
591
- stageCtx.restore();
592
- }
593
- }
594
-
595
- // 5. Draw visible text, headings, buttons & labels in the top viewport band so header refracts them when scrolling over
596
- const textEls = document.querySelectorAll(
597
- 'main h1, main h2, main h3, main p, main button, main a, main label, main input, main textarea, main span.font-mono, main span.rounded-full, main span.bg-clip-text, main div.rounded-xl span, main div.rounded-2xl span, footer p, footer a, footer button, footer span'
598
- );
599
- for (let i = 0; i < textEls.length; i++) {
600
- const el = textEls[i];
601
- if (el.tagName === 'A' && el.querySelector('img, h3')) continue;
602
- const r = el.getBoundingClientRect();
603
- if (r.bottom < -20 || r.top > vh + 20 || r.height <= 0 || r.width <= 0) continue;
604
- const tag = el.tagName;
605
- const style = window.getComputedStyle(el);
606
- const cls = typeof el.className === 'string' ? el.className : '';
607
- stageCtx.save();
608
- stageCtx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
609
- stageCtx.textBaseline = 'top';
610
- if (tag === 'H1' || tag === 'H2' || tag === 'H3' || cls.includes('bg-clip-text')) {
611
- const grad = stageCtx.createLinearGradient(r.left, r.top, r.right, r.top);
612
- if (cls.includes('bg-clip-text') && tag === 'SPAN') {
613
- grad.addColorStop(0, '#fb923c');
614
- grad.addColorStop(0.5, '#ec4899');
615
- grad.addColorStop(1, '#a855f7');
616
- } else {
617
- grad.addColorStop(0, '#f8fafc');
618
- grad.addColorStop(0.55, '#f8fafc');
619
- grad.addColorStop(0.75, '#fb923c');
620
- grad.addColorStop(0.9, '#ec4899');
621
- grad.addColorStop(1, '#a855f7');
622
- }
623
- stageCtx.fillStyle = grad;
624
- } else if (tag === 'INPUT' || tag === 'TEXTAREA') {
625
- stageCtx.fillStyle = '#94a3b8';
626
- } else {
627
- const col = style.color;
628
- stageCtx.fillStyle = col && col.startsWith('rgb') ? col : '#f1f5f9';
629
- }
630
- const rawText =
631
- tag === 'INPUT' || tag === 'TEXTAREA'
632
- ? el.value || el.getAttribute('placeholder') || ''
633
- : el.textContent || '';
634
- const text = rawText.trim().replace(/\s+/g, ' ');
635
- if (!text) {
636
- stageCtx.restore();
637
- continue;
638
- }
639
- const words = text.split(' ');
640
- const fontSizePx = parseFloat(style.fontSize) || 16;
641
- const lineHeight = parseFloat(style.lineHeight) || fontSizePx * 1.3;
642
- let line = '';
643
- const isButtonOrPill =
644
- tag === 'BUTTON' ||
645
- tag === 'A' ||
646
- tag === 'INPUT' ||
647
- style.display.includes('inline-flex') ||
648
- cls.includes('rounded-full') ||
649
- cls.includes('rounded-lg');
650
- let y = isButtonOrPill ? r.top + (r.height - fontSizePx) * 0.5 : r.top;
651
- const isCentered = style.textAlign === 'center' || (isButtonOrPill && tag !== 'INPUT');
652
- stageCtx.textAlign = isCentered ? 'center' : 'left';
653
- const drawX = isCentered ? r.left + r.width * 0.5 : r.left + (tag === 'INPUT' ? 12 : 0);
654
-
655
- for (let wIdx = 0; wIdx < words.length; wIdx++) {
656
- const testLine = line ? `${line} ${words[wIdx]}` : words[wIdx];
657
- if (!isButtonOrPill && stageCtx.measureText(testLine).width > r.width && line) {
658
- stageCtx.fillText(line, drawX, y);
659
- line = words[wIdx];
660
- y += lineHeight;
661
- } else {
662
- line = testLine;
663
- }
664
- }
665
- if (line) {
666
- stageCtx.fillText(line, drawX, y);
667
- }
668
- stageCtx.restore();
669
- }
670
-
671
- // 5. Blur buffer for frosted glass
672
- if (neededBlur > 0) {
673
- 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';
678
- }
679
- }
680
-
681
- /**
682
- * Fast Isolated Lens Renderer (Takes ~0.12 ms using precomputed TypedArray LUT!)
683
- */
684
- function renderIsolatedController(ctrl, rect) {
685
- const w = Math.max(16, Math.round(rect.width));
686
- const h = Math.max(16, Math.round(rect.height));
687
- const rad = ctrl._getRadius();
688
-
689
- if (
690
- !ctrl._lut ||
691
- ctrl._lut.w !== w ||
692
- ctrl._lut.h !== h ||
693
- Math.abs(ctrl._lut.rad - Math.min(rad, w * 0.5, h * 0.5)) > 1.0
694
- ) {
695
- ctrl._lut = buildElementLUT(w, h, rad, ctrl.options, 1);
696
- }
697
-
698
- const lut = ctrl._lut;
699
- if (ctrl._canvas.width !== w || ctrl._canvas.height !== h) {
700
- ctrl._canvas.width = w;
701
- ctrl._canvas.height = h;
702
- }
703
-
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);
707
-
708
- const sourceCtx = ctrl.options.frosted > 0 ? blurCtx : stageCtx;
709
- const srcImg = sourceCtx.getImageData(dx, dy, w, h);
710
- const srcData = srcImg.data;
711
-
712
- const lensImg = ctrl._ctx.createImageData(w, h);
713
- const lensData = lensImg.data;
714
-
715
- // Start with tinted interior copy
716
- const [tR, tG, tB, tA] = ctrl._tint;
717
- const invTA = 1.0 - tA;
718
- const offsets = lut.offsets;
719
- const dispMap = lut.dispMap;
720
- const alphas = lut.alphas;
721
- const glints = lut.glints;
722
- const topSheen = lut.topSheen;
723
- const total = w * h;
724
-
725
- for (let idx = 0; idx < total; idx++) {
726
- const a = alphas[idx];
727
- if (a === 0) continue;
728
-
729
- const offX = offsets[idx * 2];
730
- const offY = offsets[idx * 2 + 1];
731
- const disp = dispMap[idx];
732
-
733
- const x = idx % w;
734
- const y = (idx / w) | 0;
735
-
736
- let rBg, gBg, bBg;
737
- if (disp > 0 && (offX !== 0 || offY !== 0)) {
738
- const sxG = Math.max(0, Math.min(w - 1, x + offX));
739
- const syG = Math.max(0, Math.min(h - 1, y + offY));
740
- const sxR = Math.max(0, Math.min(w - 1, x + Math.round(offX * (1.0 + disp))));
741
- const syR = Math.max(0, Math.min(h - 1, y + Math.round(offY * (1.0 + disp))));
742
- const sxB = Math.max(0, Math.min(w - 1, x + Math.round(offX * (1.0 - disp))));
743
- const syB = Math.max(0, Math.min(h - 1, y + Math.round(offY * (1.0 - disp))));
744
-
745
- rBg = srcData[(syR * w + sxR) * 4];
746
- gBg = srcData[(syG * w + sxG) * 4 + 1];
747
- bBg = srcData[(syB * w + sxB) * 4 + 2];
748
- } else {
749
- const sx = Math.max(0, Math.min(w - 1, x + offX));
750
- const sy = Math.max(0, Math.min(h - 1, y + offY));
751
- const sIdx = (sy * w + sx) * 4;
752
- rBg = srcData[sIdx];
753
- gBg = srcData[sIdx + 1];
754
- bBg = srcData[sIdx + 2];
755
- }
756
-
757
- const sheen = topSheen[idx] * 0.78 + glints[idx] * 0.65;
758
- const pIdx = idx * 4;
759
- if (tA < 0.15) {
760
- const clearSheen = topSheen[idx] * 0.92 + glints[idx] * 0.88;
761
- lensData[pIdx] = Math.min(255, Math.round(rBg * invTA + tR * tA + clearSheen));
762
- lensData[pIdx + 1] = Math.min(255, Math.round(gBg * invTA + tG * tA + clearSheen));
763
- lensData[pIdx + 2] = Math.min(255, Math.round(bBg * invTA + tB * tA + clearSheen));
764
- } else {
765
- lensData[pIdx] = Math.min(255, Math.round(rBg * invTA + tR * tA + sheen * 0.72));
766
- lensData[pIdx + 1] = Math.min(255, Math.round(gBg * invTA + tG * tA + sheen * 0.88));
767
- lensData[pIdx + 2] = Math.min(255, Math.round(bBg * invTA + tB * tA + sheen));
768
- }
769
- lensData[pIdx + 3] = a;
770
- }
771
-
772
- ctrl._ctx.putImageData(lensImg, 0, 0);
773
- ctrl._canvas.style.opacity = '1';
774
- }
775
-
776
- function areRectsTouching(r1, r2, threshold = 42) {
777
- const dx = Math.max(
778
- 0,
779
- Math.abs(r1.left + r1.width * 0.5 - (r2.left + r2.width * 0.5)) -
780
- (r1.width + r2.width) * 0.5
781
- );
782
- const dy = Math.max(
783
- 0,
784
- Math.abs(r1.top + r1.height * 0.5 - (r2.top + r2.height * 0.5)) -
785
- (r1.height + r2.height) * 0.5
786
- );
787
- return Math.hypot(dx, dy) < threshold;
788
- }
789
-
790
- function renderAllActiveLenses() {
791
- rafScheduled = false;
792
- if (typeof document === 'undefined' || controllerRegistry.size === 0) return;
793
-
794
- const vw = window.innerWidth;
795
- const vh = window.innerHeight;
796
-
797
- const visible = [];
798
- let maxFrosted = 0;
799
-
800
- for (const ctrl of controllerRegistry) {
801
- if (!ctrl._mounted || !ctrl.enabled || !ctrl.element) {
802
- if (ctrl._canvas) ctrl._canvas.style.opacity = '0';
803
- continue;
804
- }
805
- const r = ctrl.element.getBoundingClientRect();
806
- if (r.width <= 4 || r.height <= 4) continue;
807
- // Keep off-screen fusion anchors (within 80px of viewport) in the active list when fusion is enabled
808
- const margin = ctrl.options.fusion ? 80 : 0;
809
- if (
810
- r.bottom < -margin ||
811
- r.top > vh + margin ||
812
- r.right < -margin ||
813
- r.left > vw + margin
814
- ) {
815
- continue;
816
- }
817
- if (ctrl.options.frosted > maxFrosted) {
818
- maxFrosted = ctrl.options.frosted;
819
- }
820
- visible.push({ ctrl, rect: r });
821
- }
822
-
823
- if (visible.length === 0) return;
824
-
825
- captureViewportStage(maxFrosted);
826
-
827
- // Disjoint-set Island Clustering
828
- const n = visible.length;
829
- const parent = new Int32Array(n);
830
- for (let i = 0; i < n; i++) parent[i] = i;
831
-
832
- function find(i) {
833
- while (parent[i] !== i) i = parent[i];
834
- return i;
835
- }
836
- function union(i, j) {
837
- const ri = find(i);
838
- const rj = find(j);
839
- if (ri !== rj) parent[ri] = rj;
840
- }
841
-
842
- for (let i = 0; i < n; i++) {
843
- if (!visible[i].ctrl.options.fusion) continue;
844
- for (let j = i + 1; j < n; j++) {
845
- if (!visible[j].ctrl.options.fusion) continue;
846
- if (areRectsTouching(visible[i].rect, visible[j].rect, 54)) {
847
- union(i, j);
848
- }
849
- }
850
- }
851
-
852
- const clusters = new Map();
853
- for (let i = 0; i < n; i++) {
854
- const root = find(i);
855
- if (!clusters.has(root)) clusters.set(root, []);
856
- clusters.get(root).push(visible[i]);
857
- }
858
-
859
- let hasFusedCluster = false;
860
-
861
- for (const group of clusters.values()) {
862
- if (group.length === 1) {
863
- const { ctrl, rect } = group[0];
864
- if (ctrl._savedShadow && ctrl.element.style.boxShadow === 'none') {
865
- ctrl.element.style.boxShadow = ctrl._savedInlineShadow || '';
866
- }
867
- if (rect.bottom > 1 && rect.top < vh) {
868
- renderIsolatedController(ctrl, rect);
869
- }
870
- } else {
871
- hasFusedCluster = true;
872
- let anyHasShadow = false;
873
- for (const item of group) {
874
- item.ctrl._canvas.style.opacity = '0';
875
- if (item.ctrl._savedShadow) {
876
- anyHasShadow = true;
877
- item.ctrl.element.style.boxShadow = 'none';
878
- }
879
- }
880
- const primaryCtrl = group[group.length - 1].ctrl;
881
- const sourceCtx = primaryCtrl.options.frosted > 0 ? blurCtx : stageCtx;
882
- const elements = group.map(({ ctrl, rect }) => ({
883
- x: rect.left,
884
- y: rect.top,
885
- w: rect.width,
886
- h: rect.height,
887
- borderRadius: ctrl._getRadius(),
888
- fusion: true,
889
- tint: ctrl._tint
890
- }));
891
-
892
- const box = renderFusedCluster(
893
- clusterOverlayCtx,
894
- sourceCtx,
895
- elements,
896
- {
897
- ...primaryCtrl.options,
898
- fusionDistance: 64
899
- },
900
- 1
901
- );
902
-
903
- if (box && clusterOverlayCanvas) {
904
- 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`;
911
- }
912
- }
913
- }
914
-
915
- if (!hasFusedCluster && clusterOverlayCanvas) {
916
- clusterOverlayCanvas.style.display = 'none';
917
- }
918
- }
919
-
920
- export function scheduleStageRedraw() {
921
- if (typeof window === 'undefined') return;
922
- if (rafScheduled && rafId) {
923
- window.cancelAnimationFrame(rafId);
924
- }
925
- rafScheduled = true;
926
- rafId = window.requestAnimationFrame(renderAllActiveLenses);
927
- }
928
-
929
- function attachGlobalStageListeners() {
930
- if (listenersAttached || typeof window === 'undefined') return;
931
- listenersAttached = true;
932
- window.addEventListener('scroll', scheduleStageRedraw, { passive: true });
933
- window.addEventListener('resize', scheduleStageRedraw, { passive: true });
934
-
935
- // Continuous 30fps sync with ambient background canvas animation
936
- const tick = () => {
937
- scheduleStageRedraw();
938
- window.setTimeout(() => window.requestAnimationFrame(tick), 32);
939
- };
940
- window.requestAnimationFrame(tick);
941
- }
942
-
943
- /**
944
- * Universal DOM Controller: turns any HTMLElement into an interactive Liquid Glass surface
945
- * using pure 2D Canvas + TypedArray LUT & Cluster Fusion (Zero WebGL, Zero SVG, Zero PNG).
946
- */
947
- export class LiquidGlassController {
948
- constructor(element, options = {}) {
949
- this.element = element;
950
- this.options = normalizeOptions(options);
951
- this.enabled = options.enabled !== undefined ? Boolean(options.enabled) : true;
952
-
953
- this._canvas = null;
954
- this._ctx = null;
955
- this._lut = null;
956
- this._ro = null;
957
- this._tint = [15, 23, 42, 0.35];
958
- this._savedBg = '';
959
- this._savedShadow = '';
960
- this._savedInlineShadow = '';
961
- this._mounted = false;
962
- this._init();
963
- }
964
-
965
- _init() {
966
- if (!this.element || typeof document === 'undefined') return;
967
-
968
- if (!this.element.style.position) {
969
- const computed = window.getComputedStyle(this.element);
970
- if (computed.position === 'static') {
971
- this.element.style.position = 'relative';
972
- }
973
- }
974
- this.element.style.overflow = 'hidden';
975
-
976
- // Read element's intended CSS background tint, then make CSS background transparent
977
- // so the 2D Canvas lens underneath renders the refracted + tinted pixels directly!
978
- const computed = window.getComputedStyle(this.element);
979
- const attrTint = this.element.getAttribute('data-lg-tint');
980
- this._savedBg = attrTint || this.element.style.backgroundColor || computed.backgroundColor;
981
- this._tint = parseCssRgba(this._savedBg);
982
- this.element.style.backgroundColor = 'transparent';
983
- if (computed.boxShadow && computed.boxShadow !== 'none') {
984
- this._savedInlineShadow = this.element.style.boxShadow || '';
985
- this._savedShadow = computed.boxShadow;
986
- }
987
-
988
- const canvas = document.createElement('canvas');
989
- canvas.className = 'lg-lens-canvas';
990
- canvas.setAttribute('aria-hidden', 'true');
991
- canvas.style.cssText = `
992
- position: absolute;
993
- inset: 0;
994
- width: 100%;
995
- height: 100%;
996
- pointer-events: none;
997
- z-index: 0;
998
- border-radius: inherit;
999
- display: block;
1000
- `;
1001
- this.element.insertBefore(canvas, this.element.firstChild);
1002
- this._canvas = canvas;
1003
- this._ctx = canvas.getContext('2d');
1004
-
1005
- controllerRegistry.add(this);
1006
- attachGlobalStageListeners();
1007
-
1008
- if (typeof ResizeObserver !== 'undefined') {
1009
- this._ro = new ResizeObserver(() => {
1010
- scheduleStageRedraw();
1011
- });
1012
- this._ro.observe(this.element);
1013
- }
1014
-
1015
- if (typeof MutationObserver !== 'undefined') {
1016
- if (this.element.style.backgroundColor && this.element.style.backgroundColor !== 'transparent') {
1017
- this._savedBg = this.element.style.backgroundColor;
1018
- this._tint = parseCssRgba(this._savedBg);
1019
- this.element.style.backgroundColor = 'transparent';
1020
- }
1021
- this._mo = new MutationObserver(() => {
1022
- if (this.element) {
1023
- const newAttrTint = this.element.getAttribute('data-lg-tint');
1024
- if (newAttrTint) {
1025
- this._savedBg = newAttrTint;
1026
- this._tint = parseCssRgba(newAttrTint);
1027
- } else if (
1028
- this.element.style.backgroundColor &&
1029
- this.element.style.backgroundColor !== 'transparent'
1030
- ) {
1031
- this._savedBg = this.element.style.backgroundColor;
1032
- this._tint = parseCssRgba(this._savedBg);
1033
- this.element.style.backgroundColor = 'transparent';
1034
- }
1035
- }
1036
- scheduleStageRedraw();
1037
- });
1038
- this._mo.observe(this.element, { attributes: true, attributeFilter: ['style', 'class'] });
1039
- if (this.element.parentElement) {
1040
- this._mo.observe(this.element.parentElement, {
1041
- attributes: true,
1042
- attributeFilter: ['style', 'class']
1043
- });
1044
- }
1045
- }
1046
-
1047
- this._mounted = true;
1048
- scheduleStageRedraw();
1049
- }
1050
-
1051
- _getRadius() {
1052
- if (!this.element) return 0;
1053
- const inlineRad = parseFloat(this.element.style.borderRadius);
1054
- if (!Number.isNaN(inlineRad)) return inlineRad;
1055
- const computed = window.getComputedStyle(this.element);
1056
- return parseFloat(computed.borderRadius) || 0;
1057
- }
1058
-
1059
- isTouchingAny(threshold = 40) {
1060
- if (!this.options.fusion || !this.element) return false;
1061
- const r1 = this.element.getBoundingClientRect();
1062
- for (const other of controllerRegistry) {
1063
- if (other === this || !other.element || !other.options.fusion) continue;
1064
- const r2 = other.element.getBoundingClientRect();
1065
- if (areRectsTouching(r1, r2, threshold)) return true;
1066
- }
1067
- return false;
1068
- }
1069
-
1070
- update(newOptions = {}) {
1071
- const merged = normalizeOptions({ ...this.options, ...newOptions });
1072
- let lutChanged = false;
1073
- let changed = false;
1074
-
1075
- if (newOptions.enabled !== undefined && Boolean(newOptions.enabled) !== this.enabled) {
1076
- this.enabled = Boolean(newOptions.enabled);
1077
- changed = true;
1078
- }
1079
-
1080
- for (const key of ['rim', 'ior', 'height', 'frosted', 'fusion']) {
1081
- if (merged[key] !== this.options[key]) {
1082
- this.options[key] = merged[key];
1083
- changed = true;
1084
- if (key === 'rim' || key === 'ior' || key === 'height') {
1085
- lutChanged = true;
1086
- }
1087
- }
1088
- }
1089
-
1090
- if (lutChanged) {
1091
- this._lut = null;
1092
- }
1093
- if (changed) {
1094
- scheduleStageRedraw();
1095
- }
1096
- }
1097
-
1098
- refresh() {
1099
- scheduleStageRedraw();
1100
- }
1101
-
1102
- destroy() {
1103
- controllerRegistry.delete(this);
1104
- if (this._ro) {
1105
- this._ro.disconnect();
1106
- this._ro = null;
1107
- }
1108
- if (this._mo) {
1109
- this._mo.disconnect();
1110
- this._mo = null;
1111
- }
1112
- if (this._canvas && this._canvas.parentNode) {
1113
- this._canvas.parentNode.removeChild(this._canvas);
1114
- }
1115
- this._mounted = false;
1116
- scheduleStageRedraw();
1117
- }
1118
- }
1119
-
1120
- /**
1121
- * Imperative helper to attach Liquid Glass to any DOM selector or HTMLElement
1122
- */
1123
- export function createLiquidGlass(target, options = {}) {
1124
- const el = typeof target === 'string' ? document.querySelector(target) : target;
1125
- return new LiquidGlassController(el, options);
1126
- }