fancy-ui-react 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -139,8 +139,14 @@ pnpm install # at the repo root
139
139
  pnpm --filter fancy-ui-react test # vitest + testing-library
140
140
  pnpm --filter fancy-ui-react check # tsc --noEmit
141
141
  pnpm --filter fancy-ui-react build # vite lib build + d.ts via tsc, then the dist gates
142
+ pnpm --filter fancy-ui-react-demo dev # the demo site (examples/react-demo) on http://localhost:3200
142
143
  ```
143
144
 
145
+ Two consumer apps live under `examples/`: `next-app`, the App Router census
146
+ that CI builds as the SSR/hydration gate, and `react-demo`, a one-page demo
147
+ site built as a static export (`next build` → `examples/react-demo/out/`).
148
+ Both resolve `fancy-ui-react` to `react/dist`, so run `build` first.
149
+
144
150
  `build` does more than build. After Vite and `tsc` it runs four scripts in
145
151
  `react/scripts/`, each guarding something no other gate can see:
146
152
 
@@ -3,7 +3,7 @@ import { jsx } from "react/jsx-runtime";
3
3
  import { useState, useRef, useEffect } from "react";
4
4
  import { useLiveRef } from "../../internals/dom/use-live-ref.js";
5
5
  import { cn } from "../../utils.js";
6
- import { hexToRgb, DEV, wrap, pointerPrototype, scaleRadiusForContainer, HSVtoRGB } from "./fluid-shared.js";
6
+ import { hexToRgb, clickBoost, DEV, wrap, pointerPrototype, scaleRadiusForContainer, HSVtoRGB } from "./fluid-shared.js";
7
7
  import { startWebGpuFluid } from "./webgpu-engine.js";
8
8
  function FluidCursor({
9
9
  simResolution = 128,
@@ -38,13 +38,19 @@ function FluidCursor({
38
38
  ditherLevels = 4,
39
39
  onReady
40
40
  }) {
41
- const [derived] = useState(() => ({
42
- clampedColorIntensity: Math.max(0, Math.min(1, colorIntensity)),
43
- clampedHdrBoost: Math.max(1, Math.min(4, hdrBoost)),
44
- clampedDitherPixelSize: Math.max(1, ditherPixelSize),
45
- clampedDitherLevels: Math.max(2, Math.min(16, Math.floor(ditherLevels))),
46
- resolvedBackColor: typeof backColor === "string" ? hexToRgb(backColor) : backColor
47
- }));
41
+ const [derived] = useState(() => {
42
+ const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
43
+ return {
44
+ clampedColorIntensity,
45
+ // A click's extra brightness, capped so it never compounds
46
+ // colorIntensity into the dither pass's clamp — see clickBoost().
47
+ clickColorBoost: clickBoost(clampedColorIntensity),
48
+ clampedHdrBoost: Math.max(1, Math.min(4, hdrBoost)),
49
+ clampedDitherPixelSize: Math.max(1, ditherPixelSize),
50
+ clampedDitherLevels: Math.max(2, Math.min(16, Math.floor(ditherLevels))),
51
+ resolvedBackColor: typeof backColor === "string" ? hexToRgb(backColor) : backColor
52
+ };
53
+ });
48
54
  const canvasRef = useRef(null);
49
55
  const activeInstanceRef = useRef(null);
50
56
  const mountCounterRef = useRef(0);
@@ -61,11 +67,11 @@ function FluidCursor({
61
67
  const onReadyRef = useLiveRef(onReady);
62
68
  function getScaledColor(hex) {
63
69
  const { r, g, b } = hexToRgb(hex);
64
- return {
70
+ return Object.freeze({
65
71
  r: r * derived.clampedColorIntensity,
66
72
  g: g * derived.clampedColorIntensity,
67
73
  b: b * derived.clampedColorIntensity
68
- };
74
+ });
69
75
  }
70
76
  function getCachedFluidColor(hex) {
71
77
  const cache = colorCacheRef.current;
@@ -186,7 +192,8 @@ function FluidCursor({
186
192
  autoSplatInterval,
187
193
  pauseWhenHidden,
188
194
  splatOnMount,
189
- generateColor
195
+ generateColor,
196
+ clickBoost: derived.clickColorBoost
190
197
  }).then((handle) => {
191
198
  if (disposed) {
192
199
  handle?.cleanup();
@@ -581,13 +588,19 @@ function FluidCursor({
581
588
  uniform vec3 color;
582
589
  uniform vec2 point;
583
590
  uniform float radius;
591
+ uniform float saturate;
584
592
 
585
593
  void main () {
586
594
  vec2 p = vUv - point.xy;
587
595
  p.x *= aspectRatio;
588
596
  vec3 splat = exp(-dot(p, p) / radius) * color;
589
597
  vec3 base = texture2D(uTarget, vUv).xyz;
590
- gl_FragColor = vec4(base + splat, 1.0);
598
+ // A click splat lifts each texel to its own profile instead of adding
599
+ // to it: what is already there never rises past what one click would
600
+ // leave at that spot, so a burst of clicks paints the same disc once
601
+ // instead of summing towards white and widening with every click.
602
+ vec3 sum = saturate > 0.5 ? max(base, splat) : base + splat;
603
+ gl_FragColor = vec4(sum, 1.0);
591
604
  }
592
605
  `
