@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.
- package/LICENSE +53 -2
- package/README.md +36 -5
- package/components/AgentStatus/AgentStatus.css +26 -11
- package/components/Card/Card.css +9 -0
- package/components/Card/Card.d.ts +6 -0
- package/components/Card/Card.js +2 -1
- package/components/Composer/Composer.css +18 -0
- package/components/Composer/Composer.d.ts +3 -0
- package/components/Composer/Composer.js +12 -1
- package/components/Prose/Prose.css +18 -1
- package/components/Reasoning/Reasoning.css +19 -4
- package/components/ShaderField/ShaderField.css +24 -0
- package/components/ShaderField/ShaderField.d.ts +68 -0
- package/components/ShaderField/ShaderField.js +54 -0
- package/components/ShaderField/field.glsl.d.ts +33 -0
- package/components/ShaderField/field.glsl.js +177 -0
- package/components/ShaderField/useShaderField.d.ts +86 -0
- package/components/ShaderField/useShaderField.js +395 -0
- package/components/registry.json +8 -0
- package/components/registry.json.d.ts +8 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +1 -0
- package/index.js +8 -0
- package/package.json +5 -1
- package/tokens/registry.json +1 -0
- package/tokens/registry.json.d.ts +1 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +1 -1
- package/tokens/tokens-light.css +2 -2
- package/tokens/tokens-motion.css +2 -0
|
@@ -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
|
+
};
|
package/components/registry.json
CHANGED
|
@@ -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",
|