fancy-ui-svelte 0.8.0 → 0.9.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.
Files changed (39) hide show
  1. package/README.md +107 -89
  2. package/dist/fancy-ui/animated-testimonials/README.md +44 -0
  3. package/dist/fancy-ui/animated-tooltip/README.md +40 -0
  4. package/dist/fancy-ui/apple-card-carousel/README.md +46 -0
  5. package/dist/fancy-ui/bento-grid/BentoGrid.test.d.ts +1 -0
  6. package/dist/fancy-ui/bento-grid/BentoGrid.test.js +109 -0
  7. package/dist/fancy-ui/border-beam/README.md +37 -0
  8. package/dist/fancy-ui/card-3d/CardContainer.test.d.ts +1 -0
  9. package/dist/fancy-ui/card-3d/CardContainer.test.js +118 -0
  10. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.d.ts +1 -0
  11. package/dist/fancy-ui/card-spotlight/CardSpotlight.test.js +58 -0
  12. package/dist/fancy-ui/displacement-text/README.md +39 -0
  13. package/dist/fancy-ui/dock/README.md +1 -1
  14. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.d.ts +1 -0
  15. package/dist/fancy-ui/editorial-engine/EditorialEngine.test.js +54 -0
  16. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +130 -9
  17. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +9 -1
  18. package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +19 -0
  19. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +32 -0
  20. package/dist/fancy-ui/fluid-cursor/index.d.ts +1 -0
  21. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +11 -4
  22. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +113 -34
  23. package/dist/fancy-ui/line-hover-link/README.md +37 -0
  24. package/dist/fancy-ui/line-reveal/LineReveal.test.d.ts +1 -0
  25. package/dist/fancy-ui/line-reveal/LineReveal.test.js +77 -0
  26. package/dist/fancy-ui/marquee/README.md +57 -0
  27. package/dist/fancy-ui/matrix-rain/MatrixRain.test.d.ts +1 -0
  28. package/dist/fancy-ui/matrix-rain/MatrixRain.test.js +30 -0
  29. package/dist/fancy-ui/matrix-rain/README.md +42 -0
  30. package/dist/fancy-ui/noise-reveal/README.md +52 -0
  31. package/dist/fancy-ui/rainbow-button/README.md +36 -0
  32. package/dist/fancy-ui/registry.js +20 -20
  33. package/dist/fancy-ui/ripple-button/README.md +36 -0
  34. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.d.ts +1 -0
  35. package/dist/fancy-ui/smooth-cursor/SmoothCursor.test.js +45 -0
  36. package/dist/fancy-ui/terminal-text/README.md +48 -0
  37. package/dist/fancy-ui/terminal-text/TerminalText.test.d.ts +1 -0
  38. package/dist/fancy-ui/terminal-text/TerminalText.test.js +88 -0
  39. package/package.json +15 -2