593
606
  );
@@ -1211,15 +1224,17 @@ function FluidCursor({
1211
1224
  splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
1212
1225
  }
1213
1226
  function clickSplat(pointer) {
1214
- const color = generateColor();
1215
- color.r *= 10;
1216
- color.g *= 10;
1217
- color.b *= 10;
1227
+ const base = generateColor();
1228
+ const color = {
1229
+ r: base.r * derived.clickColorBoost,
1230
+ g: base.g * derived.clickColorBoost,
1231
+ b: base.b * derived.clickColorBoost
1232
+ };
1218
1233
  const dx = 10 * (Math.random() - 0.5);
1219
1234
  const dy = 30 * (Math.random() - 0.5);
1220
- splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
1235
+ splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
1221
1236
  }
1222
- function splat(x, y, dx, dy, color) {
1237
+ function splat(x, y, dx, dy, color, saturate = false) {
1223
1238
  splatProgram.bind();
1224
1239
  if (splatProgram.uniforms.uTarget) {
1225
1240
  gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
@@ -1233,6 +1248,9 @@ function FluidCursor({
1233
1248
  if (splatProgram.uniforms.color) {
1234
1249
  gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0);
1235
1250
  }
1251
+ if (splatProgram.uniforms.saturate) {
1252
+ gl.uniform1f(splatProgram.uniforms.saturate, 0);
1253
+ }
1236
1254
  if (splatProgram.uniforms.radius) {
1237
1255
  let radius = correctRadius(config.SPLAT_RADIUS / 100);
1238
1256
  if (containedRef.current) {
@@ -1248,6 +1266,9 @@ function FluidCursor({
1248
1266
  if (splatProgram.uniforms.color) {
1249
1267
  gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);
1250
1268
  }
1269
+ if (splatProgram.uniforms.saturate) {
1270
+ gl.uniform1f(splatProgram.uniforms.saturate, saturate ? 1 : 0);
1271
+ }
1251
1272
  blit(dye.write);
1252
1273
  dye.swap();
1253
1274
  }
@@ -1347,6 +1368,9 @@ function FluidCursor({
1347
1368
  updatePointerDownData(pointer, -1, posX, posY);
1348
1369
  clickSplat(pointer);
1349
1370
  }
1371
+ function handlePointerUp() {
1372
+ pointers[0].down = false;
1373
+ }
1350
1374
  function handleFirstMouseMove(e) {
1351
1375
  const pointer = pointers[0];
1352
1376
  const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
@@ -1389,11 +1413,14 @@ function FluidCursor({
1389
1413
  }
1390
1414
  if (interactive) {
1391
1415
  window.addEventListener("mousedown", handleMouseDown);
1416
+ window.addEventListener("mouseup", handlePointerUp);
1392
1417
  document.body.addEventListener("mousemove", handleFirstMouseMove);
1393
1418
  window.addEventListener("mousemove", handleMouseMove);
1394
1419
  document.body.addEventListener("touchstart", handleFirstTouchStart);
1395
1420
  window.addEventListener("touchstart", handleTouchStart, false);
1396
1421
  window.addEventListener("touchmove", handleTouchMove, false);
1422
+ window.addEventListener("touchend", handlePointerUp);
1423
+ window.addEventListener("touchcancel", handlePointerUp);
1397
1424
  }
1398
1425
  let observer = null;
1399
1426
  if (pauseWhenHidden) {
@@ -1432,11 +1459,14 @@ function FluidCursor({
1432
1459
  if (autoSplatTimer) clearInterval(autoSplatTimer);
1433
1460
  if (interactive) {
1434
1461
  window.removeEventListener("mousedown", handleMouseDown);
1462
+ window.removeEventListener("mouseup", handlePointerUp);
1435
1463
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
1436
1464
  window.removeEventListener("mousemove", handleMouseMove);
1437
1465
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
1438
1466
  window.removeEventListener("touchstart", handleTouchStart);
1439
1467
  window.removeEventListener("touchmove", handleTouchMove);
1468
+ window.removeEventListener("touchend", handlePointerUp);
1469
+ window.removeEventListener("touchcancel", handlePointerUp);
1440
1470
  }
1441
1471
  if (contained) {
1442
1472
  window.removeEventListener("resize", updateCanvasRectCache);
@@ -36,6 +36,24 @@ export declare function correctRadius(radius: number, width: number, height: num
36
36
  * length under ~30% of the container height.
37
37
  */
38
38
  export declare function scaleRadiusForContainer(radius: number, containerHeight: number, viewportHeight: number): number;
39
+ /**
40
+ * Dye a click deposits at its centre, in multiples of the hue: the engine's
41
+ * default `colorIntensity` (0.15) times the ×10 boost the solver has always
42
+ * given a click — the amount that reads as a bright pop on every renderer.
43
+ */
44
+ export declare const CLICK_PEAK = 1.5;
45
+ /**
46
+ * The boost a click applies to an already intensity-scaled colour, chosen so
47
+ * the peak lands on CLICK_PEAK whatever `colorIntensity` is.
48
+ *
49
+ * The historical fixed ×10 compounded `colorIntensity`. At the default 0.15
50
+ * that is a 1.5 peak; but the dither pass asks consumers to raise the
51
+ * intensity to ≈0.4+, and at 0.6 the same ×10 stacked 5.4 per click into an
52
+ * unclamped half-float dye buffer that the dither display then clamps to 1
53
+ * and quantises — every click grew a flat, saturated disc that never read as
54
+ * a splat. Capping the boost at 10 keeps intensities ≤ 0.15 byte-identical.
55
+ */
56
+ export declare function clickBoost(colorIntensity: number): number;
39
57
  export declare function correctDeltaX(delta: number, width: number, height: number): number;
40
58
  export declare function correctDeltaY(delta: number, width: number, height: number): number;
41
59
  /**
@@ -95,6 +95,11 @@ function scaleRadiusForContainer(radius, containerHeight, viewportHeight) {
95
95
  if (k <= 1) return radius;
96
96
  return Math.max(radius, Math.min(radius * k, 0.09));
97
97
  }
98
+ const CLICK_PEAK = 1.5;
99
+ function clickBoost(colorIntensity) {
100
+ if (!(colorIntensity > 0)) return 0;
101
+ return Math.min(10, CLICK_PEAK / colorIntensity);
102
+ }
98
103
  function correctDeltaX(delta, width, height) {
99
104
  const aspectRatio = width / height;
100
105
  if (aspectRatio < 1) return delta * aspectRatio;
@@ -107,8 +112,10 @@ function correctDeltaY(delta, width, height) {
107
112
  }
108
113
  const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
109
114
  export {
115
+ CLICK_PEAK,
110
116
  DEV,
111
117
  HSVtoRGB,
118
+ clickBoost,
112
119
  correctDeltaX,
113
120
  correctDeltaY,
114
121
  correctRadius,
@@ -20,6 +20,12 @@ export interface WebGpuFluidOptions {
20
20
  pauseWhenHidden: boolean;
21
21
  splatOnMount: boolean;
22
22
  generateColor: () => ColorRGB;
23
+ /**
24
+ * Multiplier a click applies to the (already intensity-scaled) colour
25
+ * `generateColor()` returns — `clickBoost(colorIntensity)` from
26
+ * fluid-shared, so the peak is the same whatever the intensity.
27
+ */
28
+ clickBoost: number;
23
29
  }
24
30
  /**
25
31
  * Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
@@ -52,7 +52,11 @@ fn fs_splat(in: VSOut) -> @location(0) vec4f {
52
52
  p.x = p.x * u.aspect;
53
53
  let splat = exp(-dot(p, p) / u.radius) * u.color.rgb;
54
54
  let base = textureSample(tex0, smpLinear, in.uv).rgb;
55
- return vec4f(base + splat, 1.0);
55
+ // color.a is the click flag: a click splat lifts each texel to its own
56
+ // profile instead of adding to it, so a burst of clicks paints the same
57
+ // disc once instead of summing towards white and widening every click.
58
+ let sum = select(base + splat, max(base, splat), u.color.a > 0.5);
59
+ return vec4f(sum, 1.0);
56
60
  }
57
61
 
58
62
  @fragment
@@ -233,7 +237,7 @@ async function createWebGpuFluid(canvas, opts) {
233
237
  scratch[OFF_COLOR] = v.color[0];
234
238
  scratch[OFF_COLOR + 1] = v.color[1];
235
239
  scratch[OFF_COLOR + 2] = v.color[2];
236
- scratch[OFF_COLOR + 3] = 1;
240
+ scratch[OFF_COLOR + 3] = v.color[3] ?? 0;
237
241
  }
238
242
  scratch[OFF_DT] = v.dt ?? 0;
239
243
  scratch[OFF_DISSIPATION] = v.dissipation ?? 0;
@@ -368,7 +372,7 @@ async function createWebGpuFluid(canvas, opts) {
368
372
  canvas.height = height;
369
373
  initTextures();
370
374
  }
371
- }, splat = function(x, y, dx, dy, color) {
375
+ }, splat = function(x, y, dx, dy, color, saturate = false) {
372
376
  if (destroyed || lost || !velocity || !dye) return;
373
377
  let radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);
374
378
  if (opts.contained) {
@@ -383,7 +387,7 @@ async function createWebGpuFluid(canvas, opts) {
383
387
  });
384
388
  writeUniforms(uSplatDye, {
385
389
  point: [x, y],
386
- color: [color.r, color.g, color.b],
390
+ color: [color.r, color.g, color.b, saturate ? 1 : 0],
387
391
  aspect,
388
392
  radius
389
393
  });
@@ -716,13 +720,15 @@ async function startWebGpuFluid(canvas, opts) {
716
720
  engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
717
721
  }
718
722
  function clickSplat(pointer) {
719
- const color = opts.generateColor();
720
- color.r *= 10;
721
- color.g *= 10;
722
- color.b *= 10;
723
+ const base = opts.generateColor();
724
+ const color = {
725
+ r: base.r * opts.clickBoost,
726
+ g: base.g * opts.clickBoost,
727
+ b: base.b * opts.clickBoost
728
+ };
723
729
  const dx = 10 * (Math.random() - 0.5);
724
730
  const dy = 30 * (Math.random() - 0.5);
725
- engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
731
+ engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
726
732
  }
727
733
  function multipleSplats(steps) {
728
734
  const angle = Math.random() * Math.PI * 2;
@@ -780,6 +786,9 @@ async function startWebGpuFluid(canvas, opts) {
780
786
  updatePointerDownData(pointer, -1, posX, posY);
781
787
  clickSplat(pointer);
782
788
  }
789
+ function handlePointerUp() {
790
+ pointers[0].down = false;
791
+ }
783
792
  function handleFirstMouseMove(e) {
784
793
  const pointer = pointers[0];
785
794
  const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
@@ -826,11 +835,14 @@ async function startWebGpuFluid(canvas, opts) {
826
835
  }
827
836
  if (opts.interactive) {
828
837
  window.addEventListener("mousedown", handleMouseDown);
838
+ window.addEventListener("mouseup", handlePointerUp);
829
839
  document.body.addEventListener("mousemove", handleFirstMouseMove);
830
840
  window.addEventListener("mousemove", handleMouseMove);
831
841
  document.body.addEventListener("touchstart", handleFirstTouchStart);
832
842
  window.addEventListener("touchstart", handleTouchStart, false);
833
843
  window.addEventListener("touchmove", handleTouchMove, false);
844
+ window.addEventListener("touchend", handlePointerUp);
845
+ window.addEventListener("touchcancel", handlePointerUp);
834
846
  }
835
847
  let observer = null;
836
848
  if (opts.pauseWhenHidden) {
@@ -903,11 +915,14 @@ async function startWebGpuFluid(canvas, opts) {
903
915
  if (autoSplatTimer) clearInterval(autoSplatTimer);
904
916
  if (opts.interactive) {
905
917
  window.removeEventListener("mousedown", handleMouseDown);
918
+ window.removeEventListener("mouseup", handlePointerUp);
906
919
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
907
920
  window.removeEventListener("mousemove", handleMouseMove);
908
921
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
909
922
  window.removeEventListener("touchstart", handleTouchStart);
910
923
  window.removeEventListener("touchmove", handleTouchMove);
924
+ window.removeEventListener("touchend", handlePointerUp);
925
+ window.removeEventListener("touchcancel", handlePointerUp);
911
926
  }
912
927
  if (opts.contained) {
913
928
  window.removeEventListener("resize", updateCanvasRectCache);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fancy-ui-react",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Animated UI components for React — the React counterpart of fancy-ui-svelte. WebGL, GSAP and CSS motion, styled with Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "type": "module",