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 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
- # LensFX (Aave Glass Optical Refraction Engine)
1
+ # lensfx
2
2
 
3
- > Optical glass simulation and refraction engine for modern web apps.
4
- > Reverse-engineered from `https://aave.com/design/building-glass-for-the-web`.
5
- > Stack: **React 18+**, **Framer Motion**, **SVG Filters**, **WebGL / WebGL 2**.
3
+ **The most convincing, drop-in optical glass refraction for the web.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/lensfx)](https://www.npmjs.com/package/lensfx)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/lensfx)](https://bundlephobia.com/package/lensfx)
7
+ [![license](https://img.shields.io/npm/l/lensfx)](./LICENSE)
8
+ [![CI](https://github.com/rudraksha132/lensfx/actions/workflows/ci.yml/badge.svg)](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
- ## 1. Architectural Overview
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
- LensFX brings real-time optical physics to the DOM and WebGL without resorting to expensive offscreen canvas rasterizers like `html2canvas`. Real DOM elements (text, links, buttons, inputs) remain fully interactive, selectable, and accessible behind the lens.
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
- ## 2. Directory Structure
29
+ ## Quick start
16
30
 
31
+ ```bash
32
+ npm install lensfx framer-motion
17
33
  ```
18
- d:/cf/aave/
19
- ├── src/
20
- │ ├── index.ts # Unified library entry point
21
- │ ├── lib/
22
- │ │ └── glass/
23
- │ │ ├── types.ts # Lens parameters, theme overrides, slot interfaces
24
- │ │ ├── math.ts # SDF, erf, dome constants, sync/async map generators
25
- │ │ └── AaveGlass.tsx # Core <AaveGlass> component & multi-lens manager
26
- │ ├── components/
27
- │ │ ├── GlassSwitch.tsx # Switch with rubber-band overshoot & squish physics
28
- │ │ ├── GlassSlider.tsx # Range slider with track fill refraction target
29
- │ │ ├── GlassToggleGroup.tsx # Segmented control with spring-eased refraction pill
30
- │ │ └── GlassPlayground.tsx # Live benchmark & parameter exploration stage
31
- │ └── webgl/
32
- │ ├── VideoRefractionRenderer.ts # WebGL video player glass engine & shaders
33
- │ └── QRRefractionRenderer.ts # WebGL 2 interactive QR code refraction engine
34
- └── README.md
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
- ## 3. Mathematical Foundations
40
-
41
- ### 3.1 Signed Distance Function (SDF) of Rounded Rectangle
42
- The displacement map calculates pixel distance from the rounded rectangle lens:
43
- $$\text{ox} = \frac{\text{col} + 0.5}{\text{size}} \cdot 2 \cdot \text{halfW} - \text{halfW}$$
44
- $$\text{oy} = \frac{\text{row} + 0.5}{\text{size}} \cdot 2 \cdot \text{halfH} - \text{halfH}$$
45
- $$d_x = \max(|\text{ox}| - \text{halfW} + R, 0), \quad d_y = \max(|\text{oy}| - \text{halfH} + R, 0)$$
46
- $$\text{sdf} = \sqrt{d_x^2 + d_y^2} + \min(\max(|\text{ox}| - \text{halfW} + R, |\text{oy}| - \text{halfH} + R), 0) - R$$
47
-
48
- ### 3.2 Error Function (`erf`) Approximation
49
- Used for smooth edge falloff without performance penalties:
50
- $$\text{erf}(x) = \tanh(1.7724538509 \cdot x), \quad \text{where } \sqrt{\pi} \approx 1.7724538509$$
51
- $$\text{falloff} = 0.5 \cdot \left(1 + \text{erf}\left(\text{innerSDF} \cdot \frac{1}{\text{depth} \cdot \sqrt{2}}\right)\right)$$
52
-
53
- ### 3.3 3D Dome Geometry Integration
54
- For parabolic / spherical curvature (`domeDepth > 0`):
55
- $$a = \max(0.01, \min(\text{domeDepth}, \min(\text{halfW}, \text{halfH}) - 1))$$
56
- $$R_x = \frac{\text{halfW}^2 + a^2}{2a}, \quad R_y = \frac{\text{halfH}^2 + a^2}{2a}$$
57
- Numerical integration over 200 samples computes normalisation factors `scaleX` and `scaleY` so the integral sums to $0.5$:
58
- $$\nabla_{\text{dome}}(p, R) = \frac{\min(p, 0.999R)}{\sqrt{R^2 - \min(p, 0.999R)^2}} \cdot \text{scale}$$
59
-
60
- ### 3.4 Four-Fold Quadrant Symmetry
61
- For dynamic lenses, the top-left quadrant $[0 \dots \frac{\text{size}}{2}, 0 \dots \frac{\text{size}}{2}]$ is computed once and written directly into all 4 quadrants of the `ImageData` buffer simultaneously:
62
- ```javascript
63
- // Top-Left: (col, row)
64
- // Top-Right: (size - 1 - col, row) [negate R displacement]
65
- // Bottom-Left: (col, size - 1 - row) [negate G displacement]
66
- // Bottom-Right: (size - 1 - col, size - 1 - row) [negate R & G displacement]
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
- ## 4. SVG Filter Pipeline
112
+ ### `<LensSwitch>`
73
113
 
74
- The dynamic SVG filter rendered in `<AaveGlass>`:
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
- ```xml
77
- <filter filterUnits="objectBoundingBox" primitiveUnits="objectBoundingBox"
78
- color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1">
79
- <!-- 1. Neutral gray background (128, 128, 128) -->
80
- <feFlood floodColor="rgb(128,128,128)" floodOpacity="1" result="mapBg" />
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
- <!-- 2. Dynamic Displacement Map -->
83
- <feImage href="{dataUrl}" preserveAspectRatio="none" result="rawMap"
84
- x="{lensLeft}" y="{lensTop}" width="{lensW}" height="{lensH}" />
150
+ ---
85
151
 
86
- <!-- 3. Composite over neutral gray outside lens -->
87
- <feComposite in="rawMap" in2="mapBg" operator="over" result="map" />
152
+ ### `<LensSlider>`
88
153
 
89
- <!-- 4. Aspect-ratio scaling feColorMatrix -->
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
- <!-- 5. Chromatic Aberration 3-Pass Displacement -->
95
- <feDisplacementMap in="SourceGraphic" in2="scaledMap"
96
- scale="{maxScale * (1 + 0.2 * chroma)}" xChannelSelector="R" yChannelSelector="G" />
97
- <feColorMatrix type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="dispR" />
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
- <feDisplacementMap in="SourceGraphic" in2="scaledMap"
100
- scale="{maxScale * (1 + 0.1 * chroma)}" xChannelSelector="R" yChannelSelector="G" />
101
- <feColorMatrix type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="dispG" />
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
- <feDisplacementMap in="SourceGraphic" in2="scaledMap"
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
- <feComposite in="dispR" in2="dispG" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" />
108
- <feComposite in2="dispB" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="lensResult" />
193
+ ### `<LensToggleGroup>`
109
194
 
110
- <!-- 6. Specular Highlight Pass -->
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
- <!-- 7. Hole Cutout & Over Composite -->
118
- <feFlood floodColor="black" floodOpacity="1" result="lensMask" />
119
- <feComposite in="SourceGraphic" in2="lensMask" operator="out" result="holedSG" />
120
- <feComposite in="lensResult" in2="holedSG" operator="over" />
121
- </filter>
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
- ## 5. WebGL Fallback Pipelines
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
- ### 5.1 Video Player Refraction (`VideoRefractionRenderer.ts`)
129
- * **Analytical SDF Evaluation**: Evaluates 3 circle controls and 1 rounded-rectangle seek bar analytically in the fragment shader.
130
- * **Separable 9-Tap Blur**: Runs a half-resolution 2-pass blur using kernel:
131
- `[0.0276, 0.0663, 0.1240, 0.1801, 0.2042, 0.1801, 0.1240, 0.0663, 0.0276]`
132
- * **Apple-Style Vibrancy**: Dynamically balances specular highlight between additive (dark background) and multiplicative (bright background) based on perceptual luminance:
133
- $$\text{luma} = 0.299R + 0.587G + 0.114B$$
134
- $$\text{correction} = (0.5 - \text{luma}) \cdot \text{adaptStrength} \cdot \text{mask}$$
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
- ### 5.2 QR Code Refraction (`QRRefractionRenderer.ts`)
137
- * **WebGL 2 (`#version 300 es`)**: Uses integer texelFetch into an `R8` occupancy texture for $O(1)$ grid lookup.
138
- * **Analytical Finder Eye SDFs**: Renders three corner detection eyes with animated hover scales and attenuated refraction displacement.
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
- ## 6. Component Physics & Interaction Mechanics
296
+ ## WebGL renderers (Safari / video)
143
297
 
144
- ### Rubber-Band Overshoot Formula
145
- Used across `<GlassSwitch>` and `<GlassSlider>`:
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
- ### Perpendicular Squish Simulation Loop
149
- Models velocity-induced lens deformation with a damped harmonic oscillator:
150
- $$\text{targetSquish} = \min\left(0.35, \max(0.012 \cdot |\text{velocity}|^{0.75}, \text{hold})\right)$$
151
- $$a = -180 \cdot (\text{squish} - \text{targetSquish}) - 14 \cdot \text{squishVelocity}$$
152
- $$\text{lensWidth} = W_{\text{base}} \cdot (1 - 0.2 \cdot \text{squish})$$
153
- $$\text{lensHeight} = H_{\text{base}} \cdot (1 + 0.4 \cdot \text{squish})$$
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
- ## 7. Quick Usage Example
319
+ ## Styling & CSS tokens
158
320
 
159
- ```tsx
160
- import { GlassSwitch, GlassSlider, GlassToggleGroup } from "./src";
321
+ Import the stylesheet once at your app root:
161
322
 
162
- export function Demo() {
163
- return (
164
- <div style={{ display: "flex", flexDirection: "column", gap: "24px", padding: "40px" }}>
165
- {/* Switch */}
166
- <GlassSwitch defaultChecked={true} theme="dark" />
167
-
168
- {/* Slider */}
169
- <GlassSlider defaultValue={50} min={0} max={100} theme="dark" />
170
-
171
- {/* Toggle Group */}
172
- <GlassToggleGroup
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).