@@ -0,0 +1,118 @@
1
+ import { render, cleanup, fireEvent } from "@testing-library/svelte";
2
+ import { createRawSnippet } from "svelte";
3
+ import { afterEach, describe, it, expect } from "vitest";
4
+ import CardContainer from "./CardContainer.svelte";
5
+ import CardBody from "./CardBody.svelte";
6
+ import CardItem from "./CardItem.svelte";
7
+ function textSnippet(html) {
8
+ return createRawSnippet(() => ({ render: () => html }));
9
+ }
10
+ describe("CardContainer", () => {
11
+ afterEach(cleanup);
12
+ it("renders the perspective wrapper and the 3d inner container", () => {
13
+ const { container } = render(CardContainer);
14
+ const outer = container.firstElementChild;
15
+ const inner = outer.firstElementChild;
16
+ expect(outer.getAttribute("style")).toContain("perspective: 1000px");
17
+ expect(inner.getAttribute("style")).toContain("transform-style: preserve-3d");
18
+ });
19
+ it("applies containerClass to the outer wrapper and class to the inner container", () => {
20
+ const { container } = render(CardContainer, {
21
+ props: { containerClass: "my-outer", class: "my-inner" },
22
+ });
23
+ const outer = container.firstElementChild;
24
+ const inner = outer.firstElementChild;
25
+ expect(outer.className).toContain("my-outer");
26
+ expect(inner.className).toContain("my-inner");
27
+ });
28
+ it("renders children content", () => {
29
+ const { container } = render(CardContainer, {
30
+ props: { children: textSnippet("<p data-testid='content'>card body</p>") },
31
+ });
32
+ expect(container.querySelector("[data-testid='content']")?.textContent).toBe("card body");
33
+ });
34
+ it("rotates the inner container on mousemove and resets on mouseleave", async () => {
35
+ const { container } = render(CardContainer);
36
+ const inner = container.firstElementChild.firstElementChild;
37
+ // jsdom's getBoundingClientRect() is all zeros, so rotateY = clientX / 25
38
+ // and rotateX = clientY / 25 for this component's handler.
39
+ await fireEvent.mouseMove(inner, { clientX: 50, clientY: 25 });
40
+ expect(inner.style.transform).toBe("rotateY(2deg) rotateX(1deg)");
41
+ await fireEvent.mouseLeave(inner);
42
+ expect(inner.style.transform).toBe("rotateY(0deg) rotateX(0deg)");
43
+ });
44
+ });
45
+ describe("CardBody", () => {
46
+ afterEach(cleanup);
47
+ it("renders with the fixed 3d-card dimensions", () => {
48
+ const { container } = render(CardBody);
49
+ const body = container.firstElementChild;
50
+ expect(body.className).toContain("h-96");
51
+ expect(body.className).toContain("w-96");
52
+ expect(body.getAttribute("style")).toContain("transform-style: preserve-3d");
53
+ });
54
+ it("applies custom class names", () => {
55
+ const { container } = render(CardBody, { props: { class: "my-body" } });
56
+ const body = container.firstElementChild;
57
+ expect(body.className).toContain("my-body");
58
+ });
59
+ it("renders children content", () => {
60
+ const { container } = render(CardBody, {
61
+ props: { children: textSnippet("<span data-testid='inner'>x</span>") },
62
+ });
63
+ expect(container.querySelector("[data-testid='inner']")).toBeTruthy();
64
+ });
65
+ });
66
+ describe("CardItem", () => {
67
+ afterEach(cleanup);
68
+ // CardItem reads its hover state from CardContainer via setContext/getContext
69
+ // under the "card3d:mouseEntered" key. @testing-library/svelte forwards a
70
+ // `context` mount option straight to Svelte's mount(), so it can be tested
71
+ // standalone without mounting a full CardContainer tree.
72
+ function withContext(mouseEntered) {
73
+ return new Map([["card3d:mouseEntered", () => mouseEntered]]);
74
+ }
75
+ it("sits at the zero transform when the container reports no hover", () => {
76
+ const { container } = render(CardItem, {
77
+ props: { translateX: 20, rotateZ: 10 },
78
+ context: withContext(false),
79
+ });
80
+ const el = container.firstElementChild;
81
+ expect(el.style.transform).toContain("translateX(0px)");
82
+ expect(el.style.transform).toContain("rotateZ(0deg)");
83
+ });
84
+ it("applies the configured translate/rotate values once the container reports hover", () => {
85
+ const { container } = render(CardItem, {
86
+ props: { translateX: 20, translateY: -10, rotateZ: 10 },
87
+ context: withContext(true),
88
+ });
89
+ const el = container.firstElementChild;
90
+ expect(el.style.transform).toContain("translateX(20px)");
91
+ expect(el.style.transform).toContain("translateY(-10px)");
92
+ expect(el.style.transform).toContain("rotateZ(10deg)");
93
+ });
94
+ it("renders the element specified by the as prop", () => {
95
+ const { container } = render(CardItem, {
96
+ props: { as: "span" },
97
+ context: withContext(false),
98
+ });
99
+ expect(container.firstElementChild?.tagName.toLowerCase()).toBe("span");
100
+ });
101
+ it("defaults to a div and merges custom class names", () => {
102
+ const { container } = render(CardItem, {
103
+ props: { class: "my-item" },
104
+ context: withContext(false),
105
+ });
106
+ const el = container.firstElementChild;
107
+ expect(el.tagName.toLowerCase()).toBe("div");
108
+ expect(el.className).toContain("my-item");
109
+ expect(el.className).toContain("w-fit");
110
+ });
111
+ it("renders children content", () => {
112
+ const { container } = render(CardItem, {
113
+ props: { children: textSnippet("<em data-testid='item-child'>x</em>") },
114
+ context: withContext(false),
115
+ });
116
+ expect(container.querySelector("[data-testid='item-child']")).toBeTruthy();
117
+ });
118
+ });
@@ -0,0 +1,58 @@
1
+ import { render, cleanup, fireEvent } from "@testing-library/svelte";
2
+ import { createRawSnippet } from "svelte";
3
+ import { afterEach, describe, it, expect } from "vitest";
4
+ import CardSpotlight from "./CardSpotlight.svelte";
5
+ function textSnippet(html) {
6
+ return createRawSnippet(() => ({ render: () => html }));
7
+ }
8
+ describe("CardSpotlight", () => {
9
+ afterEach(cleanup);
10
+ it("renders the wrapper with the base card classes", () => {
11
+ const { container } = render(CardSpotlight);
12
+ const wrapper = container.firstElementChild;
13
+ expect(wrapper.className).toContain("rounded-xl");
14
+ expect(wrapper.className).toContain("overflow-hidden");
15
+ });
16
+ it("applies custom class names to the wrapper", () => {
17
+ const { container } = render(CardSpotlight, { props: { class: "my-spotlight" } });
18
+ const wrapper = container.firstElementChild;
19
+ expect(wrapper.className).toContain("my-spotlight");
20
+ });
21
+ it("applies slotClass to the content wrapper", () => {
22
+ const { container } = render(CardSpotlight, { props: { slotClass: "my-slot" } });
23
+ const slot = container.querySelector(".relative.z-10");
24
+ expect(slot.className).toContain("my-slot");
25
+ });
26
+ it("renders children content", () => {
27
+ const { container } = render(CardSpotlight, {
28
+ props: { children: textSnippet("<p data-testid='content'>hi</p>") },
29
+ });
30
+ expect(container.querySelector("[data-testid='content']")).toBeTruthy();
31
+ });
32
+ // The overlay's `style="background: {backgroundStyle}; opacity: {gradientOpacity}"`
33
+ // attribute never materializes under this repo's vitest+jsdom setup — verified
34
+ // with a from-scratch minimal component reproduction, unrelated to mouse
35
+ // tracking or gradientSize/gradientColor: any style attribute shaped like
36
+ // `style="prop: {dyn}; prop2: value"` on a plain element renders with no
37
+ // style attribute at all (confirmed via getAttribute, .style.cssText, and
38
+ // the individual .style.* properties, all empty). Because vite.config.ts
39
+ // forces `resolve: { conditions: ['browser'] }`, tests compile the same
40
+ // client output real browsers get, so this is worth a maintainer checking
41
+ // in an actual browser rather than something to chase further here — see
42
+ // the gradient/opacity plumbing tests below, which stay on structure only.
43
+ it("accepts gradientSize/gradientColor/gradientOpacity without throwing and keeps the overlay in the DOM", () => {
44
+ const { container } = render(CardSpotlight, {
45
+ props: { gradientSize: 300, gradientColor: "#ff0000", gradientOpacity: 0.5 },
46
+ });
47
+ const overlay = container.querySelector(".pointer-events-none.absolute.inset-0");
48
+ expect(overlay).toBeTruthy();
49
+ });
50
+ it("responds to mousemove and mouseleave without throwing", async () => {
51
+ const { container } = render(CardSpotlight);
52
+ const wrapper = container.firstElementChild;
53
+ await fireEvent.mouseMove(wrapper, { clientX: 40, clientY: 30 });
54
+ await fireEvent.mouseLeave(wrapper);
55
+ // The awaits above would reject if either handler threw.
56
+ expect(container.querySelector(".pointer-events-none.absolute.inset-0")).toBeTruthy();
57
+ });
58
+ });
@@ -0,0 +1,39 @@
1
+ # DisplacementText
2
+
3
+ Large text rendered as a Three.js plane whose surface bulges toward the viewer near the cursor, via a vertex-shader displacement driven by raycasting against an invisible hit-plane. The text itself is rasterized to a canvas texture, not drawn with real DOM glyphs.
4
+
5
+ ## Usage
6
+
7
+ ```svelte
8
+ <script lang="ts">
9
+ import { DisplacementText } from "fancy-ui-svelte";
10
+ </script>
11
+
12
+ <DisplacementText text="Hover Me" fontSize={220} class="h-[400px] w-full" />
13
+ ```
14
+
15
+ ```svelte
16
+ <!-- Fixed color instead of following light/dark mode -->
17
+ <DisplacementText text="Fancy" color="#ff4d4d" font="Georgia, serif" />
18
+ ```
19
+
20
+ ## Props
21
+
22
+ | Prop | Type | Default | Description |
23
+ | ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------ |
24
+ | `text` | `string` | `"Hover Me"` | Text to display |
25
+ | `fontSize` | `number` | `200` | Font size in pixels, used when rasterizing the text texture |
26
+ | `font` | `string` | `"Inter, sans-serif"` | Font family |
27
+ | `color` | `string` | — | Fixed text color; overrides `lightColor`/`darkColor` and disables dark-mode tracking |
28
+ | `lightColor` | `string` | `"#000000"` | Text color in light mode |
29
+ | `darkColor` | `string` | `"#ffffff"` | Text color in dark mode |
30
+ | `class` | `string` | `""` | Additional CSS classes on the root |
31
+
32
+ ## Implementation notes
33
+
34
+ - The whole Three.js scene (renderer, camera, geometry, material) is created and torn down inside a single `$effect` that depends on `text`, `fontSize`, `font`, `color`, `lightColor`, and `darkColor` — any of those changing disposes the old scene and builds a new one from scratch rather than updating it incrementally. This is simple but not cheap; avoid binding these props to something that changes every frame.
35
+ - Displacement comes from a plane geometry (100×100 segments) whose vertex shader offsets `z` based on distance from a `uDisplacement` uniform, updated every `pointermove` by raycasting against a large (500×500), fully transparent hit-plane placed in the same scene — the visible text plane itself is not the raycast target.
36
+ - Text is rasterized once per texture rebuild via a 2048×2048 `<canvas>` with `ctx.fillText`, then uploaded as a `THREE.CanvasTexture`.
37
+ - When `color` is not set, a `MutationObserver` watches `document.documentElement`'s `class` attribute (the app's dark-mode toggle) and regenerates the text texture — disposing the old one — whenever the resolved color actually changes. This observer is not created at all when `color` is set.
38
+ - Cleanup on effect teardown: removes the `pointermove` and `resize` listeners, cancels the animation frame, disconnects the `MutationObserver`, removes the renderer's canvas from the DOM, and disposes the renderer, geometry, material, and texture.
39
+ - No `prefers-reduced-motion` handling — the render loop and pointer tracking run regardless of user preference.
@@ -1,6 +1,6 @@
1
1
  # Dock
