@robr0/design-system 0.6.0 → 0.7.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.
@@ -0,0 +1,177 @@
1
+ const BLOB_COUNT = 8;
2
+ const vertexSource = (
3
+ /* glsl */
4
+ `#version 300 es
5
+ // Fullscreen triangle — no buffers, three vertices from gl_VertexID.
6
+ void main() {
7
+ vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
8
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
9
+ }
10
+ `
11
+ );
12
+ const fragmentSource = (
13
+ /* glsl */
14
+ `#version 300 es
15
+ precision highp float;
16
+
17
+ uniform vec2 u_resolution; // drawing-buffer size in pixels
18
+ uniform float u_time; // field time, seconds (speed applied JS-side)
19
+ uniform vec3 u_color[${BLOB_COUNT}]; // linear RGB per blob
20
+ uniform vec4 u_blob[${BLOB_COUNT}]; // xy = centre, z = sigma, w = emission weight
21
+ uniform vec4 u_motion[${BLOB_COUNT}]; // xy = angular speeds, zw = phases
22
+ uniform float u_intensity; // peak-alpha ceiling
23
+ uniform float u_warp; // domain-warp strength
24
+ uniform float u_scale; // noise frequency
25
+ uniform float u_grain; // film-grain strength
26
+ uniform float u_streak; // anisotropic stretch: 0 = blobs, 1 = light streams
27
+ uniform vec2 u_mouse; // pointer in field coordinates (smoothed JS-side)
28
+ uniform float u_mvel; // smoothed pointer speed, width units/s
29
+ uniform float u_react; // cursor-reactivity strength
30
+
31
+ out vec4 outColor;
32
+
33
+ // Integer hash — WebGL2 has real uint ops, so no fract(sin()) precision
34
+ // lottery on mobile GPUs.
35
+ uint ihash(uvec2 p) {
36
+ p = p * uvec2(73333u, 7777u) ^ (p.yx >> 3u);
37
+ uint h = p.x ^ (p.y * 2654435761u);
38
+ h ^= h >> 15u;
39
+ h *= 2246822519u;
40
+ h ^= h >> 13u;
41
+ return h;
42
+ }
43
+
44
+ float rnd(ivec2 p) {
45
+ return float(ihash(uvec2(p)) >> 8) * (1.0 / 16777216.0);
46
+ }
47
+
48
+ float vnoise(vec2 p) {
49
+ vec2 i = floor(p);
50
+ vec2 f = fract(p);
51
+ vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
52
+ ivec2 c = ivec2(i);
53
+ return mix(
54
+ mix(rnd(c), rnd(c + ivec2(1, 0)), u.x),
55
+ mix(rnd(c + ivec2(0, 1)), rnd(c + ivec2(1, 1)), u.x),
56
+ u.y
57
+ );
58
+ }
59
+
60
+ // 3 octaves; a 4th is invisible at this softness.
61
+ float fbm(vec2 p) {
62
+ float s = 0.0;
63
+ float a = 0.5;
64
+ for (int i = 0; i < 3; i++) {
65
+ s += a * vnoise(p);
66
+ p = p * 2.02 + vec2(17.0, 9.0);
67
+ a *= 0.5;
68
+ }
69
+ return s;
70
+ }
71
+
72
+ void main() {
73
+ // CSS coordinate space: origin at container centre, one unit = width,
74
+ // y running downward.
75
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / u_resolution.x;
76
+ p.y = -p.y;
77
+
78
+ // Light-stream axis: a fixed diagonal. Distances across the axis are
79
+ // magnified before the Gaussian, so every source stretches along it and
80
+ // the field reads as rays instead of discs.
81
+ float ca = cos(0.5);
82
+ float sa = sin(0.5);
83
+ mat2 toStreak = mat2(ca, -sa, sa, ca);
84
+ float squeeze = 1.0 + u_streak * 4.0;
85
+
86
+ // Domain warp: the field flows rather than drifts. The noise is sampled
87
+ // in the same anisotropic space, so the flow lines follow the streams.
88
+ float t = u_time;
89
+ vec2 ps = toStreak * p;
90
+ ps.y *= squeeze;
91
+ vec2 q = vec2(
92
+ fbm(ps * u_scale + vec2(0.0, t * 0.030)),
93
+ fbm(ps * u_scale + vec2(5.2, 1.3) - t * 0.024)
94
+ );
95
+ vec2 w = p + u_warp * (q - 0.5);
96
+
97
+ // Cursor wake: a smooth vortex around the pointer, amplified by speed.
98
+ // The displacement is built from the *unnormalised* offset so it falls to
99
+ // zero at the cursor itself. Normalising here (md/mr) made the direction
100
+ // spin through 360 degrees at full strength across the centre pixel, which
101
+ // rendered as a pinch — a cone converging to a point.
102
+ vec2 md = p - u_mouse;
103
+ float mr2 = dot(md, md);
104
+ float minf = exp(-mr2 / (2.0 * 0.22 * 0.22));
105
+ vec2 swirl = vec2(-md.y, md.x);
106
+ // smoothstep, not min(): a hard clamp stepped visibly as speed crossed it.
107
+ float stir = 0.25 + 1.3 * smoothstep(0.0, 0.8, u_mvel);
108
+ w += u_react * minf * stir * (swirl * 0.9 + md * 0.35);
109
+
110
+ // Accumulate true Gaussians in linear space. Positive weights emit;
111
+ // negative weights absorb (shadows — the eclipse disc).
112
+ vec3 acc = vec3(0.0);
113
+ float cov = 0.0;
114
+ float shade = 0.0;
115
+ for (int i = 0; i < ${BLOB_COUNT}; i++) {
116
+ vec2 c = u_blob[i].xy + 0.045 * vec2(
117
+ cos(u_motion[i].x * t + u_motion[i].z),
118
+ sin(u_motion[i].y * t + u_motion[i].w)
119
+ );
120
+ vec2 d = toStreak * (w - c);
121
+ d.y *= squeeze;
122
+ float s = u_blob[i].z;
123
+ float g = exp(-dot(d, d) / (2.0 * s * s));
124
+ float wgt = u_blob[i].w;
125
+ if (wgt >= 0.0) {
126
+ acc += u_color[i] * (g * wgt);
127
+ cov += g * wgt;
128
+ } else {
129
+ shade += g * -wgt;
130
+ }
131
+ }
132
+
133
+ // Glow trail: wider and softer than the swirl, and mostly velocity-driven,
134
+ // so a resting cursor does not park a bright dot on the field.
135
+ //
136
+ // The wake brightens whatever colour it is passing over rather than adding
137
+ // one of its own. Injecting u_color[0] meant the trail always wore the
138
+ // first blob's token — gold in the site palette, violet in beam — which
139
+ // read as a stray colour with no relationship to the field beneath it.
140
+ // Weighting by the local hue keeps the wake colour-neutral: it reads as
141
+ // light falling on the field, and it stays correct for any palette.
142
+ vec3 local = acc / max(cov, 1e-4);
143
+ float gfall = exp(-mr2 / (2.0 * 0.3 * 0.3));
144
+ float glow = u_react * gfall * (0.12 + 0.5 * smoothstep(0.0, 0.8, u_mvel));
145
+ acc += local * glow;
146
+ cov += glow;
147
+
148
+ vec3 lin = acc / max(cov, 1e-4);
149
+ float lit = max(cov - shade, 0.0);
150
+ float alpha = u_intensity * (1.0 - exp(-lit * 0.9));
151
+
152
+ vec3 srgb = pow(max(lin, vec3(0.0)), vec3(1.0 / 2.2));
153
+
154
+ // Film grain: animated hash noise. At u_grain 0 this degenerates to the
155
+ // ±1/255 dither that kills banding, so the two share one lookup.
156
+ uvec2 gp = uvec2(gl_FragCoord.xy) + uvec2(
157
+ uint(fract(t * 7.31) * 1024.0) * 7919u,
158
+ uint(fract(t * 3.17) * 1024.0) * 104729u
159
+ );
160
+ float gr = float(ihash(gp) & 255u) / 255.0 - 0.5;
161
+ srgb += gr * (1.0 / 255.0 + u_grain * 0.35);
162
+ // A small grain-driven alpha floor lets the sparkle read even where the
163
+ // field itself is empty (the reference's grainy dark scene).
164
+ float grainFloor = u_grain * 0.1 * (gr + 0.5);
165
+ alpha = clamp(alpha * (1.0 + gr * u_grain * 0.5) + grainFloor, 0.0, 1.0);
166
+ srgb = clamp(srgb, 0.0, 1.0);
167
+
168
+ // Premultiplied output (context is created with premultipliedAlpha: true).
169
+ outColor = vec4(srgb * alpha, alpha);
170
+ }
171
+ `
172
+ );
173
+ export {
174
+ BLOB_COUNT,
175
+ fragmentSource,
176
+ vertexSource
177
+ };
@@ -0,0 +1,86 @@
1
+ import { RefObject } from 'react';
2
+ /** The seven tuneable properties of the field. */
3
+ export interface ShaderParams {
4
+ /** Overall opacity of the field, 0.1–1. */
5
+ intensity: number;
6
+ /** Domain-warp strength: how much fbm noise bends the field, 0–0.5. */
7
+ warp: number;
8
+ /** Noise frequency. Higher values give finer, busier structure, 0.5–6. */
9
+ scale: number;
10
+ /** Drift-rate multiplier on every blob's period, 0–4. */
11
+ speed: number;
12
+ /** Dither/film-grain amplitude, 0–0.4. Also what kills 8-bit banding. */
13
+ grain: number;
14
+ /** Anisotropic stretch: 0 renders discs, 1 renders diagonal streams, 0–1. */
15
+ streak: number;
16
+ /**
17
+ * Cursor-wake strength, 0–1. Defaults to 0 — the interaction is built and
18
+ * dormant, not absent. Raising it turns on the swirl and glow trail, and
19
+ * with them the loop's step up from 30fps to 60fps while a wake is alive.
20
+ */
21
+ react: number;
22
+ }
23
+ /**
24
+ * One light source in the field.
25
+ *
26
+ * The coordinate space is the shader's (see field.glsl): origin at the
27
+ * container centre, one unit = container width, y running downward. Centres
28
+ * are constants — nothing here is recomputed on resize.
29
+ */
30
+ export interface ShaderBlob {
31
+ /**
32
+ * Semantic colour token, read from the canvas's computed style at runtime,
33
+ * so the field re-themes with `data-theme` and with any scoped custom-
34
+ * property override. A token nothing defines resolves to no colour — an
35
+ * invisible source, never an error.
36
+ */
37
+ token: string;
38
+ /** Disc diameter in container-width units. */
39
+ size: number;
40
+ /** Centre offset from the container centre, width units. */
41
+ cx: number;
42
+ /** Centre offset from the container centre, width units. */
43
+ cy: number;
44
+ /** Drift cycle in seconds, before `speed` is applied. */
45
+ period: number;
46
+ /** Phase offset, so sources never move in lockstep. */
47
+ phase: number;
48
+ /**
49
+ * Emission weight. Positive emits light; negative absorbs it, giving a
50
+ * shadow that occludes whatever shines behind it.
51
+ */
52
+ weight: number;
53
+ }
54
+ /**
55
+ * The shipped look: seven parameters tuned by eye, cursor wake off. Spread it
56
+ * and override the one or two you want rather than restating the set.
57
+ */
58
+ export declare const DEFAULT_SHADER_PARAMS: ShaderParams;
59
+ /**
60
+ * The default composition: eight sources on core accent tokens, filling
61
+ * BLOB_COUNT exactly.
62
+ *
63
+ * `--color-action-primary-bg` is deliberately absent. The action colour is
64
+ * reserved for CTAs and focus rings, and a full-viewport decorative field is
65
+ * precisely the use that would stop it meaning "click here" — so the last
66
+ * source takes `--color-core-ui-secondary` instead.
67
+ */
68
+ export declare const DEFAULT_SHADER_BLOBS: readonly ShaderBlob[];
69
+ /**
70
+ * Live tunables. Changing them never rebuilds the GL state — the render loop
71
+ * reads them through a ref, and a paused field redraws one frame.
72
+ */
73
+ export interface ShaderFieldParams extends ShaderParams {
74
+ /** The colour-source table; swap it to change composition live. */
75
+ blobs: readonly ShaderBlob[];
76
+ }
77
+ export interface ShaderFieldState {
78
+ /** False once getContext('webgl2') has returned null — the caller should
79
+ * leave its CSS fallback painted. */
80
+ supported: boolean;
81
+ /** True once the first frame has been drawn. */
82
+ active: boolean;
83
+ }
84
+ export declare function parseColor(value: string): [number, number, number] | null;
85
+ export declare function srgbToLinear(c: number): number;
86
+ export declare function useShaderField(canvasRef: RefObject<HTMLCanvasElement | null>, params: ShaderFieldParams, enabled?: boolean): ShaderFieldState;
@@ -0,0 +1,395 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { BLOB_COUNT, vertexSource, fragmentSource } from "./field.glsl.js";
3
+ const DEFAULT_SHADER_PARAMS = {
4
+ intensity: 0.5,
5
+ warp: 0.14,
6
+ scale: 2.7,
7
+ speed: 2,
8
+ grain: 0.1,
9
+ streak: 0.4,
10
+ react: 0
11
+ };
12
+ const DEFAULT_SHADER_BLOBS = [
13
+ { token: "--color-core-accent-gold", size: 0.45, cx: 0.175, cy: 0.125, period: 18, phase: 0, weight: 1 },
14
+ { token: "--color-core-accent-mint", size: 0.55, cx: 0.125, cy: 0.125, period: 16, phase: 1, weight: 1 },
15
+ { token: "--color-core-accent-violet", size: 0.9, cx: 0.35, cy: 0.25, period: 22, phase: 0.5, weight: 1 },
16
+ { token: "--color-bg-container-secondary", size: 0.55, cx: 0.025, cy: 0.075, period: 14, phase: 1.5, weight: 1 },
17
+ { token: "--color-core-accent-cobalt", size: 0.55, cx: -0.125, cy: 0.475, period: 17, phase: 0.8, weight: 1 },
18
+ { token: "--color-core-accent-coral", size: 0.48, cx: 0.39, cy: -0.26, period: 19, phase: 0.3, weight: 1 },
19
+ { token: "--color-core-accent-amber", size: 0.3, cx: 0.35, cy: 0.3, period: 15, phase: 1.2, weight: 1 },
20
+ { token: "--color-core-ui-secondary", size: 0.75, cx: -0.125, cy: -0.025, period: 20, phase: 0.7, weight: 1 }
21
+ ];
22
+ const RENDER_SCALE = 0.5;
23
+ const FRAME_MS = 1e3 / 30;
24
+ const FRAME_MS_ACTIVE = 1e3 / 60;
25
+ const WAKE_IDLE = 0.015;
26
+ const COLOR_FADE_MS = 300;
27
+ function parseColor(value) {
28
+ const v = value.trim();
29
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v);
30
+ if (hex) {
31
+ const h = hex[1];
32
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
33
+ return [
34
+ parseInt(full.slice(0, 2), 16) / 255,
35
+ parseInt(full.slice(2, 4), 16) / 255,
36
+ parseInt(full.slice(4, 6), 16) / 255
37
+ ];
38
+ }
39
+ const rgb = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/.exec(v);
40
+ if (rgb) {
41
+ return [Number(rgb[1]) / 255, Number(rgb[2]) / 255, Number(rgb[3]) / 255];
42
+ }
43
+ return null;
44
+ }
45
+ function srgbToLinear(c) {
46
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
47
+ }
48
+ function compile(gl, type, source) {
49
+ const shader = gl.createShader(type);
50
+ if (!shader) return null;
51
+ gl.shaderSource(shader, source);
52
+ gl.compileShader(shader);
53
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
54
+ console.error("ShaderField compile error:", gl.getShaderInfoLog(shader));
55
+ gl.deleteShader(shader);
56
+ return null;
57
+ }
58
+ return shader;
59
+ }
60
+ function useShaderField(canvasRef, params, enabled = true) {
61
+ const [supported, setSupported] = useState(true);
62
+ const [active, setActive] = useState(false);
63
+ const paramsRef = useRef(params);
64
+ const redrawRef = useRef(() => {
65
+ });
66
+ useEffect(() => {
67
+ paramsRef.current = params;
68
+ redrawRef.current();
69
+ }, [params]);
70
+ useEffect(() => {
71
+ const canvas = canvasRef.current;
72
+ if (!canvas || !enabled) return;
73
+ const gl = canvas.getContext("webgl2", {
74
+ alpha: true,
75
+ antialias: false,
76
+ depth: false,
77
+ stencil: false,
78
+ premultipliedAlpha: true,
79
+ powerPreference: "low-power"
80
+ });
81
+ if (!gl) {
82
+ setSupported(false);
83
+ return;
84
+ }
85
+ if (!gl.isContextLost()) {
86
+ canvas.__dsLoseExt = gl.getExtension("WEBGL_lose_context");
87
+ }
88
+ const loseExt = () => gl.getExtension("WEBGL_lose_context") ?? canvas.__dsLoseExt ?? null;
89
+ let program = null;
90
+ let vao = null;
91
+ let loc = {};
92
+ function initGL() {
93
+ if (!gl) return false;
94
+ const vs = compile(gl, gl.VERTEX_SHADER, vertexSource);
95
+ const fs = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
96
+ if (!vs || !fs) return false;
97
+ program = gl.createProgram();
98
+ if (!program) return false;
99
+ gl.attachShader(program, vs);
100
+ gl.attachShader(program, fs);
101
+ gl.linkProgram(program);
102
+ gl.deleteShader(vs);
103
+ gl.deleteShader(fs);
104
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
105
+ console.error("ShaderField link error:", gl.getProgramInfoLog(program));
106
+ return false;
107
+ }
108
+ gl.useProgram(program);
109
+ vao = gl.createVertexArray();
110
+ gl.bindVertexArray(vao);
111
+ gl.disable(gl.BLEND);
112
+ gl.disable(gl.DEPTH_TEST);
113
+ loc = {};
114
+ for (const name of [
115
+ "u_resolution",
116
+ "u_time",
117
+ "u_color[0]",
118
+ "u_blob[0]",
119
+ "u_motion[0]",
120
+ "u_intensity",
121
+ "u_warp",
122
+ "u_scale",
123
+ "u_grain",
124
+ "u_streak",
125
+ "u_mouse",
126
+ "u_mvel",
127
+ "u_react"
128
+ ]) {
129
+ loc[name] = gl.getUniformLocation(program, name);
130
+ }
131
+ return true;
132
+ }
133
+ let uploadedBlobs = null;
134
+ function uploadBlobs(blobs) {
135
+ if (!gl) return;
136
+ const blob = new Float32Array(BLOB_COUNT * 4);
137
+ const motion = new Float32Array(BLOB_COUNT * 4);
138
+ for (let i = 0; i < BLOB_COUNT; i++) {
139
+ const b = blobs[i];
140
+ if (!b) {
141
+ blob[i * 4 + 0] = 99;
142
+ blob[i * 4 + 1] = 99;
143
+ blob[i * 4 + 2] = 0.01;
144
+ continue;
145
+ }
146
+ blob[i * 4 + 0] = b.cx;
147
+ blob[i * 4 + 1] = b.cy;
148
+ blob[i * 4 + 2] = b.size * 0.42;
149
+ blob[i * 4 + 3] = b.weight;
150
+ const wx = 2 * Math.PI / b.period;
151
+ motion[i * 4 + 0] = wx;
152
+ motion[i * 4 + 1] = wx * 0.83;
153
+ motion[i * 4 + 2] = b.phase * 2.1;
154
+ motion[i * 4 + 3] = b.phase * 3.7;
155
+ }
156
+ gl.uniform4fv(loc["u_blob[0]"], blob);
157
+ gl.uniform4fv(loc["u_motion[0]"], motion);
158
+ uploadedBlobs = blobs;
159
+ }
160
+ const colorCurrent = new Float32Array(BLOB_COUNT * 3);
161
+ const colorTarget = new Float32Array(BLOB_COUNT * 3);
162
+ let colorFadeT = COLOR_FADE_MS;
163
+ function readColors(into) {
164
+ const style = getComputedStyle(canvas);
165
+ into.fill(0);
166
+ paramsRef.current.blobs.slice(0, BLOB_COUNT).forEach((b, i) => {
167
+ const parsed = parseColor(style.getPropertyValue(b.token));
168
+ if (parsed) {
169
+ into[i * 3 + 0] = srgbToLinear(parsed[0]);
170
+ into[i * 3 + 1] = srgbToLinear(parsed[1]);
171
+ into[i * 3 + 2] = srgbToLinear(parsed[2]);
172
+ }
173
+ });
174
+ }
175
+ readColors(colorCurrent);
176
+ colorTarget.set(colorCurrent);
177
+ let bufWidth = 0;
178
+ let bufHeight = 0;
179
+ function resize() {
180
+ if (!gl) return;
181
+ const c = canvas;
182
+ const dpr = window.devicePixelRatio || 1;
183
+ const w = Math.max(1, Math.round(c.clientWidth * dpr * RENDER_SCALE));
184
+ const h = Math.max(1, Math.round(c.clientHeight * dpr * RENDER_SCALE));
185
+ if (w === bufWidth && h === bufHeight) return;
186
+ bufWidth = w;
187
+ bufHeight = h;
188
+ c.width = w;
189
+ c.height = h;
190
+ gl.viewport(0, 0, w, h);
191
+ gl.uniform2f(loc["u_resolution"], w, h);
192
+ }
193
+ let fieldTime = 0;
194
+ let colorsDirty = false;
195
+ const mouseTarget = { x: 9, y: 9 };
196
+ const mouse = { x: 9, y: 9 };
197
+ let mouseVel = 0;
198
+ let moveAccum = 0;
199
+ const onPointerMove = (e) => {
200
+ const c = canvas;
201
+ const rect = c.getBoundingClientRect();
202
+ if (rect.width === 0) return;
203
+ const x = (e.clientX - rect.left - rect.width / 2) / rect.width;
204
+ const y = (e.clientY - rect.top - rect.height / 2) / rect.width;
205
+ if (mouseTarget.x < 5) {
206
+ moveAccum += Math.hypot(x - mouseTarget.x, y - mouseTarget.y);
207
+ }
208
+ mouseTarget.x = x;
209
+ mouseTarget.y = y;
210
+ if (mouse.x > 5) {
211
+ mouse.x = x;
212
+ mouse.y = y;
213
+ }
214
+ };
215
+ window.addEventListener("pointermove", onPointerMove, { passive: true });
216
+ function draw(dtMs) {
217
+ if (!gl || !program) return;
218
+ resize();
219
+ if (paramsRef.current.blobs !== uploadedBlobs) {
220
+ uploadBlobs(paramsRef.current.blobs);
221
+ colorsDirty = true;
222
+ }
223
+ if (colorsDirty) {
224
+ readColors(colorTarget);
225
+ colorFadeT = 0;
226
+ colorsDirty = false;
227
+ }
228
+ if (colorFadeT < COLOR_FADE_MS) {
229
+ colorFadeT = Math.min(colorFadeT + dtMs, COLOR_FADE_MS);
230
+ const t = colorFadeT / COLOR_FADE_MS;
231
+ for (let i = 0; i < colorCurrent.length; i++) {
232
+ colorCurrent[i] += (colorTarget[i] - colorCurrent[i]) * t;
233
+ }
234
+ }
235
+ const p = paramsRef.current;
236
+ gl.uniform3fv(loc["u_color[0]"], colorCurrent);
237
+ gl.uniform1f(loc["u_time"], fieldTime);
238
+ gl.uniform1f(loc["u_intensity"], p.intensity);
239
+ gl.uniform1f(loc["u_warp"], p.warp);
240
+ gl.uniform1f(loc["u_scale"], p.scale);
241
+ gl.uniform1f(loc["u_grain"], p.grain);
242
+ gl.uniform1f(loc["u_streak"], p.streak);
243
+ const dtSec = Math.max(dtMs / 1e3, 1e-3);
244
+ const k = 1 - Math.exp(-dtSec * 14);
245
+ mouse.x += (mouseTarget.x - mouse.x) * k;
246
+ mouse.y += (mouseTarget.y - mouse.y) * k;
247
+ const instant = moveAccum / dtSec;
248
+ moveAccum = 0;
249
+ const blend = 1 - Math.exp(-dtSec * (instant > mouseVel ? 14 : 2.2));
250
+ mouseVel += (instant - mouseVel) * blend;
251
+ gl.uniform2f(loc["u_mouse"], mouse.x, mouse.y);
252
+ gl.uniform1f(loc["u_mvel"], mouseVel);
253
+ gl.uniform1f(loc["u_react"], p.react);
254
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
255
+ frameBudget = mouseVel > WAKE_IDLE && p.react > 0 ? FRAME_MS_ACTIVE : FRAME_MS;
256
+ }
257
+ let raf = 0;
258
+ let lastDraw = 0;
259
+ let running = false;
260
+ let disposed = false;
261
+ let frameBudget = FRAME_MS;
262
+ const reduceQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
263
+ function frame(now) {
264
+ raf = requestAnimationFrame(frame);
265
+ const elapsed = now - lastDraw;
266
+ if (elapsed < frameBudget - 1) return;
267
+ const dt = Math.min(elapsed, 100);
268
+ lastDraw = now;
269
+ fieldTime += dt / 1e3 * paramsRef.current.speed;
270
+ draw(dt);
271
+ }
272
+ function start() {
273
+ if (running || disposed || reduceQuery.matches || document.hidden) return;
274
+ running = true;
275
+ lastDraw = performance.now();
276
+ raf = requestAnimationFrame(frame);
277
+ }
278
+ function stop() {
279
+ running = false;
280
+ cancelAnimationFrame(raf);
281
+ }
282
+ function renderOnce() {
283
+ if (disposed || running) return;
284
+ draw(COLOR_FADE_MS);
285
+ }
286
+ redrawRef.current = renderOnce;
287
+ const onReduceChange = () => {
288
+ if (reduceQuery.matches) {
289
+ stop();
290
+ renderOnce();
291
+ } else {
292
+ start();
293
+ }
294
+ };
295
+ reduceQuery.addEventListener("change", onReduceChange);
296
+ const onVisibility = () => {
297
+ if (document.hidden) stop();
298
+ else start();
299
+ };
300
+ document.addEventListener("visibilitychange", onVisibility);
301
+ const themeObserver = new MutationObserver(() => {
302
+ colorsDirty = true;
303
+ if (!running) {
304
+ readColors(colorTarget);
305
+ colorCurrent.set(colorTarget);
306
+ colorsDirty = false;
307
+ renderOnce();
308
+ }
309
+ });
310
+ themeObserver.observe(document.documentElement, {
311
+ attributes: true,
312
+ attributeFilter: ["data-theme", "style"]
313
+ });
314
+ const resizeObserver = new ResizeObserver(() => {
315
+ if (!running) {
316
+ renderOnce();
317
+ }
318
+ });
319
+ resizeObserver.observe(canvas);
320
+ let restoreTimer = 0;
321
+ const onContextLost = (e) => {
322
+ e.preventDefault();
323
+ stop();
324
+ setActive(false);
325
+ window.clearTimeout(restoreTimer);
326
+ restoreTimer = window.setTimeout(() => {
327
+ if (disposed || !gl.isContextLost()) return;
328
+ try {
329
+ loseExt()?.restoreContext();
330
+ } catch {
331
+ setSupported(false);
332
+ }
333
+ }, 700);
334
+ };
335
+ const onContextRestored = () => {
336
+ if (initGL()) {
337
+ bufWidth = 0;
338
+ bufHeight = 0;
339
+ uploadedBlobs = null;
340
+ colorsDirty = true;
341
+ setActive(true);
342
+ if (reduceQuery.matches) renderOnce();
343
+ else start();
344
+ }
345
+ };
346
+ canvas.addEventListener("webglcontextlost", onContextLost);
347
+ canvas.addEventListener("webglcontextrestored", onContextRestored);
348
+ if (gl.isContextLost()) {
349
+ const ext = loseExt();
350
+ if (!ext) {
351
+ queueMicrotask(() => setSupported(false));
352
+ } else {
353
+ try {
354
+ ext.restoreContext();
355
+ } catch {
356
+ queueMicrotask(() => setSupported(false));
357
+ }
358
+ }
359
+ } else if (initGL()) {
360
+ draw(COLOR_FADE_MS);
361
+ setActive(true);
362
+ start();
363
+ } else {
364
+ setSupported(false);
365
+ }
366
+ return () => {
367
+ disposed = true;
368
+ stop();
369
+ window.clearTimeout(restoreTimer);
370
+ redrawRef.current = () => {
371
+ };
372
+ window.removeEventListener("pointermove", onPointerMove);
373
+ reduceQuery.removeEventListener("change", onReduceChange);
374
+ document.removeEventListener("visibilitychange", onVisibility);
375
+ themeObserver.disconnect();
376
+ resizeObserver.disconnect();
377
+ canvas.removeEventListener("webglcontextlost", onContextLost);
378
+ canvas.removeEventListener("webglcontextrestored", onContextRestored);
379
+ if (program) gl.deleteProgram(program);
380
+ if (vao) gl.deleteVertexArray(vao);
381
+ try {
382
+ if (!gl.isContextLost()) loseExt()?.loseContext();
383
+ } catch {
384
+ }
385
+ };
386
+ }, [canvasRef, enabled]);
387
+ return { supported, active };
388
+ }
389
+ export {
390
+ DEFAULT_SHADER_BLOBS,
391
+ DEFAULT_SHADER_PARAMS,
392
+ parseColor,
393
+ srgbToLinear,
394
+ useShaderField
395
+ };
@@ -516,6 +516,14 @@
516
516
  "category": "data-display",
517
517
  "client": true
518
518
  },
519
+ {
520
+ "name": "ShaderField",
521
+ "label": "Shader field",
522
+ "slug": "shader-field",
523
+ "description": "An ambient WebGL2 field of soft light sources that sample colour tokens, with a reported fallback status.",
524
+ "category": "layout",
525
+ "client": true
526
+ },
519
527
  {
520
528
  "name": "Skeleton",
521
529
  "label": "Skeleton",
@@ -516,6 +516,14 @@ declare const _default: {
516
516
  "category": "data-display",
517
517
  "client": true
518
518
  },
519
+ {
520
+ "name": "ShaderField",
521
+ "label": "Shader field",
522
+ "slug": "shader-field",
523
+ "description": "An ambient WebGL2 field of soft light sources that sample colour tokens, with a reported fallback status.",
524
+ "category": "layout",
525
+ "client": true
526
+ },
519
527
  {
520
528
  "name": "Skeleton",
521
529
  "label": "Skeleton",