lensfx 0.1.0 → 0.2.0
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/CHANGELOG.md +53 -0
- package/README.md +333 -137
- package/dist/index.d.mts +252 -194
- package/dist/index.d.ts +252 -194
- package/dist/index.js +2728 -3185
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2702 -3143
- package/dist/index.mjs.map +1 -1
- package/dist/lensfx.css +113 -0
- package/package.json +62 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `lensfx` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
#### Engine Layer (`lensfx`)
|
|
13
|
+
- Pure mathematical optics engine:
|
|
14
|
+
- Error function (`erf`) calculation for Gaussian surface profiles.
|
|
15
|
+
- Optical dome constant calculation (`computeDomeConstants`).
|
|
16
|
+
- Radial gradient generation for spherical dome profiles (`domeGradient`).
|
|
17
|
+
- SVG `feColorMatrix` calculation for aspect ratio corrections (`colorMatrixValues`).
|
|
18
|
+
- SVG mask URI generation for rounded-rectangle lens clipping (`getSvgMaskDataUri`).
|
|
19
|
+
- Real-time and asynchronous displacement map generator (`createDisplacementMapGeneratorSync`, `generateDisplacementMapAsync`).
|
|
20
|
+
- Pre-calibrated lens optical constants (`SWITCH_GLASS_DEFAULTS`, `SLIDER_GLASS_DEFAULTS`, dark/light theme overrides).
|
|
21
|
+
|
|
22
|
+
#### Primitives Layer
|
|
23
|
+
- `<Lens>` unstyled behavioral glass component:
|
|
24
|
+
- SVG `feDisplacementMap` real-time DOM refraction (preserves text selection, pointer events, form inputs).
|
|
25
|
+
- Chromatic aberration separation (red, green, blue optical channels).
|
|
26
|
+
- Multi-lens context support (`Lens.General` / `Lens.Group`) for sharing one background across multiple moving lenses.
|
|
27
|
+
- Offscreen generation pausing (`pauseOffscreen`) via `IntersectionObserver`.
|
|
28
|
+
- Motion value interpolation for 60fps gesture and scroll-driven physics via Framer Motion.
|
|
29
|
+
|
|
30
|
+
#### Components Layer
|
|
31
|
+
- `<LensSwitch>` accessible glass toggle switch with spring physics, keyboard support (Space / Enter), and full controlled/uncontrolled state.
|
|
32
|
+
- `<LensSlider>` accessible optical glass range slider with tick quantization, track fill refraction, and thumb dome physics.
|
|
33
|
+
- `<LensToggleGroup>` segmented radio control with real-time sliding optical lens and glowing active indicator.
|
|
34
|
+
|
|
35
|
+
#### WebGL Layer
|
|
36
|
+
- `VideoRefractionRenderer` WebGL 1 shader fallback for live HTML5 `<video>` elements on WebKit / Safari.
|
|
37
|
+
- `QRRefractionRenderer` WebGL 2 (#version 300 es) interactive QR code refraction engine with O(1) uniform texture lookup.
|
|
38
|
+
|
|
39
|
+
#### Design Tokens
|
|
40
|
+
- Design token stylesheet `lensfx/styles` (`dist/lensfx.css`) providing namespaced CSS custom properties (`--lensfx-*`) for colors, radii, shadows, animations, auto dark-mode (`prefers-color-scheme`), and accessibility (`prefers-reduced-motion`, `:focus-visible`).
|
|
41
|
+
|
|
42
|
+
#### Developer Experience & Tooling
|
|
43
|
+
- Dual ESM and CJS bundle output with TypeScript declarations (`tsup`).
|
|
44
|
+
- `"use client"` directive injection for Next.js App Router (RSC) compatibility.
|
|
45
|
+
- Tree-shaking enabled with `sideEffects: ["*.css"]`.
|
|
46
|
+
- Comprehensive Vitest unit & integration test suite (50 tests covering pure math, switch, slider, and toggle group).
|
|
47
|
+
- Size-limit bundle gate (core engine < 10 kB, full library < 80 kB).
|
|
48
|
+
- GitHub Actions CI workflow for type checking, testing, building, and bundle size tracking.
|
|
49
|
+
|
|
50
|
+
## [0.1.0] - 2026-10-16
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
- Initial experimental release of reverse-engineered glass simulation engine.
|
package/README.md
CHANGED
|
@@ -1,184 +1,380 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lensfx
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**The most convincing, drop-in optical glass refraction for the web.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/lensfx)
|
|
6
|
+
[](https://bundlephobia.com/package/lensfx)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](https://github.com/rudraksha132/lensfx/actions)
|
|
9
|
+
|
|
10
|
+
Real optical refraction — not blur, not `backdrop-filter`, not a fake glass card.
|
|
11
|
+
Real DOM elements (text, links, inputs, images) refracted in place using physics-derived SVG displacement maps.
|
|
6
12
|
|
|
7
13
|
---
|
|
8
14
|
|
|
9
|
-
##
|
|
15
|
+
## Who it's for
|
|
16
|
+
|
|
17
|
+
App developers who want a glass effect without learning SVG filters, WebGL, or optical physics.
|
|
18
|
+
|
|
19
|
+
## What it does best
|
|
20
|
+
|
|
21
|
+
The most convincing, drop-in refraction on the web. `<LensSwitch>`, `<LensSlider>`, and `<LensToggleGroup>` all look and feel like real glass — because they simulate real lenses, not a CSS shortcut.
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
## What it refuses to do
|
|
24
|
+
|
|
25
|
+
No full design system, no CSS framework, no state management. The narrow scope is what keeps the API small and the bundle light.
|
|
12
26
|
|
|
13
27
|
---
|
|
14
28
|
|
|
15
|
-
##
|
|
29
|
+
## Quick start
|
|
16
30
|
|
|
31
|
+
```bash
|
|
32
|
+
npm install lensfx framer-motion
|
|
17
33
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import "lensfx/styles";
|
|
37
|
+
import { Lens } from "lensfx";
|
|
38
|
+
|
|
39
|
+
export default function App() {
|
|
40
|
+
return (
|
|
41
|
+
<div style={{ background: "url('/hero.jpg') center/cover", width: 400, height: 300 }}>
|
|
42
|
+
<Lens
|
|
43
|
+
lens={{
|
|
44
|
+
lensW: 120,
|
|
45
|
+
lensH: 80,
|
|
46
|
+
borderRadius: 20,
|
|
47
|
+
depth: 2,
|
|
48
|
+
domeDepth: 6,
|
|
49
|
+
chromaAmount: 0.8,
|
|
50
|
+
specularStrength: 1.2,
|
|
51
|
+
specularRotation: 45,
|
|
52
|
+
sdfBoundary: true,
|
|
53
|
+
edgeFalloff: true,
|
|
54
|
+
glowStrength: 0.4,
|
|
55
|
+
}}
|
|
56
|
+
x={0.5}
|
|
57
|
+
y={0.5}
|
|
58
|
+
>
|
|
59
|
+
<p style={{ color: "white" }}>Drag me</p>
|
|
60
|
+
</Lens>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
35
64
|
```
|
|
36
65
|
|
|
66
|
+
That's it. Zero configuration for a working lens.
|
|
67
|
+
|
|
37
68
|
---
|
|
38
69
|
|
|
39
|
-
##
|
|
40
|
-
|
|
41
|
-
###
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
## Components
|
|
71
|
+
|
|
72
|
+
### `<Lens>` — primitive (unstyled)
|
|
73
|
+
|
|
74
|
+
The single behavioral primitive. All other components compose over this.
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { Lens } from "lensfx";
|
|
78
|
+
|
|
79
|
+
<Lens
|
|
80
|
+
lens={params} // LensParams — all optical controls
|
|
81
|
+
x={0.5} // normalised (0–1) or px position
|
|
82
|
+
y={0.5}
|
|
83
|
+
lensW={120} // override width (or MotionValue)
|
|
84
|
+
lensH={80} // override height (or MotionValue)
|
|
85
|
+
borderRadius={20} // or MotionValue
|
|
86
|
+
tintColor="white" // CSS color string
|
|
87
|
+
tintOpacity={0.1} // or MotionValue
|
|
88
|
+
tintBlur={4} // backdrop blur inside lens (px)
|
|
89
|
+
refractionTarget={<MyBackground />} // alternative target element
|
|
90
|
+
pauseOffscreen // stop generating maps when not visible
|
|
91
|
+
className="my-lens"
|
|
92
|
+
style={{ cursor: "grab" }}
|
|
93
|
+
>
|
|
94
|
+
{children} // rendered on top of the distortion
|
|
95
|
+
</Lens>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### `Lens.General` — multi-lens provider
|
|
99
|
+
|
|
100
|
+
Share one refraction context across many lenses (e.g. a moving spotlight over a page):
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<Lens.General.Provider>
|
|
104
|
+
<Lens.General.Target>
|
|
105
|
+
<p>This text gets refracted by any lens above it.</p>
|
|
106
|
+
</Lens.General.Target>
|
|
107
|
+
</Lens.General.Provider>
|
|
67
108
|
```
|
|
68
|
-
This achieves sub-2ms generation times on standard hardware.
|
|
69
109
|
|
|
70
110
|
---
|
|
71
111
|
|
|
72
|
-
|
|
112
|
+
### `<LensSwitch>`
|
|
73
113
|
|
|
74
|
-
|
|
114
|
+
A toggle switch with rubber-band drag, squish physics, and live refraction of the track behind the thumb.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { LensSwitch } from "lensfx";
|
|
118
|
+
|
|
119
|
+
// Uncontrolled
|
|
120
|
+
<LensSwitch
|
|
121
|
+
defaultChecked
|
|
122
|
+
ariaLabel="Enable notifications"
|
|
123
|
+
onCheckedChange={(checked) => console.log(checked)}
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
// Controlled
|
|
127
|
+
<LensSwitch
|
|
128
|
+
checked={isEnabled}
|
|
129
|
+
onCheckedChange={setIsEnabled}
|
|
130
|
+
ariaLabel="Enable notifications"
|
|
131
|
+
theme="light"
|
|
132
|
+
width={90}
|
|
133
|
+
height={34}
|
|
134
|
+
/>
|
|
135
|
+
```
|
|
75
136
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
137
|
+
| Prop | Type | Default | Description |
|
|
138
|
+
|---|---|---|---|
|
|
139
|
+
| `checked` | `boolean` | — | Controlled checked state |
|
|
140
|
+
| `defaultChecked` | `boolean` | `false` | Uncontrolled default |
|
|
141
|
+
| `onCheckedChange` | `(checked: boolean) => void` | — | Change handler |
|
|
142
|
+
| `disabled` | `boolean` | `false` | Disabled state |
|
|
143
|
+
| `ariaLabel` | `string` | `"Switch"` | Accessible label |
|
|
144
|
+
| `theme` | `"dark" \| "light"` | `"dark"` | Adjusts specular and brightness |
|
|
145
|
+
| `width` | `number` | `74` | Track width in px |
|
|
146
|
+
| `height` | `number` | `28` | Track height in px |
|
|
147
|
+
| `lens` | `Partial<LensParams>` | — | Override optical parameters |
|
|
148
|
+
| `rubberOvershoot` | `number` | `0.15` | Max rubber-band overdrag (fraction of width) |
|
|
81
149
|
|
|
82
|
-
|
|
83
|
-
<feImage href="{dataUrl}" preserveAspectRatio="none" result="rawMap"
|
|
84
|
-
x="{lensLeft}" y="{lensTop}" width="{lensW}" height="{lensH}" />
|
|
150
|
+
---
|
|
85
151
|
|
|
86
|
-
|
|
87
|
-
<feComposite in="rawMap" in2="mapBg" operator="over" result="map" />
|
|
152
|
+
### `<LensSlider>`
|
|
88
153
|
|
|
89
|
-
|
|
90
|
-
<feColorMatrix in="map" type="matrix"
|
|
91
|
-
values="{Sx} 0 0 0 {0.5*(1-Sx)} 0 {Sy} 0 0 {0.5*(1-Sy)} 0 0 1 0 0 0 0 0 1 0"
|
|
92
|
-
result="scaledMap" />
|
|
154
|
+
A range slider where the thumb is a glass lens gliding over a coloured progress track.
|
|
93
155
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
```tsx
|
|
157
|
+
import { LensSlider } from "lensfx";
|
|
158
|
+
|
|
159
|
+
// Uncontrolled
|
|
160
|
+
<LensSlider
|
|
161
|
+
defaultValue={30}
|
|
162
|
+
min={0}
|
|
163
|
+
max={100}
|
|
164
|
+
step={1}
|
|
165
|
+
ariaLabel="Volume"
|
|
166
|
+
onValueChange={(val) => console.log(val)}
|
|
167
|
+
/>
|
|
168
|
+
|
|
169
|
+
// Controlled
|
|
170
|
+
<LensSlider
|
|
171
|
+
value={volume}
|
|
172
|
+
onValueChange={setVolume}
|
|
173
|
+
ariaLabel="Volume"
|
|
174
|
+
theme="light"
|
|
175
|
+
width={240}
|
|
176
|
+
/>
|
|
177
|
+
```
|
|
98
178
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
179
|
+
| Prop | Type | Default | Description |
|
|
180
|
+
|---|---|---|---|
|
|
181
|
+
| `value` | `number` | — | Controlled value |
|
|
182
|
+
| `defaultValue` | `number` | `50` | Uncontrolled default |
|
|
183
|
+
| `onValueChange` | `(val: number) => void` | — | Change handler |
|
|
184
|
+
| `min` | `number` | `0` | Minimum value |
|
|
185
|
+
| `max` | `number` | `100` | Maximum value |
|
|
186
|
+
| `step` | `number` | `1` | Step size |
|
|
187
|
+
| `ariaLabel` | `string` | `"Slider"` | Accessible label |
|
|
188
|
+
| `theme` | `"dark" \| "light"` | `"dark"` | Adjusts specular and brightness |
|
|
189
|
+
| `width` | `number` | `240` | Total width in px |
|
|
102
190
|
|
|
103
|
-
|
|
104
|
-
scale="{maxScale}" xChannelSelector="R" yChannelSelector="G" />
|
|
105
|
-
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="dispB" />
|
|
191
|
+
---
|
|
106
192
|
|
|
107
|
-
|
|
108
|
-
<feComposite in2="dispB" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="lensResult" />
|
|
193
|
+
### `<LensToggleGroup>`
|
|
109
194
|
|
|
110
|
-
|
|
111
|
-
<!-- Light Theme: Additive Specular -->
|
|
112
|
-
<feColorMatrix in="{isSafari ? 'rawMap' : 'map'}" type="matrix"
|
|
113
|
-
values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 1 0 {-128/255}" result="specMask" />
|
|
114
|
-
<feComposite in="specMask" in2="lensResult" operator="arithmetic"
|
|
115
|
-
k1="0" k2="{specularStrength}" k3="1" k4="0" result="lensResult" />
|
|
195
|
+
A segmented control where a glass pill springs between options.
|
|
116
196
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
197
|
+
```tsx
|
|
198
|
+
import { LensToggleGroup, ToggleOption } from "lensfx";
|
|
199
|
+
|
|
200
|
+
const options: ToggleOption[] = [
|
|
201
|
+
{ id: "day", label: "Day", activeColor1: "#fbbf24", activeColor2: "#f59e0b" },
|
|
202
|
+
{ id: "week", label: "Week", activeColor1: "#38bdf8", activeColor2: "#0284c7" },
|
|
203
|
+
{ id: "month", label: "Month", activeColor1: "#a78bfa", activeColor2: "#7c3aed" },
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
// Uncontrolled
|
|
207
|
+
<LensToggleGroup options={options} defaultValue="week" />
|
|
208
|
+
|
|
209
|
+
// Controlled
|
|
210
|
+
<LensToggleGroup
|
|
211
|
+
options={options}
|
|
212
|
+
value={period}
|
|
213
|
+
onValueChange={setPeriod}
|
|
214
|
+
theme="light"
|
|
215
|
+
/>
|
|
122
216
|
```
|
|
123
217
|
|
|
124
218
|
---
|
|
125
219
|
|
|
126
|
-
##
|
|
220
|
+
## Optical parameters (`LensParams`)
|
|
221
|
+
|
|
222
|
+
Every component accepts a `lens` prop to override these. All values are optional:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
<LensSwitch lens={{ domeDepth: 8, chromaAmount: 1.5 }} />
|
|
226
|
+
```
|
|
127
227
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
228
|
+
| Parameter | Type | Default | Description |
|
|
229
|
+
|---|---|---|---|
|
|
230
|
+
| `lensW` | `number` | `90` | Lens width (px) |
|
|
231
|
+
| `lensH` | `number` | `60` | Lens height (px) |
|
|
232
|
+
| `borderRadius` | `number` | `0` | Corner radius (px) |
|
|
233
|
+
| `depth` | `number` | `0` | Refraction magnitude (0–10) |
|
|
234
|
+
| `domeDepth` | `number` | `0` | Ellipsoid dome height above lens plane (px) |
|
|
235
|
+
| `chromaAmount` | `number` | `0` | RGB split (0–1) |
|
|
236
|
+
| `scaleX` | `number` | `0` | Horizontal displacement scale |
|
|
237
|
+
| `scaleY` | `number` | `0` | Vertical displacement scale |
|
|
238
|
+
| `sdfBoundary` | `boolean` | `false` | Clip displacement to lens SDF boundary |
|
|
239
|
+
| `edgeFalloff` | `boolean` | `false` | Smooth erf() rolloff at edges |
|
|
240
|
+
| `specularStrength` | `number` | `0` | Highlight intensity |
|
|
241
|
+
| `specularRotation` | `number` | `0` | Light angle (degrees) |
|
|
242
|
+
| `glowStrength` | `number` | `0` | Inner glow band (0–1) |
|
|
243
|
+
| `brightness` | `number` | `0` | Brightness overlay (-1 to 1) |
|
|
244
|
+
| `tint` | `number` | `0` | White (positive) or black (negative) tint |
|
|
245
|
+
| `splayAmount` | `number` | `0` | Corner displacement attenuation |
|
|
246
|
+
| `blurAmount` | `number` | `0` | Frosted glass blur radius (px) |
|
|
247
|
+
| `mapSize` | `number` | `256` | Displacement map resolution |
|
|
135
248
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Engine API (advanced)
|
|
252
|
+
|
|
253
|
+
For custom lenses or non-React environments:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import {
|
|
257
|
+
erf,
|
|
258
|
+
computeDomeConstants,
|
|
259
|
+
createDisplacementMapGeneratorSync,
|
|
260
|
+
generateDisplacementMapAsync,
|
|
261
|
+
colorMatrixValues,
|
|
262
|
+
getSvgMaskDataUri,
|
|
263
|
+
} from "lensfx";
|
|
264
|
+
|
|
265
|
+
// Generate a displacement map on every animation frame
|
|
266
|
+
const gen = createDisplacementMapGeneratorSync(256);
|
|
267
|
+
const { dataUrl, loopMs } = gen.generate({
|
|
268
|
+
lensHalfWidth: 60,
|
|
269
|
+
lensHalfHeight: 30,
|
|
270
|
+
borderRadius: 15,
|
|
271
|
+
depth: 2,
|
|
272
|
+
sdfBoundary: true,
|
|
273
|
+
edgeFalloff: true,
|
|
274
|
+
specularRotation: 45,
|
|
275
|
+
glowStrength: 0.5,
|
|
276
|
+
glowSpread: 0.5,
|
|
277
|
+
glowExponent: 1.5,
|
|
278
|
+
edgeStrength: 0,
|
|
279
|
+
edgeWidth: 3,
|
|
280
|
+
edgeExponent: 1.5,
|
|
281
|
+
domeDepth: 5,
|
|
282
|
+
splayAmount: 0.4,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Plug into an SVG feDisplacementMap manually:
|
|
286
|
+
feImageEl.setAttribute("href", dataUrl);
|
|
287
|
+
filterEl.id = `my-lens-v${version}`;
|
|
288
|
+
targetEl.style.filter = `url(#${filterEl.id})`;
|
|
289
|
+
|
|
290
|
+
// When done:
|
|
291
|
+
gen.dispose();
|
|
292
|
+
```
|
|
139
293
|
|
|
140
294
|
---
|
|
141
295
|
|
|
142
|
-
##
|
|
296
|
+
## WebGL renderers (Safari / video)
|
|
143
297
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
$$f(x) = \text{maxOvershoot} \cdot \left(1 - \left(1 - \min\left(1, \frac{x}{\text{dampening}}\right)\right)^3\right)$$
|
|
298
|
+
Safari cannot apply SVG `feDisplacementMap` to live `<video>` frames.
|
|
299
|
+
Use the WebGL fallback:
|
|
147
300
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
301
|
+
```ts
|
|
302
|
+
import { VideoRefractionRenderer } from "lensfx";
|
|
303
|
+
|
|
304
|
+
const renderer = new VideoRefractionRenderer({
|
|
305
|
+
canvas: canvasEl,
|
|
306
|
+
video: videoEl,
|
|
307
|
+
depth: 2,
|
|
308
|
+
chromaAmount: 0.5,
|
|
309
|
+
specularRotation: 45,
|
|
310
|
+
specularStrength: 1,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Call on every animation frame:
|
|
314
|
+
renderer.render({ lensX: 200, lensY: 150, lensW: 120, lensH: 80, borderRadius: 20 });
|
|
315
|
+
```
|
|
154
316
|
|
|
155
317
|
---
|
|
156
318
|
|
|
157
|
-
##
|
|
319
|
+
## Styling & CSS tokens
|
|
158
320
|
|
|
159
|
-
|
|
160
|
-
import { GlassSwitch, GlassSlider, GlassToggleGroup } from "./src";
|
|
321
|
+
Import the stylesheet once at your app root:
|
|
161
322
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
options={[
|
|
174
|
-
{ id: "hubs", label: "Hubs" },
|
|
175
|
-
{ id: "spokes", label: "Spokes" },
|
|
176
|
-
{ id: "reserves", label: "Reserves" },
|
|
177
|
-
]}
|
|
178
|
-
defaultValue="hubs"
|
|
179
|
-
theme="dark"
|
|
180
|
-
/>
|
|
181
|
-
</div>
|
|
182
|
-
);
|
|
323
|
+
```ts
|
|
324
|
+
import "lensfx/styles";
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Override any token:
|
|
328
|
+
|
|
329
|
+
```css
|
|
330
|
+
:root {
|
|
331
|
+
--lensfx-primary: #a78bfa; /* purple accent */
|
|
332
|
+
--lensfx-bg-2: #1e1b4b; /* indigo background */
|
|
333
|
+
--lensfx-radius-full: 9999px;
|
|
183
334
|
}
|
|
184
335
|
```
|
|
336
|
+
|
|
337
|
+
All CSS variables are prefixed `--lensfx-` and listed in [`src/lensfx.css`](./src/lensfx.css).
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Accessibility
|
|
342
|
+
|
|
343
|
+
- `<LensSwitch>` renders a real `<input type="checkbox" role="switch">`. Space and Enter toggle it. Screen readers announce state correctly.
|
|
344
|
+
- `<LensSlider>` renders a real `<input type="range">`. Arrow keys, Home, End all work natively.
|
|
345
|
+
- `<LensToggleGroup>` uses `role="radio"` on each button with `aria-checked`.
|
|
346
|
+
- Refraction is **purely visual** — it does not change focus order, hit-testing, or text selection.
|
|
347
|
+
- `prefers-reduced-motion` is respected: animations are disabled.
|
|
348
|
+
- `prefers-color-scheme` is respected by the CSS token file.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Browser support & limitations
|
|
353
|
+
|
|
354
|
+
| Browser | SVG Refraction | Video Refraction |
|
|
355
|
+
|---|---|---|
|
|
356
|
+
| Chrome / Edge | ✅ Full | ✅ Full |
|
|
357
|
+
| Firefox | ✅ Full | ✅ Full |
|
|
358
|
+
| Safari (desktop) | ✅ Full | ⚠️ Use `VideoRefractionRenderer` |
|
|
359
|
+
| Safari (iOS) | ✅ (pixel coords mode) | ⚠️ Use `VideoRefractionRenderer` |
|
|
360
|
+
|
|
361
|
+
**Performance notes**:
|
|
362
|
+
- The displacement map is generated on the CPU. For large lenses (> 200 × 200 px), `mapSize: 128` provides a good quality/performance trade-off.
|
|
363
|
+
- The `pauseOffscreen` prop stops map generation when the lens is scrolled out of view — highly recommended for long pages.
|
|
364
|
+
- Map generation is 4× faster thanks to quadrant symmetry; it typically takes 1–3 ms on modern hardware at 256 × 256.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## Roadmap
|
|
369
|
+
|
|
370
|
+
- [ ] Playwright visual regression tests (Chromium, Firefox, WebKit)
|
|
371
|
+
- [ ] Storybook documentation site
|
|
372
|
+
- [ ] Vue 3 adapter (`@lensfx/vue`)
|
|
373
|
+
- [ ] `<LensCard>` opinionated card component
|
|
374
|
+
- [ ] Svelte adapter
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## License
|
|
379
|
+
|
|
380
|
+
MIT — see [LICENSE](./LICENSE).
|