fancy-ui-react 0.2.0 → 0.3.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,370 @@
1
+ "use client";
2
+ const DEFAULT_COLORS = [
3
+ "#ffffff",
4
+ "#3566ff",
5
+ "#ffcf00",
6
+ "#192aff",
7
+ "#d62036",
8
+ "#282142",
9
+ "#ec4978",
10
+ "#ff9900",
11
+ "#14101f",
12
+ "#00dd33",
13
+ "#00ef82"
14
+ ];
15
+ const DEFAULT_COLUMNS = 11;
16
+ const DEFAULT_POWER = 1.65;
17
+ const DEFAULT_TILES = 15;
18
+ const CYCLE = 0.2;
19
+ const FALL_STAGGER = 6e-3;
20
+ const STRETCH = 8.5;
21
+ const BLEED = 0.012;
22
+ const COL_PHASE = -0.4;
23
+ const TAIL_SLOPE = 0.05;
24
+ const STRIP_LENGTH = 61;
25
+ function mulberry32(seed) {
26
+ let a = seed >>> 0;
27
+ return () => {
28
+ a = a + 1831565813 >>> 0;
29
+ let t = a;
30
+ t = Math.imul(t ^ t >>> 15, t | 1);
31
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
32
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
33
+ };
34
+ }
35
+ function luminance(color) {
36
+ const s = color.trim().toLowerCase();
37
+ let rgb = null;
38
+ const hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
39
+ if (hex) {
40
+ const h = hex[1];
41
+ const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
42
+ rgb = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
43
+ } else {
44
+ const m = s.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
45
+ if (m) rgb = [Number(m[1]), Number(m[2]), Number(m[3])];
46
+ }
47
+ if (!rgb) return null;
48
+ const [r, g, b] = rgb.map((v) => {
49
+ const c = Math.min(255, v) / 255;
50
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
51
+ });
52
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
53
+ }
54
+ function paletteRoles(colors) {
55
+ const ranked = colors.map((c, i) => ({ i, l: luminance(c) ?? 0.5 })).sort((a, b) => a.l - b.l || a.i - b.i);
56
+ const light = ranked[ranked.length - 1].i;
57
+ const darkCount = colors.length >= 5 ? 2 : 1;
58
+ const darks = ranked.slice(0, darkCount).map((r) => r.i);
59
+ const hues = ranked.slice(darkCount, -1).map((r) => r.i);
60
+ return { light, darks, hues };
61
+ }
62
+ function pickDifferent(from, avoid, len) {
63
+ for (let i = 1; i <= len; i++) {
64
+ const c = (from + i) % len;
65
+ if (!avoid.includes(c)) return c;
66
+ }
67
+ return from;
68
+ }
69
+ function buildStrip(seed, colors, len = STRIP_LENGTH) {
70
+ const n = colors.length;
71
+ if (n < 2) return Array.from({ length: len }, () => 0);
72
+ const { light, darks, hues } = paletteRoles(colors);
73
+ const pool = hues.length ? hues : darks;
74
+ const rand = mulberry32(seed);
75
+ const strip = [];
76
+ let lastHue = -1;
77
+ for (let k = 0; k < len; k++) {
78
+ const r = rand();
79
+ let next;
80
+ if (r < 0.26) {
81
+ next = light;
82
+ } else if (r < 0.42) {
83
+ next = darks[Math.floor(rand() * darks.length)];
84
+ } else {
85
+ let h = pool[Math.floor(rand() * pool.length)];
86
+ if (h === lastHue && pool.length > 1) h = pool[(pool.indexOf(h) + 1) % pool.length];
87
+ lastHue = h;
88
+ next = h;
89
+ }
90
+ if (k > 0 && next === strip[k - 1]) next = pickDifferent(next, [strip[k - 1]], n);
91
+ strip.push(next);
92
+ }
93
+ if (len > 2 && strip[len - 1] === strip[0]) {
94
+ strip[len - 1] = pickDifferent(strip[len - 1], [strip[len - 2], strip[0]], n);
95
+ }
96
+ return strip;
97
+ }
98
+ function stripIndex(strip, id, column) {
99
+ const len = strip.length;
100
+ const s = id - column;
101
+ return strip[(s % len + len) % len];
102
+ }
103
+ function columnEdges(width, columns, power) {
104
+ const edges = [];
105
+ for (let i = 0; i <= columns; i++) {
106
+ edges.push(Math.round(width * Math.pow(i / columns, power)));
107
+ }
108
+ return edges;
109
+ }
110
+ function oddTiles(tiles) {
111
+ const t = Math.max(3, Math.round(Number.isFinite(tiles) ? tiles : DEFAULT_TILES));
112
+ return t % 2 === 0 ? t + 1 : t;
113
+ }
114
+ function tileEdge(k, tiles, stretch = STRETCH) {
115
+ const u = k / tiles;
116
+ if (u < 0) return u * TAIL_SLOPE;
117
+ if (u > 1) return 1 + (u - 1) * TAIL_SLOPE;
118
+ const a = Math.pow(u, stretch);
119
+ return a / (a + Math.pow(1 - u, stretch));
120
+ }
121
+ const SPRING_NORM = 1 - Math.exp(-1.6);
122
+ function springStep(p) {
123
+ const half = (t) => (1 - Math.exp(-1.6 * t)) / SPRING_NORM;
124
+ return p < 0.5 ? 0.5 * half(2 * p) : 1 - 0.5 * half(2 * (1 - p));
125
+ }
126
+ function columnFlow(elapsed, column, columns, speed = 1) {
127
+ const delay = (columns - 1 - column) * FALL_STAGGER;
128
+ const t = elapsed - delay;
129
+ const raw = t <= 0 ? 0 : t * speed / CYCLE;
130
+ const linear = raw + column * COL_PHASE;
131
+ const step = Math.floor(linear);
132
+ return step + springStep(linear - step);
133
+ }
134
+ function columnTiles(flow, tiles, height, bleed) {
135
+ const out = [];
136
+ const base = -Math.floor(flow);
137
+ for (let n = tiles + 2; n >= -2; n--) {
138
+ const id = base + n;
139
+ const k = id + flow;
140
+ const top = Math.round(tileEdge(k, tiles) * height);
141
+ const bot = Math.round(tileEdge(k + 1, tiles) * height) + bleed;
142
+ if (bot <= top) continue;
143
+ out.push({ id, top, bot });
144
+ }
145
+ return out;
146
+ }
147
+ function createCoverage(columns, covered = false) {
148
+ const top = new Float32Array(columns);
149
+ const bot = new Float32Array(columns);
150
+ if (covered) bot.fill(1);
151
+ return { top, bot };
152
+ }
153
+ function columnDelays(columns, sweep, stagger, seed = 1) {
154
+ const mid = (columns - 1) / 2;
155
+ const rank = (i) => {
156
+ switch (sweep) {
157
+ case "left":
158
+ return i;
159
+ case "center":
160
+ return Math.abs(i - mid);
161
+ case "edges":
162
+ return mid - Math.abs(i - mid);
163
+ case "random":
164
+ return 0;
165
+ default:
166
+ return columns - 1 - i;
167
+ }
168
+ };
169
+ if (sweep === "random") {
170
+ const order = Array.from({ length: columns }, (_, i) => i);
171
+ const rand = mulberry32(seed ^ 24301);
172
+ for (let i = order.length - 1; i > 0; i--) {
173
+ const j = Math.floor(rand() * (i + 1));
174
+ [order[i], order[j]] = [order[j], order[i]];
175
+ }
176
+ const delays = new Array(columns);
177
+ order.forEach((col, k) => delays[col] = k * stagger);
178
+ return delays;
179
+ }
180
+ return Array.from({ length: columns }, (_, i) => rank(i) * stagger);
181
+ }
182
+ function stepCoverage(cov, direction, phaseTime, dt, duration, delays) {
183
+ const n = cov.top.length;
184
+ const rate = duration > 0 ? dt / duration : Infinity;
185
+ let done = true;
186
+ for (let i = 0; i < n; i++) {
187
+ const started = phaseTime >= (delays[i] ?? 0);
188
+ if (direction === "cover") {
189
+ if (cov.bot[i] - cov.top[i] <= 1e-6 && cov.bot[i] < 1) {
190
+ cov.top[i] = 0;
191
+ cov.bot[i] = 0;
192
+ }
193
+ if (started) {
194
+ cov.bot[i] = Math.min(1, cov.bot[i] + rate);
195
+ cov.top[i] = Math.max(0, cov.top[i] - rate);
196
+ }
197
+ if (cov.bot[i] < 1 || cov.top[i] > 0) done = false;
198
+ } else {
199
+ if (started) cov.top[i] = Math.min(cov.bot[i], cov.top[i] + rate);
200
+ if (cov.top[i] < cov.bot[i]) done = false;
201
+ }
202
+ }
203
+ return done;
204
+ }
205
+ function settleCoverage(cov, direction) {
206
+ if (direction === "cover") {
207
+ cov.top.fill(0);
208
+ cov.bot.fill(1);
209
+ } else {
210
+ cov.top.fill(0);
211
+ cov.bot.fill(0);
212
+ }
213
+ }
214
+ function easeEdge(a) {
215
+ const x = Math.min(1, Math.max(0, a));
216
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
217
+ }
218
+ function columnBand(effect, column, top, bot) {
219
+ const rise = effect === "rise" || effect === "interlace" && column % 2 === 1;
220
+ if (effect === "split") {
221
+ const half = Math.max(0, bot - top) / 2;
222
+ return { y0: 0.5 - half, y1: 0.5 + half, mirror: false };
223
+ }
224
+ if (rise) return { y0: 1 - bot, y1: 1 - top, mirror: true };
225
+ return { y0: top, y1: bot, mirror: false };
226
+ }
227
+ const DATAMOSH_PALETTES = {
228
+ broadcast: DEFAULT_COLORS,
229
+ thermal: [
230
+ "#fff4d6",
231
+ "#ffd23f",
232
+ "#ff9f1c",
233
+ "#ff5a1f",
234
+ "#e0282e",
235
+ "#b0125b",
236
+ "#6a0f63",
237
+ "#2a0a3d",
238
+ "#0d0614"
239
+ ],
240
+ sunset: [
241
+ "#fff6d8",
242
+ "#fbc56b",
243
+ "#f59a3a",
244
+ "#e97a1d",
245
+ "#c85a14",
246
+ "#8a4a22",
247
+ "#5a3b25",
248
+ "#2d1c16",
249
+ "#140b08"
250
+ ],
251
+ mono: ["#ffffff", "#e5e5e5", "#b5b5b5", "#8a8a8a", "#5c5c5c", "#2e2e2e", "#141414", "#050505"],
252
+ acid: [
253
+ "#f4ffe8",
254
+ "#e4ff1a",
255
+ "#00ff9c",
256
+ "#00e0ff",
257
+ "#3d5afe",
258
+ "#8c3dff",
259
+ "#ff2bd6",
260
+ "#1b0b36",
261
+ "#070707"
262
+ ]
263
+ };
264
+ function resolvePalette(colors) {
265
+ if (typeof colors === "string") return [...DATAMOSH_PALETTES[colors] ?? DEFAULT_COLORS];
266
+ return colors && colors.length >= 2 ? [...colors] : [...DEFAULT_COLORS];
267
+ }
268
+ const MAX_GAIN = 1.6;
269
+ const SOURCE_SPAN = 0.6;
270
+ function sourceSlotY(slot, tiles) {
271
+ return 0.5 + ((slot + 0.5) / tiles - 0.5) * SOURCE_SPAN;
272
+ }
273
+ function sourceSlot(id, column, tiles) {
274
+ const s = id - column;
275
+ return (s % tiles + tiles) % tiles;
276
+ }
277
+ function coverFit(imgW, imgH, boxW, boxH) {
278
+ if (imgW <= 0 || imgH <= 0 || boxW <= 0 || boxH <= 0) return { sx: 0, sy: 0, sw: imgW, sh: imgH };
279
+ const scale = Math.max(boxW / imgW, boxH / imgH);
280
+ const sw = boxW / scale;
281
+ const sh = boxH / scale;
282
+ return { sx: (imgW - sw) / 2, sy: (imgH - sh) / 2, sw, sh };
283
+ }
284
+ function sampleSource(pixels, pw, ph, edges, tiles, boost = true) {
285
+ const at = (x, y) => {
286
+ const cx = Math.min(pw - 1, Math.max(0, x));
287
+ const cy = Math.min(ph - 1, Math.max(0, y));
288
+ return (cy * pw + cx) * 4;
289
+ };
290
+ const cells = [];
291
+ let lo = Infinity;
292
+ let hi = -Infinity;
293
+ for (let i = 0; i + 1 < edges.length; i++) {
294
+ const x = Math.round((edges[i] + edges[i + 1]) / 2 * (pw - 1));
295
+ const row = [];
296
+ for (let s = 0; s < tiles; s++) {
297
+ const y = Math.round(sourceSlotY(s, tiles) * (ph - 1));
298
+ let r = 0;
299
+ let g = 0;
300
+ let b = 0;
301
+ for (let dy = -1; dy <= 1; dy++) {
302
+ for (let dx = -1; dx <= 1; dx++) {
303
+ const k = at(x + dx, y + dy);
304
+ r += pixels[k];
305
+ g += pixels[k + 1];
306
+ b += pixels[k + 2];
307
+ }
308
+ }
309
+ const cell = [r / 9, g / 9, b / 9];
310
+ const l = 0.2126 * cell[0] + 0.7152 * cell[1] + 0.0722 * cell[2];
311
+ lo = Math.min(lo, l);
312
+ hi = Math.max(hi, l);
313
+ row.push(cell);
314
+ }
315
+ cells.push(row);
316
+ }
317
+ const span = hi - lo;
318
+ const level = boost && span > 8 ? Math.min(MAX_GAIN, 255 / (hi - lo * 0.5)) : 1;
319
+ const offset = boost && span > 8 ? lo * 0.5 : 0;
320
+ const saturation = boost ? 1.35 : 1;
321
+ const clamp = (v) => Math.round(Math.min(255, Math.max(0, v)));
322
+ return cells.map(
323
+ (row) => row.map(([r, g, b]) => {
324
+ let [nr, ng, nb] = [(r - offset) * level, (g - offset) * level, (b - offset) * level];
325
+ const l = 0.2126 * nr + 0.7152 * ng + 0.0722 * nb;
326
+ nr = l + (nr - l) * saturation;
327
+ ng = l + (ng - l) * saturation;
328
+ nb = l + (nb - l) * saturation;
329
+ return `rgb(${clamp(nr)}, ${clamp(ng)}, ${clamp(nb)})`;
330
+ })
331
+ );
332
+ }
333
+ export {
334
+ BLEED,
335
+ COL_PHASE,
336
+ CYCLE,
337
+ DATAMOSH_PALETTES,
338
+ DEFAULT_COLORS,
339
+ DEFAULT_COLUMNS,
340
+ DEFAULT_POWER,
341
+ DEFAULT_TILES,
342
+ FALL_STAGGER,
343
+ MAX_GAIN,
344
+ SOURCE_SPAN,
345
+ STRETCH,
346
+ STRIP_LENGTH,
347
+ TAIL_SLOPE,
348
+ buildStrip,
349
+ columnBand,
350
+ columnDelays,
351
+ columnEdges,
352
+ columnFlow,
353
+ columnTiles,
354
+ coverFit,
355
+ createCoverage,
356
+ easeEdge,
357
+ luminance,
358
+ mulberry32,
359
+ oddTiles,
360
+ paletteRoles,
361
+ resolvePalette,
362
+ sampleSource,
363
+ settleCoverage,
364
+ sourceSlot,
365
+ sourceSlotY,
366
+ springStep,
367
+ stepCoverage,
368
+ stripIndex,
369
+ tileEdge
370
+ };
@@ -0,0 +1,3 @@
1
+ export { DatamoshTransition } from "./DatamoshTransition.js";
2
+ export type { DatamoshTransitionProps, DatamoshTransitionPhase, DatamoshTransitionHandle, } from "./DatamoshTransition.js";
3
+ export { DATAMOSH_PALETTES, type DatamoshEffect, type DatamoshSweep, type DatamoshPaletteName, } from "./datamosh-transition-core.js";
@@ -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 +