2
2
 
3
- A macOS-style dock component with icon magnification on hover.
3
+ An icon dock component whose items magnify smoothly as the cursor approaches.
4
4
 
5
5
  ## Components
6
6
 
@@ -0,0 +1,54 @@
1
+ import { render, cleanup } from "@testing-library/svelte";
2
+ import { afterEach, beforeEach, describe, it, expect } from "vitest";
3
+ import EditorialEngine from "./EditorialEngine.svelte";
4
+ // The real engine measures text via @chenglou/pretext, which needs a canvas
5
+ // 2d context or OffscreenCanvas — neither exists under jsdom
6
+ // (HTMLCanvasElement.prototype.getContext is mocked to return null in
7
+ // src/test-setup.ts). Stubbing `document.fonts.ready` with a promise that
8
+ // never resolves keeps the component pinned on its pre-engine fallback
9
+ // branch (the only branch that's meaningfully testable under jsdom) instead
10
+ // of letting the effect's `.then()` handler call into pretext and throw.
11
+ function stubNeverReadyFonts() {
12
+ Object.defineProperty(document, "fonts", {
13
+ configurable: true,
14
+ writable: true,
15
+ value: { ready: new Promise(() => { }) },
16
+ });
17
+ }
18
+ describe("EditorialEngine", () => {
19
+ beforeEach(stubNeverReadyFonts);
20
+ afterEach(cleanup);
21
+ it("mounts and renders the stage without throwing", () => {
22
+ const { container } = render(EditorialEngine);
23
+ expect(container.querySelector(".ee-stage")).toBeTruthy();
24
+ });
25
+ it("applies custom class names to the stage", () => {
26
+ const { container } = render(EditorialEngine, { props: { class: "my-stage" } });
27
+ const stage = container.querySelector(".ee-stage");
28
+ expect(stage.className).toContain("my-stage");
29
+ });
30
+ it("sets the font-family style from the fontFamily prop", () => {
31
+ const { container } = render(EditorialEngine, { props: { fontFamily: "Georgia, serif" } });
32
+ const stage = container.querySelector(".ee-stage");
33
+ expect(stage.style.fontFamily).toContain("Georgia");
34
+ });
35
+ it("renders the pre-engine fallback with the headline and body before the engine is ready", () => {
36
+ const { container } = render(EditorialEngine, {
37
+ props: { headline: "HELLO WORLD", body: "Some body copy." },
38
+ });
39
+ const fallback = container.querySelector(".ee-fallback");
40
+ expect(fallback).toBeTruthy();
41
+ expect(fallback?.querySelector("h2")?.textContent).toBe("HELLO WORLD");
42
+ expect(fallback?.querySelector("p")?.textContent).toBe("Some body copy.");
43
+ });
44
+ it("does not carry the ready class while pretext has not measured yet", () => {
45
+ const { container } = render(EditorialEngine);
46
+ const stage = container.querySelector(".ee-stage");
47
+ expect(stage.classList.contains("ready")).toBe(false);
48
+ });
49
+ it("unmounts cleanly", () => {
50
+ const { container, unmount } = render(EditorialEngine);
51
+ expect(() => unmount()).not.toThrow();
52
+ expect(container.querySelector(".ee-stage")).toBeFalsy();
53
+ });
54
+ });
@@ -4,6 +4,8 @@
4
4
  import {
5
5
  type ColorRGB,
6
6
  type Pointer,
7
+ type FluidCursorHandle,
8
+ type FluidRenderLevel,
7
9
  pointerPrototype,
8
10
  hexToRgb,
9
11
  HSVtoRGB,
@@ -46,6 +48,14 @@
46
48
  contained?: boolean;
47
49
  hdr?: boolean;
48
50
  hdrBoost?: number;
51
+ /**
52
+ * Called once the fluid engine is live, with an imperative handle to
53
+ * drive the simulation programmatically (trace a path via
54
+ * `moveTo`/`penUp`, fire a one-off `burst`) and read back the actual
55
+ * `renderLevel`. Pair with `interactive={false}` to drive it entirely
56
+ * without a real cursor.
57
+ */
58
+ onReady?: (handle: FluidCursorHandle) => void;
49
59
  }
