fancy-ui-svelte 0.7.0 → 0.8.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/README.md +8 -0
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +1132 -1111
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte.d.ts +3 -5
- package/dist/fancy-ui/fluid-cursor/FluidCursor.test.js +29 -0
- package/dist/fancy-ui/fluid-cursor/README.md +57 -6
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +40 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +125 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.d.ts +1 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +39 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +29 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +847 -0
- package/dist/fancy-ui/frosted-glass/FrostedGlass.svelte +5 -3
- package/dist/fancy-ui/frosted-glass/README.md +4 -2
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte +19 -2
- package/dist/fancy-ui/liquid-glass/LiquidGlass.svelte.d.ts +4 -0
- package/dist/fancy-ui/liquid-glass/LiquidGlass.test.js +14 -0
- package/dist/fancy-ui/liquid-glass/README.md +7 -1
- package/dist/fancy-ui/liquid-text/LiquidText.svelte +1151 -0
- package/dist/fancy-ui/liquid-text/LiquidText.svelte.d.ts +37 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.d.ts +1 -0
- package/dist/fancy-ui/liquid-text/LiquidText.test.js +219 -0
- package/dist/fancy-ui/liquid-text/README.md +139 -0
- package/dist/fancy-ui/liquid-text/index.d.ts +2 -0
- package/dist/fancy-ui/liquid-text/index.js +1 -0
- package/dist/fancy-ui/registry.js +124 -0
- package/package.json +4 -3
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface LiquidTextProps {
|
|
2
|
+
/** Text rendered into the fluid texture (or as static fallback text). */
|
|
3
|
+
text?: string;
|
|
4
|
+
/** CSS font-family. Empty string resolves to getComputedStyle(host).fontFamily. */
|
|
5
|
+
font?: string;
|
|
6
|
+
/** Font size in px. 0 auto-fits the text to the container's width. */
|
|
7
|
+
fontSize?: number;
|
|
8
|
+
/** CSS font-weight for the rasterized/fallback text. */
|
|
9
|
+
fontWeight?: number | string;
|
|
10
|
+
/** Text color used in light mode. */
|
|
11
|
+
lightColor?: string;
|
|
12
|
+
/** Text color used in dark mode. */
|
|
13
|
+
darkColor?: string;
|
|
14
|
+
/** Additional CSS classes applied to the root element. */
|
|
15
|
+
class?: string;
|
|
16
|
+
/** Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. */
|
|
17
|
+
strength?: number;
|
|
18
|
+
/** Splat radius in screen pixels around the pointer. */
|
|
19
|
+
radius?: number;
|
|
20
|
+
/** Multiplier from mouse-delta-per-frame to splat force. */
|
|
21
|
+
forceGain?: number;
|
|
22
|
+
/** Per-frame velocity decay factor (relax-back rate for the smear). */
|
|
23
|
+
dissipation?: number;
|
|
24
|
+
/** Viscous diffusion strength (Jacobi iteration, 8 iters). */
|
|
25
|
+
viscosity?: number;
|
|
26
|
+
/** Chromatic offset = warp amount x this ratio. */
|
|
27
|
+
chromaticRatio?: number;
|
|
28
|
+
/** If window.innerWidth <= staticBelow at mount, render static DOM text instead. */
|
|
29
|
+
staticBelow?: number;
|
|
30
|
+
/** Whether the fluid sim reacts to pointer movement. */
|
|
31
|
+
interactive?: boolean;
|
|
32
|
+
/** Pause the render loop via visibilitychange when the tab/page is hidden. */
|
|
33
|
+
pauseWhenHidden?: boolean;
|
|
34
|
+
}
|
|
35
|
+
declare const LiquidText: import("svelte").Component<LiquidTextProps, {}, "">;
|
|
36
|
+
type LiquidText = ReturnType<typeof LiquidText>;
|
|
37
|
+
export default LiquidText;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { render, screen, cleanup } from "@testing-library/svelte";
|
|
2
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
3
|
+
import LiquidText from "./LiquidText.svelte";
|
|
4
|
+
/**
|
|
5
|
+
* Ambient test environment (see src/test-setup.ts) already stubs:
|
|
6
|
+
* - HTMLCanvasElement.prototype.getContext -> null (WebGL "unavailable")
|
|
7
|
+
* - window.matchMedia -> { matches: false, ... }
|
|
8
|
+
* - ResizeObserver / IntersectionObserver -> no-op classes
|
|
9
|
+
*
|
|
10
|
+
* Per the frozen LiquidText contract, "no WebGL" and "no reduced-motion
|
|
11
|
+
* preference" both drive the component into its DOM-fallback path. Tests
|
|
12
|
+
* below that specifically target the fallback/no-WebGL/reduced-motion
|
|
13
|
+
* behavior re-assert these overrides explicitly (mirroring the ambient
|
|
14
|
+
* default) so intent is self-documenting and independent of test-setup.ts.
|
|
15
|
+
*/
|
|
16
|
+
describe("LiquidText", () => {
|
|
17
|
+
afterEach(cleanup);
|
|
18
|
+
describe("accessible text node", () => {
|
|
19
|
+
it("renders the text prop as a real text node in the DOM", () => {
|
|
20
|
+
render(LiquidText, { props: { text: "Hello Liquid" } });
|
|
21
|
+
const textNode = screen.getByText("Hello Liquid");
|
|
22
|
+
expect(textNode).toBeInTheDocument();
|
|
23
|
+
// The contract only requires the *canvas* to be aria-hidden; the
|
|
24
|
+
// real text node itself must stay in the accessibility tree,
|
|
25
|
+
// whether it is the sr-only span (canvas active) or the visible
|
|
26
|
+
// fallback text.
|
|
27
|
+
expect(textNode.getAttribute("aria-hidden")).not.toBe("true");
|
|
28
|
+
});
|
|
29
|
+
it('falls back to the default text "Liquid" when no text prop is passed', () => {
|
|
30
|
+
render(LiquidText);
|
|
31
|
+
expect(screen.getByText("Liquid")).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
it("marks the canvas aria-hidden when a canvas is rendered", () => {
|
|
34
|
+
const { container } = render(LiquidText, { props: { text: "Canvas Check" } });
|
|
35
|
+
const canvas = container.querySelector("canvas");
|
|
36
|
+
// The contract does not pin down whether <canvas> is always present
|
|
37
|
+
// in the markup or only mounted on the interactive/WebGL path, so
|
|
38
|
+
// this only asserts when one is actually found.
|
|
39
|
+
if (canvas) {
|
|
40
|
+
expect(canvas.getAttribute("aria-hidden")).toBe("true");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("WebGL unavailable fallback", () => {
|
|
45
|
+
it("renders visible styled fallback text, starts no rAF render loop, and logs no console errors", () => {
|
|
46
|
+
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
|
47
|
+
HTMLCanvasElement.prototype.getContext = (() => null);
|
|
48
|
+
const rafSpy = vi.spyOn(window, "requestAnimationFrame");
|
|
49
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
50
|
+
render(LiquidText, {
|
|
51
|
+
props: {
|
|
52
|
+
text: "No WebGL",
|
|
53
|
+
font: "Georgia, serif",
|
|
54
|
+
fontSize: 48,
|
|
55
|
+
fontWeight: 600,
|
|
56
|
+
lightColor: "#123456",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const textNode = screen.getByText("No WebGL");
|
|
60
|
+
expect(textNode).toBeInTheDocument();
|
|
61
|
+
// Per contract: "visible styled text in fallback modes", as opposed
|
|
62
|
+
// to the sr-only span used when the canvas is actively driven. It
|
|
63
|
+
// must actually carry the prop-driven font/size/color styling, not
|
|
64
|
+
// just be present and unstyled.
|
|
65
|
+
expect(textNode.className).not.toContain("sr-only");
|
|
66
|
+
expect(textNode.style.fontFamily).toBe("Georgia, serif");
|
|
67
|
+
expect(textNode.style.fontSize).toBe("48px");
|
|
68
|
+
expect(textNode.style.fontWeight).toBe("600");
|
|
69
|
+
expect(textNode.style.color).toBe("rgb(18, 52, 86)");
|
|
70
|
+
expect(rafSpy).not.toHaveBeenCalled();
|
|
71
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
72
|
+
rafSpy.mockRestore();
|
|
73
|
+
errorSpy.mockRestore();
|
|
74
|
+
HTMLCanvasElement.prototype.getContext = originalGetContext;
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("prefers-reduced-motion fallback", () => {
|
|
78
|
+
it("renders static fallback text and starts no rAF render loop when reduced motion is preferred", () => {
|
|
79
|
+
const originalMatchMedia = window.matchMedia;
|
|
80
|
+
Object.defineProperty(window, "matchMedia", {
|
|
81
|
+
writable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
value: (query) => ({
|
|
84
|
+
matches: query.includes("prefers-reduced-motion"),
|
|
85
|
+
media: query,
|
|
86
|
+
onchange: null,
|
|
87
|
+
addEventListener: () => { },
|
|
88
|
+
removeEventListener: () => { },
|
|
89
|
+
dispatchEvent: () => false,
|
|
90
|
+
addListener: () => { },
|
|
91
|
+
removeListener: () => { },
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const rafSpy = vi.spyOn(window, "requestAnimationFrame");
|
|
95
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
96
|
+
render(LiquidText, {
|
|
97
|
+
props: {
|
|
98
|
+
text: "Reduced Motion",
|
|
99
|
+
font: "Georgia, serif",
|
|
100
|
+
fontSize: 32,
|
|
101
|
+
fontWeight: 500,
|
|
102
|
+
lightColor: "#654321",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const textNode = screen.getByText("Reduced Motion");
|
|
106
|
+
expect(textNode).toBeInTheDocument();
|
|
107
|
+
expect(textNode.className).not.toContain("sr-only");
|
|
108
|
+
// Same requirement as the no-WebGL fallback: styled per the props,
|
|
109
|
+
// not just present.
|
|
110
|
+
expect(textNode.style.fontFamily).toBe("Georgia, serif");
|
|
111
|
+
expect(textNode.style.fontSize).toBe("32px");
|
|
112
|
+
expect(textNode.style.fontWeight).toBe("500");
|
|
113
|
+
expect(textNode.style.color).toBe("rgb(101, 67, 33)");
|
|
114
|
+
expect(rafSpy).not.toHaveBeenCalled();
|
|
115
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
116
|
+
rafSpy.mockRestore();
|
|
117
|
+
errorSpy.mockRestore();
|
|
118
|
+
Object.defineProperty(window, "matchMedia", {
|
|
119
|
+
writable: true,
|
|
120
|
+
configurable: true,
|
|
121
|
+
value: originalMatchMedia,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("cleanup on repeated mount/unmount", () => {
|
|
126
|
+
it("leaves window/document/body listener counts balanced and no dangling rAF after 5 mount/unmount cycles", () => {
|
|
127
|
+
const targets = [window, document, document.body];
|
|
128
|
+
const addSpies = targets.map((t) => vi.spyOn(t, "addEventListener"));
|
|
129
|
+
const removeSpies = targets.map((t) => vi.spyOn(t, "removeEventListener"));
|
|
130
|
+
let nextRafId = 1;
|
|
131
|
+
const pendingRafIds = new Set();
|
|
132
|
+
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => {
|
|
133
|
+
const id = nextRafId++;
|
|
134
|
+
pendingRafIds.add(id);
|
|
135
|
+
return id;
|
|
136
|
+
});
|
|
137
|
+
const cafSpy = vi
|
|
138
|
+
.spyOn(window, "cancelAnimationFrame")
|
|
139
|
+
.mockImplementation((id) => {
|
|
140
|
+
pendingRafIds.delete(id);
|
|
141
|
+
});
|
|
142
|
+
for (let i = 0; i < 5; i++) {
|
|
143
|
+
const { unmount } = render(LiquidText, { props: { text: `pass-${i}` } });
|
|
144
|
+
unmount();
|
|
145
|
+
}
|
|
146
|
+
// Per-event-name balance on each target: every addEventListener("x", …)
|
|
147
|
+
// must be matched by a removeEventListener("x", …) across the 5
|
|
148
|
+
// mount/unmount cycles. Checking per event name (not just raw totals)
|
|
149
|
+
// catches leaks that would otherwise cancel out coincidentally.
|
|
150
|
+
targets.forEach((_, i) => {
|
|
151
|
+
const counts = new Map();
|
|
152
|
+
for (const call of addSpies[i].mock.calls) {
|
|
153
|
+
const name = call[0];
|
|
154
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
155
|
+
}
|
|
156
|
+
for (const call of removeSpies[i].mock.calls) {
|
|
157
|
+
const name = call[0];
|
|
158
|
+
counts.set(name, (counts.get(name) ?? 0) - 1);
|
|
159
|
+
}
|
|
160
|
+
for (const [eventName, balance] of counts) {
|
|
161
|
+
expect(balance, `"${eventName}" listener leak on target #${i}`).toBe(0);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
expect(pendingRafIds.size).toBe(0);
|
|
165
|
+
addSpies.forEach((s) => s.mockRestore());
|
|
166
|
+
removeSpies.forEach((s) => s.mockRestore());
|
|
167
|
+
rafSpy.mockRestore();
|
|
168
|
+
cafSpy.mockRestore();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe("class prop", () => {
|
|
172
|
+
it("applies the class prop to the root element", () => {
|
|
173
|
+
const { container } = render(LiquidText, { props: { class: "my-liquid-class" } });
|
|
174
|
+
const root = container.firstElementChild;
|
|
175
|
+
expect(root.className).toContain("my-liquid-class");
|
|
176
|
+
});
|
|
177
|
+
it("still renders a root element with the default empty class", () => {
|
|
178
|
+
const { container } = render(LiquidText);
|
|
179
|
+
expect(container.firstElementChild).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe("prop defaults and full prop surface (smoke)", () => {
|
|
183
|
+
it("mounts without error using only defaults", () => {
|
|
184
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
185
|
+
const { container } = render(LiquidText);
|
|
186
|
+
expect(container.firstElementChild).toBeInTheDocument();
|
|
187
|
+
expect(screen.getByText("Liquid")).toBeInTheDocument();
|
|
188
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
189
|
+
errorSpy.mockRestore();
|
|
190
|
+
});
|
|
191
|
+
it("mounts without error with every documented prop explicitly set", () => {
|
|
192
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => { });
|
|
193
|
+
const { container } = render(LiquidText, {
|
|
194
|
+
props: {
|
|
195
|
+
text: "Smoke Test",
|
|
196
|
+
font: "Arial, sans-serif",
|
|
197
|
+
fontSize: 96,
|
|
198
|
+
fontWeight: 800,
|
|
199
|
+
lightColor: "#111111",
|
|
200
|
+
darkColor: "#eeeeee",
|
|
201
|
+
class: "smoke-root",
|
|
202
|
+
strength: 0.75,
|
|
203
|
+
radius: 220,
|
|
204
|
+
forceGain: 24,
|
|
205
|
+
dissipation: 0.95,
|
|
206
|
+
viscosity: 6,
|
|
207
|
+
chromaticRatio: 0.35,
|
|
208
|
+
staticBelow: 768,
|
|
209
|
+
interactive: false,
|
|
210
|
+
pauseWhenHidden: false,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
expect(container.firstElementChild).toBeInTheDocument();
|
|
214
|
+
expect(screen.getByText("Smoke Test")).toBeInTheDocument();
|
|
215
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
216
|
+
errorSpy.mockRestore();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# LiquidText
|
|
2
|
+
|
|
3
|
+
Large display text that liquefies under the cursor: a raw-WebGL fluid velocity
|
|
4
|
+
field displaces the text's UVs along the pointer's trail, with RGB chromatic
|
|
5
|
+
fringing on the warped edges, then relaxes back to rest as the fluid decays.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Raw WebGL2 (WebGL1 fallback) fluid simulation — no Three.js dependency
|
|
10
|
+
- Any string rendered through the sim: text is rasterized to a texture via
|
|
11
|
+
Canvas2D (`fillText`), not a pre-baked image
|
|
12
|
+
- Mouse-velocity-driven splats: faster cursor movement = stronger smear
|
|
13
|
+
- Chromatic aberration on displaced edges (3-tap R/G/B sampling)
|
|
14
|
+
- Self-relaxing: velocity field decays every frame, so the smear resolves on
|
|
15
|
+
its own roughly a second after the cursor moves away
|
|
16
|
+
- Auto-fit font size to the container width when `fontSize` is unset
|
|
17
|
+
- Graceful fallbacks: `prefers-reduced-motion`, no WebGL, and small viewports
|
|
18
|
+
(`staticBelow`) all render plain styled static text instead of the sim
|
|
19
|
+
- Accessible by construction: a real text node is always present in the DOM;
|
|
20
|
+
only the `<canvas>` is `aria-hidden`
|
|
21
|
+
- Single `requestAnimationFrame` loop, paused via `visibilitychange` when the
|
|
22
|
+
tab is hidden (`pauseWhenHidden`)
|
|
23
|
+
- Exhaustive cleanup on unmount: cancels the rAF loop, removes every
|
|
24
|
+
listener, disconnects the `ResizeObserver`, and calls `WEBGL_lose_context`
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```svelte
|
|
29
|
+
<script>
|
|
30
|
+
import { LiquidText } from "fancy-ui-svelte";
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<LiquidText text="Liquid" class="text-7xl font-bold" />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```svelte
|
|
37
|
+
<!-- Tuned for a subtler, slower-decaying smear -->
|
|
38
|
+
<LiquidText
|
|
39
|
+
text="Rama Herbin"
|
|
40
|
+
font="Bricolage Grotesque, sans-serif"
|
|
41
|
+
fontWeight={700}
|
|
42
|
+
strength={0.35}
|
|
43
|
+
radius={120}
|
|
44
|
+
dissipation={0.985}
|
|
45
|
+
lightColor="#0a0a0a"
|
|
46
|
+
darkColor="#fafafa"
|
|
47
|
+
/>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```svelte
|
|
51
|
+
<!-- Force the static DOM fallback (e.g. inside a `prefers-reduced-motion`
|
|
52
|
+
conscious layout, or below a custom breakpoint) -->
|
|
53
|
+
<LiquidText text="Liquid" interactive={false} staticBelow={1280} />
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Props
|
|
57
|
+
|
|
58
|
+
| Prop | Type | Default | Description |
|
|
59
|
+
| ---------------- | -------------------- | ----------- | -------------------------------------------------------------------------------------- |
|
|
60
|
+
| `text` | `string` | `"Liquid"` | The text rendered into the fluid texture (or as static fallback text). |
|
|
61
|
+
| `font` | `string` | `""` | CSS font-family for the text. Empty string resolves to `getComputedStyle(host).fontFamily`. |
|
|
62
|
+
| `fontSize` | `number` | `0` | Font size in px. `0` auto-fits the text to the container's width. |
|
|
63
|
+
| `fontWeight` | `number \| string` | `700` | CSS font-weight for the rasterized/fallback text. |
|
|
64
|
+
| `lightColor` | `string` | `"#000000"` | Text color used in light mode. |
|
|
65
|
+
| `darkColor` | `string` | `"#ffffff"` | Text color used in dark mode. |
|
|
66
|
+
| `class` | `string` | `""` | Additional class names applied to the root element. |
|
|
67
|
+
| `strength` | `number` | `0.5` | Geometric UV warp gain — how far the fluid velocity displaces the text's UVs. |
|
|
68
|
+
| `radius` | `number` | `160` | Splat radius in screen pixels around the pointer. |
|
|
69
|
+
| `forceGain` | `number` | `17` | Multiplier from mouse-delta-per-frame to splat force. |
|
|
70
|
+
| `dissipation` | `number` | `0.98` | Per-frame velocity decay factor (relax-back rate for the smear). |
|
|
71
|
+
| `viscosity` | `number` | `4` | Viscous diffusion strength (Jacobi iteration, 8 iters). |
|
|
72
|
+
| `chromaticRatio` | `number` | `0.2` | Chromatic offset = warp amount × this ratio. |
|
|
73
|
+
| `staticBelow` | `number` | `1024` | If `window.innerWidth <= staticBelow` at mount, render static DOM text instead. |
|
|
74
|
+
| `interactive` | `boolean` | `true` | Whether the fluid sim reacts to pointer movement. |
|
|
75
|
+
| `pauseWhenHidden`| `boolean` | `true` | Pause the render loop via `visibilitychange` when the tab/page is hidden. |
|
|
76
|
+
|
|
77
|
+
## Behavior contract
|
|
78
|
+
|
|
79
|
+
- A real text node is **always** in the DOM: a `sr-only` span while the
|
|
80
|
+
canvas is actively driven, or visible styled text in any fallback mode.
|
|
81
|
+
- The `<canvas>` is always `aria-hidden="true"` — it is a visual effect
|
|
82
|
+
layer, not content.
|
|
83
|
+
- The component falls back to plain styled DOM text (no WebGL sim, no rAF
|
|
84
|
+
loop) when any of these are true at mount: `prefers-reduced-motion` is
|
|
85
|
+
preferred, WebGL is unavailable, or `window.innerWidth <= staticBelow`.
|
|
86
|
+
- Root markup shape: `<div class={...}>` wrapping the canvas and the text
|
|
87
|
+
node.
|
|
88
|
+
|
|
89
|
+
## Implementation notes
|
|
90
|
+
|
|
91
|
+
### Fluid simulation
|
|
92
|
+
|
|
93
|
+
- Runs the velocity solve at **0.25× the canvas resolution** to keep the
|
|
94
|
+
per-frame cost low for a text-sized effect (this doesn't need dye-buffer
|
|
95
|
+
resolution the way a full-screen cursor effect does).
|
|
96
|
+
- Pipeline per frame: self-advection with `dissipation` decay → pointer-velocity
|
|
97
|
+
splat (screen-px falloff `(1 - dist/radius)² × 1.5`, force ∝ mouse delta ×
|
|
98
|
+
`forceGain`) → viscous diffusion (Jacobi, `viscosity`, 8 iterations) →
|
|
99
|
+
divergence → pressure solve (Jacobi, 16 iterations) → gradient
|
|
100
|
+
subtraction/projection. Vorticity confinement is intentionally left out —
|
|
101
|
+
it isn't part of the reference behavior being reproduced and would add
|
|
102
|
+
cost without visible benefit at this scale. `dt` is fixed rather than
|
|
103
|
+
measured from real frame time, so the decay rate stays predictable
|
|
104
|
+
regardless of frame rate.
|
|
105
|
+
- Text is rasterized once per (text, font, fontSize, fontWeight) combination
|
|
106
|
+
via Canvas2D `fillText` into a texture, and re-rasterized on `ResizeObserver`
|
|
107
|
+
changes that affect layout (e.g. auto-fit font size).
|
|
108
|
+
|
|
109
|
+
### Chromatic sampling
|
|
110
|
+
|
|
111
|
+
- The fragment shader reads the velocity field at the screen UV, derives a
|
|
112
|
+
geometric warp (`velocity * strength`) and a chromatic offset
|
|
113
|
+
(`warp * chromaticRatio`), then does 3 independent texture taps: red at
|
|
114
|
+
`uv - offset`, green at `uv`, blue at `uv + offset`. The result is a
|
|
115
|
+
visible RGB fringe exactly where the text is being smeared, which
|
|
116
|
+
disappears as the velocity field dissipates.
|
|
117
|
+
|
|
118
|
+
### Fallbacks
|
|
119
|
+
|
|
120
|
+
- `prefers-reduced-motion: reduce` and "no WebGL context available" both
|
|
121
|
+
short-circuit straight to the static DOM text path — no canvas sim is
|
|
122
|
+
initialized, no rAF loop starts.
|
|
123
|
+
- `staticBelow` is checked once against `window.innerWidth` at mount (not
|
|
124
|
+
reactively re-checked on resize) — crossing the breakpoint after mount
|
|
125
|
+
does not retroactively swap modes.
|
|
126
|
+
- All three fallback conditions render the same static markup: visible,
|
|
127
|
+
normally-styled text using `lightColor`/`darkColor` per color scheme.
|
|
128
|
+
|
|
129
|
+
### Cleanup
|
|
130
|
+
|
|
131
|
+
- A single `requestAnimationFrame` loop drives the sim; it is paused (not
|
|
132
|
+
cancelled) on `document.visibilitychange` when `pauseWhenHidden` is true,
|
|
133
|
+
and resumed when the tab becomes visible again.
|
|
134
|
+
- On unmount: cancels the pending rAF, removes every `window`/`document`
|
|
135
|
+
listener the component registered, disconnects the `ResizeObserver`, and
|
|
136
|
+
explicitly calls the `WEBGL_lose_context` extension's `loseContext()` to
|
|
137
|
+
release GPU resources deterministically rather than waiting on GC.
|
|
138
|
+
- Verified safe under repeated mount/unmount (no leaked listeners, no
|
|
139
|
+
dangling animation frames) — see `LiquidText.test.ts`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as LiquidText } from "./LiquidText.svelte";
|
|
@@ -935,6 +935,18 @@ export const registry = {
|
|
|
935
935
|
default: "true",
|
|
936
936
|
description: "Confine simulation to parent container",
|
|
937
937
|
},
|
|
938
|
+
{
|
|
939
|
+
name: "hdr",
|
|
940
|
+
type: "boolean",
|
|
941
|
+
default: "false",
|
|
942
|
+
description: "Render via the WebGPU HDR engine — wide gamut + brighter-than-white glow on HDR displays. Needs Chrome/Edge 129+ or Safari 26+; falls back to wide-gamut WebGL (Chrome 104+, Safari 16.4+, Firefox 132+), then to standard rendering",
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
name: "hdrBoost",
|
|
946
|
+
type: "number",
|
|
947
|
+
default: "1.5",
|
|
948
|
+
description: "Display exposure multiplier in HDR mode, clamped to [1, 4]",
|
|
949
|
+
},
|
|
938
950
|
],
|
|
939
951
|
},
|
|
940
952
|
"glow-border": {
|
|
@@ -1779,6 +1791,18 @@ export const registry = {
|
|
|
1779
1791
|
},
|
|
1780
1792
|
{ name: "scale", type: "number", default: "-180", description: "Displacement scale" },
|
|
1781
1793
|
{ name: "frost", type: "number", default: "0.05", description: "Frosted glass opacity" },
|
|
1794
|
+
{
|
|
1795
|
+
name: "fallbackBlur",
|
|
1796
|
+
type: "number",
|
|
1797
|
+
default: "20",
|
|
1798
|
+
description: "Backdrop blur in pixels for the Safari fallback",
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
name: "fallbackSaturation",
|
|
1802
|
+
type: "number",
|
|
1803
|
+
default: "180",
|
|
1804
|
+
description: "Backdrop saturation percentage for the Safari fallback",
|
|
1805
|
+
},
|
|
1782
1806
|
{
|
|
1783
1807
|
name: "containerClass",
|
|
1784
1808
|
type: "string",
|
|
@@ -2225,6 +2249,106 @@ export const registry = {
|
|
|
2225
2249
|
},
|
|
2226
2250
|
],
|
|
2227
2251
|
},
|
|
2252
|
+
"liquid-text": {
|
|
2253
|
+
name: "LiquidText",
|
|
2254
|
+
slug: "liquid-text",
|
|
2255
|
+
description: "Big text that liquefies along the cursor's path via a raw-WebGL fluid solver, with chromatic-aberration fringing that relaxes back over time",
|
|
2256
|
+
category: "text",
|
|
2257
|
+
status: "done",
|
|
2258
|
+
credits: [
|
|
2259
|
+
{ source: "Inspired by ryanritzenthaler.com (behavioral study)" },
|
|
2260
|
+
{ source: "Fluid solver after Pavel Dobryakov's WebGL fluid simulation" },
|
|
2261
|
+
],
|
|
2262
|
+
tags: ["text", "webgl", "fluid", "displacement", "cursor", "shader", "chromatic-aberration"],
|
|
2263
|
+
props: [
|
|
2264
|
+
{ name: "text", type: "string", default: '"Liquid"', description: "Text to display" },
|
|
2265
|
+
{
|
|
2266
|
+
name: "font",
|
|
2267
|
+
type: "string",
|
|
2268
|
+
default: '""',
|
|
2269
|
+
description: "Font family; empty string resolves via getComputedStyle(host).fontFamily",
|
|
2270
|
+
},
|
|
2271
|
+
{
|
|
2272
|
+
name: "fontSize",
|
|
2273
|
+
type: "number",
|
|
2274
|
+
default: "0",
|
|
2275
|
+
description: "Font size in pixels; 0 auto-fits to the container width",
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
name: "fontWeight",
|
|
2279
|
+
type: "number | string",
|
|
2280
|
+
default: "700",
|
|
2281
|
+
description: "Font weight",
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
name: "lightColor",
|
|
2285
|
+
type: "string",
|
|
2286
|
+
default: '"#000000"',
|
|
2287
|
+
description: "Text color in light mode",
|
|
2288
|
+
},
|
|
2289
|
+
{
|
|
2290
|
+
name: "darkColor",
|
|
2291
|
+
type: "string",
|
|
2292
|
+
default: '"#ffffff"',
|
|
2293
|
+
description: "Text color in dark mode",
|
|
2294
|
+
},
|
|
2295
|
+
{ name: "class", type: "string", description: "Additional CSS classes" },
|
|
2296
|
+
{
|
|
2297
|
+
name: "strength",
|
|
2298
|
+
type: "number",
|
|
2299
|
+
default: "0.5",
|
|
2300
|
+
description: "Geometric UV warp gain",
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
name: "radius",
|
|
2304
|
+
type: "number",
|
|
2305
|
+
default: "160",
|
|
2306
|
+
description: "Splat radius in screen pixels",
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
name: "forceGain",
|
|
2310
|
+
type: "number",
|
|
2311
|
+
default: "17",
|
|
2312
|
+
description: "Mouse-delta to force multiplier",
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
name: "dissipation",
|
|
2316
|
+
type: "number",
|
|
2317
|
+
default: "0.98",
|
|
2318
|
+
description: "Per-frame velocity decay factor",
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
name: "viscosity",
|
|
2322
|
+
type: "number",
|
|
2323
|
+
default: "4",
|
|
2324
|
+
description: "Jacobi viscous diffusion strength (8 iterations)",
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
name: "chromaticRatio",
|
|
2328
|
+
type: "number",
|
|
2329
|
+
default: "0.2",
|
|
2330
|
+
description: "Chromatic offset as a ratio of the warp",
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
name: "staticBelow",
|
|
2334
|
+
type: "number",
|
|
2335
|
+
default: "1024",
|
|
2336
|
+
description: "innerWidth at/below which the component renders as static DOM text (also used at mount)",
|
|
2337
|
+
},
|
|
2338
|
+
{
|
|
2339
|
+
name: "interactive",
|
|
2340
|
+
type: "boolean",
|
|
2341
|
+
default: "true",
|
|
2342
|
+
description: "Whether pointer movement drives the fluid simulation",
|
|
2343
|
+
},
|
|
2344
|
+
{
|
|
2345
|
+
name: "pauseWhenHidden",
|
|
2346
|
+
type: "boolean",
|
|
2347
|
+
default: "true",
|
|
2348
|
+
description: "Pause the render loop while the document is hidden",
|
|
2349
|
+
},
|
|
2350
|
+
],
|
|
2351
|
+
},
|
|
2228
2352
|
};
|
|
2229
2353
|
// =============================================================================
|
|
2230
2354
|
// Registry Helpers
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fancy-ui-svelte",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -60,7 +60,6 @@
|
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@changesets/cli": "^2.30.0",
|
|
63
|
-
"@vercel/analytics": "^2.0.1",
|
|
64
63
|
"@internationalized/date": "^3.10.1",
|
|
65
64
|
"@lucide/svelte": "^0.562.0",
|
|
66
65
|
"@playwright/test": "^1.58.2",
|
|
@@ -80,12 +79,13 @@
|
|
|
80
79
|
"@types/canvas-confetti": "^1.9.0",
|
|
81
80
|
"@types/node": "^25.2.3",
|
|
82
81
|
"@types/three": "^0.183.1",
|
|
82
|
+
"@vercel/analytics": "^2.0.1",
|
|
83
|
+
"@webgpu/types": "^0.1.71",
|
|
83
84
|
"arctic": "^3.7.0",
|
|
84
85
|
"bits-ui": "^2.15.4",
|
|
85
86
|
"isomorphic-dompurify": "^3.7.1",
|
|
86
87
|
"jsdom": "^28.0.0",
|
|
87
88
|
"marked": "^17.0.3",
|
|
88
|
-
"mdsvex": "^0.12.7",
|
|
89
89
|
"nanoid": "^5.1.6",
|
|
90
90
|
"prettier": "^3.8.1",
|
|
91
91
|
"prettier-plugin-svelte": "^3.5.0",
|
|
@@ -109,6 +109,7 @@
|
|
|
109
109
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
110
110
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
111
111
|
"check:registry": "node scripts/check-registry.mjs",
|
|
112
|
+
"check:i18n": "node scripts/check-i18n.mjs",
|
|
112
113
|
"test": "vitest run",
|
|
113
114
|
"test:watch": "vitest",
|
|
114
115
|
"test:e2e": "playwright test",
|