50
60
 
51
61
  let {
@@ -76,6 +86,7 @@
76
86
  contained = true,
77
87
  hdr = false,
78
88
  hdrBoost = 1.5,
89
+ onReady,
79
90
  }: Props = $props();
80
91
 
81
92
  const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
@@ -157,6 +168,31 @@
157
168
  return cleanup;
158
169
  }
159
170
 
171
+ // Inert handle for environments where no renderer came up at all, so a
172
+ // consumer waiting on onReady can tell "unsupported" from "still starting".
173
+ const noRendererHandle: FluidCursorHandle = {
174
+ moveTo() {},
175
+ penUp() {},
176
+ burst() {},
177
+ renderLevel: "none",
178
+ };
179
+
180
+ // Hand the handle to the consumer. Guarded because the WebGPU path invokes
181
+ // this inside its startup promise chain: an exception from the callback would
182
+ // otherwise be caught as a WebGPU setup failure, retrying WebGL on a canvas
183
+ // already claimed by WebGPU and dropping the registered cleanup. Re-thrown
184
+ // asynchronously so it still surfaces as an unhandled error.
185
+ function notifyReady(handle: FluidCursorHandle) {
186
+ if (!onReady) return;
187
+ try {
188
+ onReady(handle);
189
+ } catch (error) {
190
+ queueMicrotask(() => {
191
+ throw error;
192
+ });
193
+ }
194
+ }
195
+
160
196
  onMount(() => {
161
197
  const canvas = canvasRef;
162
198
  if (!canvas) return;
@@ -209,23 +245,26 @@
209
245
  splatOnMount,
210
246
  generateColor,
211
247
  })
212
- .then((cleanup) => {
248
+ .then((handle) => {
213
249
  if (disposed) {
214
- cleanup?.();
250
+ handle?.cleanup();
215
251
  return;
216
252
  }
217
253
  // A newer singleton instance mounted while we were starting up:
218
254
  // registering now would destroy it (newest-mount-wins would
219
255
  // invert). Discard this stale completion instead.
220
256
  if (!allowMultiple && mountToken !== mountCounter) {
221
- cleanup?.();
257
+ handle?.cleanup();
222
258
  return;
223
259
  }
224
- if (!cleanup) {
260
+ if (!handle) {
261
+ // WebGPU unavailable: the WebGL fallback surfaces its own
262
+ // handle through onReady.
225
263
  activeCleanup = startWebGl() ?? null;
226
264
  return;
227
265
  }
228
- activeCleanup = registerInstance(cleanup);
266
+ activeCleanup = registerInstance(handle.cleanup);
267
+ notifyReady(handle);
229
268
  })
230
269
  .catch((error) => {
231
270
  // Unexpected failure while wiring the WebGPU path: fall back
@@ -245,9 +284,24 @@
245
284
 
246
285
  // WebGL engine path (default, and fallback when WebGPU is missing).
247
286
  function startWebGl(): (() => void) | undefined {
287
+ // Wide-gamut probe result, surfaced through the handle's renderLevel.
288
+ let renderLevel: FluidRenderLevel = "webgl-sdr";
289
+ // Dedicated synthetic pointer driven programmatically through the
290
+ // handle. It lives alongside the mouse pointer in the same list, so
291
+ // applyInputs splats both and the two inputs coexist. Only created
292
+ // when a consumer asked for the handle: an unused extra pointer would
293
+ // still consume the shared fluidColors sequence in updateColors.
294
+ const autopilotPointer = onReady ? pointerPrototype() : null;
295
+ if (autopilotPointer) pointers.push(autopilotPointer);
296
+ let penWasUp = true;
297
+
248
298
  // Get WebGL context
249
299
  const context = getWebGLContext(canvas);
250
- if (!context.gl || !context.ext) return;
300
+ if (!context.gl || !context.ext) {
301
+ // No renderer at all (WebGPU already failed if it was tried).
302
+ notifyReady(noRendererHandle);
303
+ return;
304
+ }
251
305
 
252
306
  // These are guaranteed non-null after the check above
253
307
  const gl = context.gl;
@@ -282,15 +336,24 @@
282
336
 
283
337
  // HDR fallback: wide-gamut backbuffer when the WebGPU engine is
284
338
  // unavailable. Colors are reinterpreted in P3 (more saturated).
339
+ // The property is read back because assigning an unsupported color
340
+ // space silently leaves the buffer in sRGB.
285
341
  if (hdr && "drawingBufferColorSpace" in gl) {
286
342
  try {
287
343
  gl.drawingBufferColorSpace = "display-p3";
288
- if (import.meta.env.DEV) {
289
- console.info("[FluidCursor] HDR level: webgl-p3 (wide gamut fallback)");
344
+ if (gl.drawingBufferColorSpace === "display-p3") {
345
+ renderLevel = "webgl-p3";
290
346
  }
291
347
  } catch {
292
348
  // Unsupported color space: buffer stays sRGB.
293
349
  }
350
+ if (import.meta.env.DEV) {
351
+ console.info(
352
+ renderLevel === "webgl-p3"
353
+ ? "[FluidCursor] HDR level: webgl-p3 (wide gamut fallback)"
354
+ : "[FluidCursor] HDR level: webgl-sdr (display-p3 drawing buffer rejected)"
355
+ );
356
+ }
294
357
  }
295
358
 
296
359
  const isWebGL2 = "drawBuffers" in gl;
@@ -1157,6 +1220,10 @@
1157
1220
  if (colorUpdateTimer >= 1) {
1158
1221
  colorUpdateTimer = wrap(colorUpdateTimer, 0, 1);
1159
1222
  pointers.forEach((p) => {
1223
+ // The synthetic pointer's color belongs to the handle: pulling
1224
+ // from the generator here would advance the shared fluidColors
1225
+ // index twice per update and overwrite explicit stroke colors.
1226
+ if (p === autopilotPointer) return;
1160
1227
  p.color = generateColor();
1161
1228
  });
1162
1229
  }
@@ -1467,6 +1534,49 @@
1467
1534
  return delta;
1468
1535
  }
1469
1536
 
1537
+ // Programmatic drive surface (exposed through onReady).
1538
+ //
1539
+ // x,y in [0,1], top-left origin. `updatePointerMoveData` applies
1540
+ // `texcoordY = 1 - posY / height`, so feeding it a top-left pixel Y
1541
+ // yields the same path a real mouse event's clientY takes. This is the
1542
+ // only Y flip on the WebGL path.
1543
+ function autopilotMoveTo(x: number, y: number, color?: ColorRGB) {
1544
+ if (!autopilotPointer) return;
1545
+ const posX = x * canvas.width;
1546
+ const posY = y * canvas.height;
1547
+ if (penWasUp) {
1548
+ // First move of a new stroke: reposition only (mirrors
1549
+ // updatePointerDownData) so the jump from the previous stroke's
1550
+ // end never draws a connecting streak.
1551
+ penWasUp = false;
1552
+ autopilotPointer.down = true;
1553
+ autopilotPointer.moved = false;
1554
+ autopilotPointer.texcoordX = posX / canvas.width;
1555
+ autopilotPointer.texcoordY = 1 - posY / canvas.height;
1556
+ autopilotPointer.prevTexcoordX = autopilotPointer.texcoordX;
1557
+ autopilotPointer.prevTexcoordY = autopilotPointer.texcoordY;
1558
+ autopilotPointer.deltaX = 0;
1559
+ autopilotPointer.deltaY = 0;
1560
+ // A stroke with no explicit color takes a fresh generated one, as
1561
+ // a real pointer-down does: the prototype's black would inject
1562
+ // invisible dye.
1563
+ autopilotPointer.color = color ?? generateColor();
1564
+ return;
1565
+ }
1566
+ updatePointerMoveData(autopilotPointer, posX, posY, color ?? autopilotPointer.color);
1567
+ }
1568
+ function autopilotPenUp() {
1569
+ // `moved` is deliberately left alone: applyInputs consumes and clears
1570
+ // it on the next frame, so clearing it here would swallow the final
1571
+ // segment of a stroke finished synchronously before that frame.
1572
+ penWasUp = true;
1573
+ if (autopilotPointer) autopilotPointer.down = false;
1574
+ }
1575
+ // Top-left origin in, bottom-up texcoords out: flip y and negate dy.
1576
+ function autopilotBurst(x: number, y: number, dx: number, dy: number, color: ColorRGB) {
1577
+ splat(x, 1 - y, dx, -dy, color);
1578
+ }
1579
+
1470
1580
  // Event Listeners
1471
1581
  function handleMouseDown(e: MouseEvent) {
1472
1582
  const pointer = pointers[0];
@@ -1582,7 +1692,18 @@
1582
1692
  }
1583
1693
  }
1584
1694
 
1585
- return registerInstance(cleanup);
1695
+ // Register before notifying: a throwing callback must not cost us the
1696
+ // cleanup registration (listeners, rAF loop and GL resources would
1697
+ // otherwise outlive the component).
1698
+ const dispose = registerInstance(cleanup);
1699
+ notifyReady({
1700
+ moveTo: autopilotMoveTo,
1701
+ penUp: autopilotPenUp,
1702
+ burst: autopilotBurst,
1703
+ renderLevel,
1704
+ });
1705
+
1706
+ return dispose;
1586
1707
  }
1587
1708
  });
1588
1709
  </script>
@@ -1,4 +1,4 @@
1
- import { type ColorRGB } from "./fluid-shared.js";
1
+ import { type ColorRGB, type FluidCursorHandle } from "./fluid-shared.js";
2
2
  interface Props {
3
3
  simResolution?: number;
4
4
  dyeResolution?: number;
@@ -27,6 +27,14 @@ interface Props {
27
27
  contained?: boolean;
28
28
  hdr?: boolean;
29
29
  hdrBoost?: number;
30
+ /**
31
+ * Called once the fluid engine is live, with an imperative handle to
32
+ * drive the simulation programmatically (trace a path via
33
+ * `moveTo`/`penUp`, fire a one-off `burst`) and read back the actual
34
+ * `renderLevel`. Pair with `interactive={false}` to drive it entirely
35
+ * without a real cursor.
36
+ */
37
+ onReady?: (handle: FluidCursorHandle) => void;
30
38
  }
31
39
  declare const FluidCursor: import("svelte").Component<Props, {}, "">;
32
40
  type FluidCursor = ReturnType<typeof FluidCursor>;
@@ -127,4 +127,23 @@ describe("FluidCursor", () => {
127
127
  expect(container.querySelector("canvas")).toBeInTheDocument();
128
128
  });
129
129
  });
130
+ describe("onReady handle", () => {
131
+ // jsdom provides no WebGL context, so this exercises the no-renderer path.
132
+ it('reports renderLevel "none" when no renderer is available', () => {
133
+ const onReady = vi.fn();
134
+ render(FluidCursor, { props: { onReady } });
135
+ expect(onReady).toHaveBeenCalledTimes(1);
136
+ expect(onReady.mock.calls[0][0].renderLevel).toBe("none");
137
+ });
138
+ it("hands over an inert handle that is safe to call", () => {
139
+ const onReady = vi.fn();
140
+ render(FluidCursor, { props: { onReady } });
141
+ const handle = onReady.mock.calls[0][0];
142
+ expect(() => {
143
+ handle.moveTo(0.5, 0.5);
144
+ handle.penUp();
145
+ handle.burst(0.5, 0.5, 10, 10, { r: 1, g: 0, b: 0 });
146
+ }).not.toThrow();
147
+ });
148
+ });
130
149
  });
@@ -38,3 +38,35 @@ export declare function correctRadius(radius: number, width: number, height: num
38
38
  export declare function scaleRadiusForContainer(radius: number, containerHeight: number, viewportHeight: number): number;
39
39
  export declare function correctDeltaX(delta: number, width: number, height: number): number;
40
40
  export declare function correctDeltaY(delta: number, width: number, height: number): number;
41
+ /**
42
+ * How the fluid is actually being rendered, surfaced as data so callers can
43
+ * react (e.g. tell true HDR output apart from a clamped fallback):
44
+ * - "webgpu-hdr": WebGPU with extended tone mapping active AND a display
45
+ * reporting `(dynamic-range: high)` — true HDR output.
46
+ * - "webgpu-sdr": WebGPU float16 + P3, but the browser clamped tone mapping or
47
+ * the output display is SDR.
48
+ * - "webgl-p3": WebGL fallback with a display-p3 drawing buffer.
49
+ * - "webgl-sdr": WebGL fallback, plain sRGB.
50
+ * - "none": no GPU rendering available at all; the handle is inert.
51
+ *
52
+ * Sampled once when the handle is created: it does not update if the window
53
+ * later moves to a display with a different dynamic range.
54
+ */
55
+ export type FluidRenderLevel = "webgpu-hdr" | "webgpu-sdr" | "webgl-p3" | "webgl-sdr" | "none";
56
+ /**
57
+ * Imperative control surface handed to `FluidCursor`'s `onReady` callback so a
58
+ * parent can drive the simulation programmatically (trace a path, fire a
59
+ * one-off burst) and read back which render path actually engaged. Coordinates
60
+ * are in [0,1] with the ORIGIN AT THE TOP-LEFT (CSS convention); velocity is
61
+ * derived from consecutive `moveTo` calls, so smooth paths feel like a real
62
+ * cursor. Any engine-specific Y flip is internal.
63
+ */
64
+ export interface FluidCursorHandle {
65
+ /** Drive the synthetic pointer like a mouse. The next `moveTo` after `penUp` repositions without drawing. */
66
+ moveTo(x: number, y: number, color?: ColorRGB): void;
67
+ /** Lift the pen so the next `moveTo` repositions without producing a connecting streak. */
68
+ penUp(): void;
69
+ /** One-off impulse (click-like burst). Same coord convention; dx/dy in engine velocity units. */
70
+ burst(x: number, y: number, dx: number, dy: number, color: ColorRGB): void;
71
+ readonly renderLevel: FluidRenderLevel;
72
+ }
@@ -3,3 +3,4 @@ export { default as FluidCursor } from "./FluidCursor.svelte";
3
3
  * @deprecated Use `FluidCursor` instead. `FluidCursorAdvanced` has been merged into `FluidCursor`.
4
4
  */
5
5
  export { default as FluidCursorAdvanced } from "./FluidCursor.svelte";
6
+ export type { FluidCursorHandle, FluidRenderLevel } from "./fluid-shared.js";
@@ -1,4 +1,4 @@
1
- import { type ColorRGB } from "./fluid-shared.js";
1
+ import { type ColorRGB, type FluidCursorHandle } from "./fluid-shared.js";
2
2
  export interface WebGpuFluidOptions {
3
3
  simResolution: number;
4
4
  dyeResolution: number;
@@ -23,7 +23,14 @@ export interface WebGpuFluidOptions {
23
23
  }
24
24
  /**
25
25
  * Full WebGPU setup for FluidCursor: engine + pointer wiring + rAF loop +
26
- * visibility pause + auto splats. Resolves with a cleanup function, or with
27
- * `null` when WebGPU is unavailable (caller falls back to the WebGL path).
26
+ * visibility pause + auto splats. Resolves with a {@link FluidCursorHandle}
27
+ * (augmented with `cleanup`), or with `null` when WebGPU is unavailable
28
+ * (caller falls back to the WebGL path).
29
+ *
30
+ * Handle coordinates are top-left origin in [0,1]; WebGPU texcoords already
31
+ * use a top-origin convention, so no Y flip happens here (the WebGL path owns
32
+ * its own flip).
28
33
  */
29
- export declare function startWebGpuFluid(canvas: HTMLCanvasElement, opts: WebGpuFluidOptions): Promise<(() => void) | null>;
34
+ export declare function startWebGpuFluid(canvas: HTMLCanvasElement, opts: WebGpuFluidOptions): Promise<(FluidCursorHandle & {
35
+ cleanup(): void;
36
+ }) | null>;