fancy-ui-svelte 0.12.0 → 0.13.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.
@@ -48,8 +48,11 @@
48
48
  </script>
49
49
 
50
50
  <script lang="ts">
51
+ import { fade } from "svelte/transition";
51
52
  import { cn } from "../../utils.js";
52
53
  import { sound as soundFx } from "../sound/sound.svelte.js";
54
+ import { prefersReducedMotion } from "../_internals/motion/anchored.js";
55
+ import { DURATIONS } from "../_internals/motion/tokens.js";
53
56
 
54
57
  let {
55
58
  variant = "primary",
@@ -93,7 +96,14 @@
93
96
  cn(
94
97
  "ft-btn",
95
98
  "inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium",
96
- "cursor-pointer transition-colors",
99
+ // No `transition-colors` here: the scoped style block below declares a
100
+ // `transition` shorthand on this same element (it has to, so the press
101
+ // scale can join the colour channel under `prefers-reduced-motion:
102
+ // no-preference`), and Svelte's scoped CSS is unlayered while Tailwind's
103
+ // utilities sit in `@layer utilities` — the utility would lose silently
104
+ // and read as a colour transition that never ran. The colour channel is
105
+ // re-declared by hand at exactly the values it resolved to.
106
+ "cursor-pointer",
97
107
  "focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--ft-btn-accent)]/35",
98
108
  // `data-disabled` covers the anchor branch, which has no native `:disabled`
99
109
  // pseudo-class to hang the same dimmed treatment off. It tracks `disabled`
@@ -132,6 +142,21 @@
132
142
  // can't drift out of sync with each other.
133
143
  const anchorInert = $derived(disabled || loading);
134
144
 
145
+ // The one duration the lead slot's cross-fade runs on: `DURATIONS.micro`, the
146
+ // glyph-scale rung, collapsed to 0 when the user has asked for less motion —
147
+ // at 0 Svelte skips `element.animate()` entirely and the swap is synchronous,
148
+ // which is the same behaviour this button had before the fade existed.
149
+ //
150
+ // Deliberately a plain function, never a `$derived`: `prefersReducedMotion()`
151
+ // resolves `window.matchMedia` fresh on every call and its own contract
152
+ // (`_internals/motion/anchored.ts`) forbids reading it from a `$derived`,
153
+ // where the answer would be computed once and then never revisited. A
154
+ // transition's params thunk runs once per transition, at the instant that
155
+ // transition starts — exactly the call site that contract sanctions.
156
+ function leadFade() {
157
+ return prefersReducedMotion() ? 0 : DURATIONS.micro;
158
+ }
159
+
135
160
  // The single guard both branches funnel through. A native `disabled` button
136
161
  // already refuses real pointer/keyboard input, but a synthetic `.click()` (or
137
162
  // an anchor, which has no disabled state at all) walks straight past that —
@@ -160,10 +185,29 @@
160
185
  tabindex={anchorInert ? -1 : undefined}
161
186
  onclick={handleClick}
162
187
  >
163
- {#if loading}
164
- <span class="ft-btn-spinner" aria-hidden="true"></span>
165
- {:else if iconStart}
166
- {@render iconStart()}
188
+ <!--
189
+ The lead slot. Both branches of this component render it identically —
190
+ see the button branch below for why the split `in:` / `out:` pair is
191
+ the right shape here rather than one bidirectional `transition:`.
192
+ -->
193
+ {#if loading || iconStart}
194
+ <span class="ft-btn-lead" aria-hidden={loading ? "true" : undefined}>
195
+ {#if loading}
196
+ <span
197
+ class="ft-btn-spinner"
198
+ in:fade={{ duration: leadFade() }}
199
+ out:fade={{ duration: leadFade() }}
200
+ ></span>
201
+ {:else}
202
+ <span
203
+ class="ft-btn-lead-icon"
204
+ in:fade={{ duration: leadFade() }}
205
+ out:fade={{ duration: leadFade() }}
206
+ >
207
+ {@render iconStart?.()}
208
+ </span>
209
+ {/if}
210
+ </span>
167
211
  {/if}
168
212
  {@render children?.()}
169
213
  {#if iconEnd}
@@ -180,10 +224,46 @@
180
224
  aria-busy={loading ? "true" : undefined}
181
225
  onclick={handleClick}
182
226
  >
183
- {#if loading}
184
- <span class="ft-btn-spinner" aria-hidden="true"></span>
185
- {:else if iconStart}
186
- {@render iconStart()}
227
+ <!--
228
+ The lead slot: one fixed-size cell that holds the spinner and
229
+ `iconStart` at the same time, so `loading` swaps them by cross-fading
230
+ in place instead of cutting, and the label beside it never shifts.
231
+
232
+ A split `in:` / `out:` pair rather than one bidirectional
233
+ `transition:`, which is the shape the rest of this campaign uses. Two
234
+ reasons, both structural: the spinner and the icon are DIFFERENT
235
+ elements, so there is no single node whose transition could be
236
+ bidirectional; and a cross-fade wants both halves running at once
237
+ rather than one after the other. The outer `{#if loading || iconStart}`
238
+ keeps a button with neither from paying for an empty grid cell — and,
239
+ because a local transition never plays on the initial render of the
240
+ block that owns it, a button that starts out `loading` still renders
241
+ its spinner instantly, with no fade in from nothing.
242
+
243
+ `aria-hidden` sits on the slot rather than on the spinner so that it
244
+ covers the whole cell for the length of the fade: mid-swap the
245
+ outgoing icon is still mounted, and a screen reader has no business
246
+ reading a glyph that is on its way out of a control already marked
247
+ `aria-busy`.
248
+ -->
249
+ {#if loading || iconStart}
250
+ <span class="ft-btn-lead" aria-hidden={loading ? "true" : undefined}>
251
+ {#if loading}
252
+ <span
253
+ class="ft-btn-spinner"
254
+ in:fade={{ duration: leadFade() }}
255
+ out:fade={{ duration: leadFade() }}
256
+ ></span>
257
+ {:else}
258
+ <span
259
+ class="ft-btn-lead-icon"
260
+ in:fade={{ duration: leadFade() }}
261
+ out:fade={{ duration: leadFade() }}
262
+ >
263
+ {@render iconStart?.()}
264
+ </span>
265
+ {/if}
266
+ </span>
187
267
  {/if}
188
268
  {@render children?.()}
189
269
  {#if iconEnd}
@@ -198,6 +278,91 @@
198
278
  --ft-accent,
199
279
  light-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))
200
280
  );
281
+ /* 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) =
282
+ tokens.EASINGS.inout — the reversible-state pair, because a press
283
+ resolves either way (released, or the interaction carries on). */
284
+ --ft-btn-motion: var(--ft-duration-fast, 150ms)
285
+ var(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));
286
+ /*
287
+ * Replaces the `transition-colors` utility removed from the class string
288
+ * above, at exactly the values that utility already resolved to. Colour is
289
+ * a state change, not motion, so it stays OUTSIDE the reduced-motion query
290
+ * — gating it would only make a theme flip look broken for the people who
291
+ * asked for less movement.
292
+ *
293
+ * `box-shadow` is deliberately absent, and must stay absent: this button's
294
+ * focus ring is `focus-visible:ring-*`, which compiles to a `box-shadow`,
295
+ * and a focus ring must never animate. `text-decoration-color`, `fill` and
296
+ * `stroke` never change on this control, so the three listed here are the
297
+ * faithful subset of what the utility covered.
298
+ */
299
+ transition:
300
+ color var(--ft-btn-motion),
301
+ background-color var(--ft-btn-motion),
302
+ border-color var(--ft-btn-motion);
303
+ /* Kills the ~300ms tap delay without blocking scroll — the same rule, for
304
+ the same reason, as `.ft-pressable` and `.ft-toggle`. A press that
305
+ answers a third of a second late is not press feedback. */
306
+ touch-action: manipulation;
307
+ }
308
+
309
+ /*
310
+ * The press. Pressable's contract inlined as a plain rule on the native
311
+ * control rather than wrapping a `<button>` in a `<Pressable>` div — same
312
+ * `0.97`, same clock, one element instead of two.
313
+ *
314
+ * Only the property list is re-declared in here: `transform` joins the colour
315
+ * channel under `no-preference` and nowhere else, so with motion reduced the
316
+ * colours still cross and the button simply does not move. The resting state
317
+ * (no `transform` at all) is the ungated fallback.
318
+ */
319
+ @media (prefers-reduced-motion: no-preference) {
320
+ .ft-btn {
321
+ transition:
322
+ color var(--ft-btn-motion),
323
+ background-color var(--ft-btn-motion),
324
+ border-color var(--ft-btn-motion),
325
+ transform var(--ft-btn-motion);
326
+ }
327
+
328
+ /* Both inert states are excluded so a press that does nothing does not
329
+ pretend to. `[data-disabled]` is the anchor branch, which has no native
330
+ `:disabled` to hang this off; `[aria-busy]` is the loading one, on both
331
+ branches. A native disabled `<button>` never matches `:active` anyway —
332
+ the attribute selectors are what make the anchor behave like it. */
333
+ .ft-btn:not([data-disabled="true"]):not([aria-busy="true"]):active {
334
+ transform: scale(0.97);
335
+ }
336
+ }
337
+
338
+ /*
339
+ * The lead slot: one fixed cell the spinner and `iconStart` share, sized
340
+ * exactly like the spinner it holds (`calc(1em + 1px)`, read from the
341
+ * button's own font-size, so it follows the size variant for free). Fixed
342
+ * rather than content-sized on purpose — a slot that measured whichever
343
+ * child happened to be mounted would grow while both are cross-fading and
344
+ * snap back the frame the fade ended, moving the label twice for one swap.
345
+ */
346
+ .ft-btn-lead {
347
+ display: grid;
348
+ place-items: center;
349
+ flex: none;
350
+ width: calc(1em + 1px);
351
+ height: calc(1em + 1px);
352
+ }
353
+
354
+ /* Both children occupy the one cell at once — that overlap IS the
355
+ cross-fade. Named rather than `> *` so a caller's own `iconStart` markup,
356
+ which is one level further down, is never caught by it. */
357
+ .ft-btn-lead > .ft-btn-spinner,
358
+ .ft-btn-lead > .ft-btn-lead-icon {
359
+ grid-area: 1 / 1;
360
+ }
361
+
362
+ .ft-btn-lead-icon {
363
+ display: inline-flex;
364
+ align-items: center;
365
+ justify-content: center;
201
366
  }
202
367
 
203
368
  .ft-btn--accent {
@@ -1,7 +1,8 @@
1
- import { render, cleanup, fireEvent } from "@testing-library/svelte";
2
- import { createRawSnippet } from "svelte";
1
+ import { render, cleanup, fireEvent, waitFor } from "@testing-library/svelte";
2
+ import { createRawSnippet, tick } from "svelte";
3
3
  import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
4
4
  import Button from "./Button.svelte";
5
+ import LeadHarness from "./ButtonLeadHarness.test.svelte";
5
6
  import { sound } from "../sound/sound.svelte.js";
6
7
  function snippet(html) {
7
8
  return createRawSnippet(() => ({ render: () => html }));
@@ -9,8 +10,27 @@ function snippet(html) {
9
10
  function root(container) {
10
11
  return container.firstElementChild;
11
12
  }
13
+ /** Replaces `window.matchMedia` wholesale — the pattern the rest of the repo
14
+ * uses. `prefersReducedMotion()` resolves it fresh on every call, and the
15
+ * lead fade reads it from its transition params thunk, so an override
16
+ * installed before a render is visible to the very next swap. */
17
+ function stubReducedMotion(matches) {
18
+ vi.stubGlobal("matchMedia", (query) => ({
19
+ matches,
20
+ media: query,
21
+ onchange: null,
22
+ addEventListener: () => { },
23
+ removeEventListener: () => { },
24
+ dispatchEvent: () => false,
25
+ addListener: () => { },
26
+ removeListener: () => { },
27
+ }));
28
+ }
12
29
  describe("Button", () => {
13
- afterEach(cleanup);
30
+ afterEach(() => {
31
+ cleanup();
32
+ vi.unstubAllGlobals();
33
+ });
14
34
  it("renders a real button with children as its label", () => {
15
35
  const { container } = render(Button, { props: { children: snippet("<span>Save</span>") } });
16
36
  const el = root(container);
@@ -229,6 +249,100 @@ describe("Button", () => {
229
249
  // alone; only the base `bg-primary` is a real conflict with `bg-red-500`.
230
250
  expect(classList).not.toContain("bg-primary");
231
251
  });
252
+ // The scoped `<style>` now declares a `transition` shorthand on this same
253
+ // element, so the press scale can join the colour channel under
254
+ // `prefers-reduced-motion: no-preference`. Svelte's scoped CSS is unlayered
255
+ // and Tailwind's utilities sit in `@layer utilities`, so leaving
256
+ // `transition-colors` on the class string would read as a colour transition
257
+ // that silently never ran.
258
+ it("drops the transition-colors utility in favour of the hand-written channel", () => {
259
+ const { container } = render(Button, { props: { children: snippet("<span>Go</span>") } });
260
+ expect(root(container).className).not.toContain("transition-colors");
261
+ });
262
+ describe("lead slot", () => {
263
+ const leadProps = {
264
+ iconStart: snippet('<span class="my-icon-start">+</span>'),
265
+ children: snippet("<span>Save</span>"),
266
+ };
267
+ function toggleOf(container) {
268
+ return container.querySelector('[data-testid="toggle"]');
269
+ }
270
+ it("wraps iconStart in the shared lead cell, exposed to assistive tech", () => {
271
+ const { container } = render(Button, { props: leadProps });
272
+ const lead = container.querySelector(".ft-btn-lead");
273
+ expect(lead).toBeTruthy();
274
+ expect(lead?.querySelector(".my-icon-start")).toBeTruthy();
275
+ // Not loading: nothing here is decorative, so the caller's own icon
276
+ // markup keeps whatever accessible treatment the caller gave it.
277
+ expect(lead?.hasAttribute("aria-hidden")).toBe(false);
278
+ });
279
+ it("renders no lead cell at all when there is neither a spinner nor an iconStart", () => {
280
+ const { container } = render(Button, {
281
+ props: { children: snippet("<span>Save</span>") },
282
+ });
283
+ expect(container.querySelector(".ft-btn-lead")).toBeNull();
284
+ });
285
+ it("keeps aria-busy on the control and aria-hidden on the cell, never the other way round", () => {
286
+ const { container } = render(Button, { props: { ...leadProps, loading: true } });
287
+ const el = root(container);
288
+ const lead = container.querySelector(".ft-btn-lead");
289
+ expect(el.getAttribute("aria-busy")).toBe("true");
290
+ expect(el.hasAttribute("aria-hidden")).toBe(false);
291
+ expect(lead.getAttribute("aria-hidden")).toBe("true");
292
+ expect(lead.hasAttribute("aria-busy")).toBe(false);
293
+ });
294
+ it("renders the same lead cell on the anchor branch", () => {
295
+ const { container } = render(Button, {
296
+ props: { ...leadProps, href: "https://example.com" },
297
+ });
298
+ const lead = container.querySelector(".ft-btn-lead");
299
+ expect(root(container).tagName).toBe("A");
300
+ expect(lead?.querySelector(".my-icon-start")).toBeTruthy();
301
+ });
302
+ // The proof the cross-fade exists at all. Every static-render assertion
303
+ // above and at `:101` stays green whether or not the fade was ever wired
304
+ // up, because a local transition never plays on the initial render of the
305
+ // block that owns it — only a live toggle can tell the two apart.
306
+ it("holds the icon and the spinner in the DOM together for the length of the cross-fade", async () => {
307
+ const { container } = render(LeadHarness);
308
+ const toggle = toggleOf(container);
309
+ expect(container.querySelector(".my-icon-start")).toBeTruthy();
310
+ expect(container.querySelector(".ft-btn-spinner")).toBeNull();
311
+ toggle.click();
312
+ await tick();
313
+ // Both mounted, sharing the one grid cell: that overlap IS the fade.
314
+ // Without it the icon would already be gone in this same flush.
315
+ expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
316
+ expect(container.querySelector(".my-icon-start")).toBeTruthy();
317
+ // And it settles on the spinner alone once the fade finishes.
318
+ await waitFor(() => expect(container.querySelector(".my-icon-start")).toBeNull());
319
+ expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
320
+ });
321
+ it("cross-fades back the other way when loading clears", async () => {
322
+ const { container } = render(LeadHarness);
323
+ const toggle = toggleOf(container);
324
+ toggle.click();
325
+ await waitFor(() => expect(container.querySelector(".my-icon-start")).toBeNull());
326
+ toggle.click();
327
+ await tick();
328
+ expect(container.querySelector(".my-icon-start")).toBeTruthy();
329
+ expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
330
+ await waitFor(() => expect(container.querySelector(".ft-btn-spinner")).toBeNull());
331
+ expect(container.querySelector(".my-icon-start")).toBeTruthy();
332
+ });
333
+ it("reduced motion: the swap is synchronous, with no window where both are mounted", async () => {
334
+ stubReducedMotion(true);
335
+ const { container } = render(LeadHarness);
336
+ const toggle = toggleOf(container);
337
+ toggle.click();
338
+ await tick();
339
+ // Duration 0 makes Svelte skip `element.animate()` entirely, so the
340
+ // outgoing icon is gone in the same flush that mounts the spinner —
341
+ // exactly the behaviour this button had before the fade existed.
342
+ expect(container.querySelector(".ft-btn-spinner")).toBeTruthy();
343
+ expect(container.querySelector(".my-icon-start")).toBeNull();
344
+ });
345
+ });
232
346
  describe("sound", () => {
233
347
  let play;
234
348
  beforeEach(() => {
@@ -0,0 +1,32 @@
1
+ <!--
2
+ Test-only rig for the lead slot's spinner ↔ `iconStart` cross-fade.
3
+
4
+ `loading` is held as local `$state` and flipped from a button inside this
5
+ component, deliberately, rather than driven from the test through
6
+ `rerender()`: `rerender` awaits a tick of its own, so a test using it can
7
+ never observe the ONE flush the cross-fade lives in — by the time it returns,
8
+ the stubbed Web Animations chain (a dummy animation on one microtask, the
9
+ real one on the next) has already resolved and the outgoing node has left
10
+ either way. A raw `.click()` flips the state synchronously, so a single
11
+ `await tick()` after it lands inside the fade window.
12
+
13
+ Starts NOT loading on purpose: a local transition never plays on the initial
14
+ render of the block that owns it, so the first click is the first genuine
15
+ swap, and the second click the genuine swap back.
16
+
17
+ Not exported from index.ts, and not collected by Vitest directly (the run
18
+ includes `*.test.ts` only) — imported and rendered from Button.test.ts.
19
+ -->
20
+ <script lang="ts">
21
+ import Button from "./Button.svelte";
22
+
23
+ let isLoading = $state(false);
24
+ </script>
25
+
26
+ <button type="button" data-testid="toggle" onclick={() => (isLoading = !isLoading)}>toggle</button>
27
+
28
+ {#snippet plus()}
29
+ <span class="my-icon-start">+</span>
30
+ {/snippet}
31
+
32
+ <Button loading={isLoading} iconStart={plus}>Save</Button>
@@ -0,0 +1,3 @@
1
+ declare const ButtonLeadHarness: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ButtonLeadHarness = ReturnType<typeof ButtonLeadHarness>;
3
+ export default ButtonLeadHarness;
@@ -20,7 +20,7 @@ The library's foundational push-button: six variants, three sizes, a loading sta
20
20
  ```
21
21
 
22
22
  ```svelte
23
- <!-- Loading: swaps the leading icon for a spinner, keeps the button's own colours -->
23
+ <!-- Loading: cross-fades the leading icon into a spinner, keeps the button's own colours -->
24
24
  <Button loading={saving} onclick={save}>Save changes</Button>
25
25
  ```
26
26
 
@@ -93,10 +93,21 @@ Set `sound` to play the `press` cue on activation, through the shared sound cont
93
93
 
94
94
  It is opt-in and silent by default: nothing plays unless both `sound` is set on the button **and** the user has turned sound on globally (through `SoundToggle` or `sound.enable()`). `disabled` and `loading` block the cue exactly like they block `onclick` — nothing plays for a click that never fires the callback either.
95
95
 
96
+ ## Motion
97
+
98
+ - **The lead slot cross-fades.** `iconStart` and the spinner share one fixed-size cell — `calc(1em + 1px)`, the same box the spinner already drew itself in, so it follows the size variant for free. Flipping `loading` fades one out while the other fades in, over the same 80 ms, in place. Both are mounted at once for the length of that fade; the cell never resizes, so the label beside it never shifts. A button that starts out `loading` renders its spinner immediately, with no fade in from nothing, and a button given neither a spinner nor an `iconStart` renders no cell at all.
99
+ - **Pressing scales the button to `0.97`** for as long as it is held, over 150 ms on `--ft-ease-inout` — the reversible-state curve, because a press resolves either way. A disabled button and a `loading` one are both excluded: a press that does nothing should not pretend to.
100
+ - The press transition lists `transform` and the three colour properties, and deliberately never `box-shadow`. This button's focus ring _is_ a `box-shadow` (`focus-visible:ring-*` compiles to one), and a focus ring must never animate.
101
+ - **Reduced motion.** The press scale is declared entirely inside `@media (prefers-reduced-motion: no-preference)`; without that preference the button simply does not move, and the resting state is the ungated fallback. The lead cross-fade collapses to a duration of `0`, which makes the swap synchronous — exactly the instant cut this button did before the fade existed. The spinner's own rotation is likewise gated, and with motion reduced the ring is still drawn, just still, so `aria-busy` keeps a visible companion. Colour is the one channel that stays ungated in both places: a colour change is not motion, and gating it would only make a theme flip look broken for the people who asked for less movement.
102
+ - **Touch and coarse pointers.** `:active` is exactly the affordance a finger gets, so the press feedback is not suppressed on touch. The button carries `touch-action: manipulation`, which removes the browser's ~300 ms tap delay without blocking scrolling — a button inside a scrollable list stays scrollable while it press-reacts.
103
+ - **Timing.** 80 ms, linear, for the lead cross-fade (a fade needs no curve); 150 ms, `--ft-ease-inout` (`cubic-bezier(0.4, 0, 0.2, 1)`), for both the press scale and the colour channel beside it.
104
+
96
105
  ## Implementation notes
97
106
 
98
107
  - `href` switches the rendered element from `<button>` to `<a>` — the two share one class list and one `onclick` guard, so behaviour does not fork with the markup.
99
108
  - An anchor cannot be `disabled`, and half its activation paths — middle-click firing `auxclick`, "open link in new tab" from the context menu — act on `href`/`target` directly and never reach `onclick` at all. So `disabled` and `loading` are combined into one `anchorInert` flag that strips `href` and `target`, sets `aria-disabled="true"` and `tabindex="-1"`; the click handler still swallows the event too, as a second line of defence for whatever does reach it. `loading` alone must be just as inert as `disabled` — a caller can't tell those attack surfaces apart, so the fix can't either.
100
109
  - `loading` is deliberately not `disabled` — the mockup keeps a loading button at full strength (no dimming), because "working" and "unavailable" read as different things. On the button branch this falls out naturally: `loading` never touches the native `disabled` attribute, so `disabled:opacity-50` never triggers, and the shared click guard is what actually stops the callback (proven by a synthetic dispatch that bypasses jsdom's own disabled handling in the test suite). On the anchor branch, `aria-disabled` goes true for `loading` too — inertness has to be visible to assistive tech regardless of _why_ the link is inert — but the dimmed look stays tied to `disabled` alone through a separate `data-disabled` attribute, so a loading anchor reads as busy, not unavailable, exactly like a loading button does.
101
110
  - `target="_blank"` always widens `rel` to include `noopener noreferrer`, on top of whatever tokens the caller already passed, closing the `window.opener` reverse-tabnabbing hole even when a caller forgets.
102
- - The spinner reads its own `font-size` (`calc(1em + 1px)`), so its diameter follows the size variant automatically rather than needing a per-size override; its top edge is `currentColor` and the rest of the ring is `currentColor` at 30% via `color-mix`, so it always matches whichever variant it is drawn on. The spin itself lives behind `prefers-reduced-motion: no-preference`; with motion reduced the ring is still drawn, just still, so `aria-busy` keeps a visible companion.
111
+ - The spinner reads its own `font-size` (`calc(1em + 1px)`), so its diameter follows the size variant automatically rather than needing a per-size override; its top edge is `currentColor` and the rest of the ring is `currentColor` at 30% via `color-mix`, so it always matches whichever variant it is drawn on. See [Motion](#motion) for the spin's reduced-motion gate.
112
+ - `aria-hidden` sits on the lead cell rather than on the spinner inside it, so it covers the whole cell for the length of the cross-fade: mid-swap the outgoing icon is still mounted, and a screen reader has no business reading a glyph on its way out of a control already marked `aria-busy`. With `loading` false the cell carries no `aria-hidden` at all, so a caller's own `iconStart` markup keeps whatever accessible treatment the caller gave it.
113
+ - The `transition-colors` utility is deliberately absent from the class string. Svelte's scoped `<style>` is unlayered and Tailwind's utilities live in `@layer utilities`, so the scoped `transition` shorthand this button needs (the press scale has to join the colour channel) would win over that utility silently and leave a colour transition that never ran. The colour channel is re-declared by hand instead, at exactly the values the utility resolved to.
@@ -11,6 +11,7 @@
11
11
  HSVtoRGB,
12
12
  scaleRadiusForContainer,
13
13
  wrap,
14
+ clickBoost,
14
15
  } from "./fluid-shared.js";
15
16
  import { startWebGpuFluid } from "./webgpu-engine.js";
16
17
 
@@ -106,6 +107,9 @@
106
107
  }: Props = $props();
107
108
 
108
109
  const clampedColorIntensity = Math.max(0, Math.min(1, colorIntensity));
110
+ // A click's extra brightness, capped so it never compounds colorIntensity
111
+ // into the dither pass's clamp — see clickBoost() for the arithmetic.
112
+ const clickColorBoost = clickBoost(clampedColorIntensity);
109
113
  const clampedHdrBoost = Math.max(1, Math.min(4, hdrBoost));
110
114
  const clampedDitherPixelSize = Math.max(1, ditherPixelSize);
111
115
  const clampedDitherLevels = Math.max(2, Math.min(16, Math.floor(ditherLevels)));
@@ -122,13 +126,17 @@
122
126
  let cachedFluidColorsRef: string[] | undefined;
123
127
  let cachedFluidColorsScaled: ColorRGB[] = [];
124
128
 
129
+ // Frozen: these objects are cached and handed out by reference to every
130
+ // pointer and every click, so an in-place `color.r *= …` anywhere downstream
131
+ // would scale the palette itself — and in strict mode the freeze turns that
132
+ // into a TypeError at the culprit instead of a fluid that brightens forever.
125
133
  function getScaledColor(hex: string): ColorRGB {
126
134
  const { r, g, b } = hexToRgb(hex);
127
- return {
135
+ return Object.freeze({
128
136
  r: r * clampedColorIntensity,
129
137
  g: g * clampedColorIntensity,
130
138
  b: b * clampedColorIntensity,
131
- };
139
+ });
132
140
  }
133
141
 
134
142
  function getCachedFluidColor(hex: string): ColorRGB {
@@ -265,6 +273,7 @@
265
273
  pauseWhenHidden,
266
274
  splatOnMount,
267
275
  generateColor,
276
+ clickBoost: clickColorBoost,
268
277
  })
269
278
  .then((handle) => {
270
279
  if (disposed) {
@@ -315,6 +324,15 @@
315
324
  const autopilotPointer = onReady ? pointerPrototype() : null;
316
325
  if (autopilotPointer) pointers.push(autopilotPointer);
317
326
  let penWasUp = true;
327
+ // Every GL object the engine allocates registers its own deleter here so
328
+ // `cleanup()` can hand the memory back — and then lose the context
329
+ // itself. Nothing here ever released a program, a texture or the
330
+ // context before, so every unmount (a re-key on a skin switch, a route
331
+ // change) left a whole simulation's worth of GPU memory owned by a
332
+ // context only GC would eventually drop, and each new mount took one
333
+ // more slot from the browser's ~16-context budget.
334
+ const glDisposers: Array<() => void> = [];
335
+ let glDisposed = false;
318
336
 
319
337
  // Get WebGL context
320
338
  const context = getWebGLContext(canvas);
@@ -514,6 +532,7 @@
514
532
  const shaderSource = addKeywords(source, keywords);
515
533
  const shader = gl.createShader(type);
516
534
  if (!shader) return null;
535
+ glDisposers.push(() => gl.deleteShader(shader));
517
536
  gl.shaderSource(shader, shaderSource);
518
537
  gl.compileShader(shader);
519
538
  return shader;
@@ -526,6 +545,7 @@
526
545
  if (!vertexShader || !fragmentShader) return null;
527
546
  const program = gl.createProgram();
528
547
  if (!program) return null;
548
+ glDisposers.push(() => gl.deleteProgram(program));
529
549
  gl.attachShader(program, vertexShader);
530
550
  gl.attachShader(program, fragmentShader);
531
551
  gl.linkProgram(program);
@@ -741,13 +761,19 @@
741
761
  uniform vec3 color;
742
762
  uniform vec2 point;
743
763
  uniform float radius;
764
+ uniform float saturate;
744
765
 
745
766
  void main () {
746
767
  vec2 p = vUv - point.xy;
747
768
  p.x *= aspectRatio;
748
769
  vec3 splat = exp(-dot(p, p) / radius) * color;
749
770
  vec3 base = texture2D(uTarget, vUv).xyz;
750
- gl_FragColor = vec4(base + splat, 1.0);
771
+ // A click splat lifts each texel to its own profile instead of adding
772
+ // to it: what is already there never rises past what one click would
773
+ // leave at that spot, so a burst of clicks paints the same disc once
774
+ // instead of summing towards white and widening with every click.
775
+ vec3 sum = saturate > 0.5 ? max(base, splat) : base + splat;
776
+ gl_FragColor = vec4(sum, 1.0);
751
777
  }
752
778
  `
753
779
  );
@@ -955,6 +981,7 @@
955
981
 
956
982
  const blit = (() => {
957
983
  const buffer = gl.createBuffer()!;
984
+ glDisposers.push(() => gl.deleteBuffer(buffer));
958
985
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
959
986
  gl.bufferData(
960
987
  gl.ARRAY_BUFFER,
@@ -962,6 +989,7 @@
962
989
  gl.STATIC_DRAW
963
990
  );
964
991
  const elemBuffer = gl.createBuffer()!;
992
+ glDisposers.push(() => gl.deleteBuffer(elemBuffer));
965
993
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elemBuffer);
966
994
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
967
995
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
@@ -1014,6 +1042,7 @@
1014
1042
  ): FBO {
1015
1043
  gl.activeTexture(gl.TEXTURE0);
1016
1044
  const texture = gl.createTexture()!;
1045
+ glDisposers.push(() => gl.deleteTexture(texture));
1017
1046
  gl.bindTexture(gl.TEXTURE_2D, texture);
1018
1047
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);
1019
1048
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);
@@ -1021,6 +1050,7 @@
1021
1050
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1022
1051
  gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, w, h, 0, format, type, null);
1023
1052
  const fbo = gl.createFramebuffer()!;
1053
+ glDisposers.push(() => gl.deleteFramebuffer(fbo));
1024
1054
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1025
1055
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
1026
1056
  gl.viewport(0, 0, w, h);
@@ -1481,16 +1511,41 @@
1481
1511
  }
1482
1512
 
1483
1513
  function clickSplat(pointer: Pointer) {
1484
- const color = generateColor();
1485
- color.r *= 10;
1486
- color.g *= 10;
1487
- color.b *= 10;
1514
+ // generateColor() is already intensity-scaled; the boost is capped so
1515
+ // the peak stays at CLICK_PEAK rather than compounding colorIntensity.
1516
+ // COPY, never scale in place: with `fluidColor` / `fluidColors` set,
1517
+ // generateColor() hands back the cached palette object itself, and
1518
+ // scaling that multiplied the palette again on every click — the
1519
+ // 10th click painted with a colour 10^10 times brighter — which is
1520
+ // what turned a few clicks into a giant disc and a few more into
1521
+ // NaN and nothing at all.
1522
+ const base = generateColor();
1523
+ const color = {
1524
+ r: base.r * clickColorBoost,
1525
+ g: base.g * clickColorBoost,
1526
+ b: base.b * clickColorBoost,
1527
+ };
1488
1528
  const dx = 10 * (Math.random() - 0.5);
1489
1529
  const dy = 30 * (Math.random() - 0.5);
1490
- splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
1491
- }
1492
-
1493
- function splat(x: number, y: number, dx: number, dy: number, color: ColorRGB) {
1530
+ // Saturating: a burst of clicks on one spot pools at this colour
1531
+ // rather than summing — a click has next to no velocity to carry
1532
+ // the dye away, so additive stacking used to turn into a white disc.
1533
+ splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
1534
+ }
1535
+
1536
+ /**
1537
+ * Inject velocity and dye at a point. `saturate` is the click mode: the
1538
+ * dye pass then caps each texel at the splat colour instead of adding
1539
+ * to it (velocity is always additive — a click's push is negligible).
1540
+ */
1541
+ function splat(
1542
+ x: number,
1543
+ y: number,
1544
+ dx: number,
1545
+ dy: number,
1546
+ color: ColorRGB,
1547
+ saturate = false
1548
+ ) {
1494
1549
  splatProgram.bind();
1495
1550
  if (splatProgram.uniforms.uTarget) {
1496
1551
  gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
@@ -1504,6 +1559,9 @@
1504
1559
  if (splatProgram.uniforms.color) {
1505
1560
  gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0);
1506
1561
  }
1562
+ if (splatProgram.uniforms.saturate) {
1563
+ gl.uniform1f(splatProgram.uniforms.saturate, 0);
1564
+ }
1507
1565
  if (splatProgram.uniforms.radius) {
1508
1566
  let radius = correctRadius(config.SPLAT_RADIUS / 100)!;
1509
1567
  if (contained) {
@@ -1520,6 +1578,9 @@
1520
1578
  if (splatProgram.uniforms.color) {
1521
1579
  gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);
1522
1580
  }
1581
+ if (splatProgram.uniforms.saturate) {
1582
+ gl.uniform1f(splatProgram.uniforms.saturate, saturate ? 1 : 0);
1583
+ }
1523
1584
  blit(dye.write);
1524
1585
  dye.swap();
1525
1586
  }
@@ -1652,6 +1713,13 @@
1652
1713
  clickSplat(pointer);
1653
1714
  }
1654
1715
 
1716
+ // The release half of mousedown / touchstart. `down` used to latch true
1717
+ // for the life of the engine — nothing on this path reads it, but the
1718
+ // pointer is part of the handle's contract and should tell the truth.
1719
+ function handlePointerUp() {
1720
+ pointers[0].down = false;
1721
+ }
1722
+
1655
1723
  function handleFirstMouseMove(e: MouseEvent) {
1656
1724
  const pointer = pointers[0];
1657
1725
  const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
@@ -1697,11 +1765,14 @@
1697
1765
  // Add event listeners
1698
1766
  if (interactive) {
1699
1767
  window.addEventListener("mousedown", handleMouseDown);
1768
+ window.addEventListener("mouseup", handlePointerUp);
1700
1769
  document.body.addEventListener("mousemove", handleFirstMouseMove);
1701
1770
  window.addEventListener("mousemove", handleMouseMove);
1702
1771
  document.body.addEventListener("touchstart", handleFirstTouchStart);
1703
1772
  window.addEventListener("touchstart", handleTouchStart, false);
1704
1773
  window.addEventListener("touchmove", handleTouchMove, false);
1774
+ window.addEventListener("touchend", handlePointerUp);
1775
+ window.addEventListener("touchcancel", handlePointerUp);
1705
1776
  }
1706
1777
 
1707
1778
  // Pause animation when scrolled out of view
@@ -1747,16 +1818,31 @@
1747
1818
  if (autoSplatTimer) clearInterval(autoSplatTimer);
1748
1819
  if (interactive) {
1749
1820
  window.removeEventListener("mousedown", handleMouseDown);
1821
+ window.removeEventListener("mouseup", handlePointerUp);
1750
1822
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
1751
1823
  window.removeEventListener("mousemove", handleMouseMove);
1752
1824
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
1753
1825
  window.removeEventListener("touchstart", handleTouchStart);
1754
1826
  window.removeEventListener("touchmove", handleTouchMove);
1827
+ window.removeEventListener("touchend", handlePointerUp);
1828
+ window.removeEventListener("touchcancel", handlePointerUp);
1755
1829
  }
1756
1830
  if (contained) {
1757
1831
  window.removeEventListener("resize", updateCanvasRectCache);
1758
1832
  window.removeEventListener("scroll", updateCanvasRectCache);
1759
1833
  }
1834
+ // Hand the GPU memory back, then the context itself. Unlike the React
1835
+ // port, a Svelte unmount discards the <canvas> — nothing will call
1836
+ // getContext on it again — so a lost context costs nothing, while a
1837
+ // live one would hold a slot in the browser's context budget until
1838
+ // GC. Guarded: the singleton runs the previous instance's cleanup
1839
+ // when a newer one mounts, and the component runs it again on its
1840
+ // own unmount.
1841
+ if (glDisposed) return;
1842
+ glDisposed = true;
1843
+ for (const dispose of glDisposers) dispose();
1844
+ glDisposers.length = 0;
1845
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
1760
1846
  }
1761
1847
 
1762
1848
  // Register before notifying: a throwing callback must not cost us the
@@ -134,4 +134,6 @@ Notes:
134
134
  - Like the other simulation props, dither props are applied at mount; re-key the
135
135
  component to change them at runtime.
136
136
  - Raise `colorIntensity` (≈ `0.4`+) so the dye reliably crosses the first
137
- quantization step; at the default `0.15` dots stay sparse.
137
+ quantization step; at the default `0.15` dots stay sparse. A click's extra
138
+ brightness is capped independently of `colorIntensity`, so raising it does not
139
+ make clicks pool into a solid disc.
@@ -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
  /**
@@ -111,6 +111,28 @@ export function scaleRadiusForContainer(radius, containerHeight, viewportHeight)
111
111
  // already larger must never be shrunk by entering a small container.
112
112
  return Math.max(radius, Math.min(radius * k, 0.09));
113
113
  }
114
+ /**
115
+ * Dye a click deposits at its centre, in multiples of the hue: the engine's
116
+ * default `colorIntensity` (0.15) times the ×10 boost the solver has always
117
+ * given a click — the amount that reads as a bright pop on every renderer.
118
+ */
119
+ export const CLICK_PEAK = 1.5;
120
+ /**
121
+ * The boost a click applies to an already intensity-scaled colour, chosen so
122
+ * the peak lands on CLICK_PEAK whatever `colorIntensity` is.
123
+ *
124
+ * The historical fixed ×10 compounded `colorIntensity`. At the default 0.15
125
+ * that is a 1.5 peak; but the dither pass asks consumers to raise the
126
+ * intensity to ≈0.4+, and at 0.6 the same ×10 stacked 5.4 per click into an
127
+ * unclamped half-float dye buffer that the dither display then clamps to 1
128
+ * and quantises — every click grew a flat, saturated disc that never read as
129
+ * a splat. Capping the boost at 10 keeps intensities ≤ 0.15 byte-identical.
130
+ */
131
+ export function clickBoost(colorIntensity) {
132
+ if (!(colorIntensity > 0))
133
+ return 0;
134
+ return Math.min(10, CLICK_PEAK / colorIntensity);
135
+ }
114
136
  export function correctDeltaX(delta, width, height) {
115
137
  const aspectRatio = width / height;
116
138
  if (aspectRatio < 1)
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { scaleRadiusForContainer, correctRadius, getSimResolution } from "./fluid-shared.js";
2
+ import { scaleRadiusForContainer, correctRadius, getSimResolution, clickBoost, CLICK_PEAK, } from "./fluid-shared.js";
3
3
  describe("scaleRadiusForContainer", () => {
4
4
  it("keeps the radius unchanged at viewport size (fullscreen)", () => {
5
5
  expect(scaleRadiusForContainer(0.002, 900, 900)).toBe(0.002);
@@ -37,3 +37,19 @@ describe("getSimResolution", () => {
37
37
  expect(getSimResolution(128, 500, 1000)).toEqual({ width: 128, height: 256 });
38
38
  });
39
39
  });
40
+ describe("clickBoost", () => {
41
+ it("keeps the historical ×10 at the default intensity and below", () => {
42
+ expect(clickBoost(0.15)).toBe(10);
43
+ expect(clickBoost(0.05)).toBe(10);
44
+ });
45
+ it("lands a click on CLICK_PEAK once the intensity is raised", () => {
46
+ expect(clickBoost(0.6)).toBeCloseTo(2.5);
47
+ expect(clickBoost(1)).toBe(1.5);
48
+ expect(0.6 * clickBoost(0.6)).toBeCloseTo(CLICK_PEAK);
49
+ });
50
+ it("deposits nothing for a zero or invalid intensity", () => {
51
+ expect(clickBoost(0)).toBe(0);
52
+ expect(clickBoost(-1)).toBe(0);
53
+ expect(clickBoost(Number.NaN)).toBe(0);
54
+ });
55
+ });
@@ -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 +
@@ -63,7 +63,11 @@ fn fs_splat(in: VSOut) -> @location(0) vec4f {
63
63
  p.x = p.x * u.aspect;
64
64
  let splat = exp(-dot(p, p) / u.radius) * u.color.rgb;
65
65
  let base = textureSample(tex0, smpLinear, in.uv).rgb;
66
- return vec4f(base + splat, 1.0);
66
+ // color.a is the click flag: a click splat lifts each texel to its own
67
+ // profile instead of adding to it, so a burst of clicks paints the same
68
+ // disc once instead of summing towards white and widening every click.
69
+ let sum = select(base + splat, max(base, splat), u.color.a > 0.5);
70
+ return vec4f(sum, 1.0);
67
71
  }
68
72
 
69
73
  @fragment
@@ -376,7 +380,8 @@ async function createWebGpuFluid(canvas, opts) {
376
380
  scratch[OFF_COLOR] = v.color[0];
377
381
  scratch[OFF_COLOR + 1] = v.color[1];
378
382
  scratch[OFF_COLOR + 2] = v.color[2];
379
- scratch[OFF_COLOR + 3] = 1;
383
+ // Only the splat pass reads .a (as its click flag); it defaults off.
384
+ scratch[OFF_COLOR + 3] = v.color[3] ?? 0;
380
385
  }
381
386
  scratch[OFF_DT] = v.dt ?? 0;
382
387
  scratch[OFF_DISSIPATION] = v.dissipation ?? 0;
@@ -532,7 +537,7 @@ async function createWebGpuFluid(canvas, opts) {
532
537
  initTextures();
533
538
  }
534
539
  }
535
- function splat(x, y, dx, dy, color) {
540
+ function splat(x, y, dx, dy, color, saturate = false) {
536
541
  if (destroyed || lost || !velocity || !dye)
537
542
  return;
538
543
  let radius = correctRadius(opts.splatRadius / 100, canvas.width, canvas.height);
@@ -548,7 +553,7 @@ async function createWebGpuFluid(canvas, opts) {
548
553
  });
549
554
  writeUniforms(uSplatDye, {
550
555
  point: [x, y],
551
- color: [color.r, color.g, color.b],
556
+ color: [color.r, color.g, color.b, saturate ? 1 : 0],
552
557
  aspect,
553
558
  radius,
554
559
  });
@@ -707,13 +712,20 @@ export async function startWebGpuFluid(canvas, opts) {
707
712
  engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, pointer.color);
708
713
  }
709
714
  function clickSplat(pointer) {
710
- const color = opts.generateColor();
711
- color.r *= 10;
712
- color.g *= 10;
713
- color.b *= 10;
715
+ // COPY, never scale in place: with `fluidColor` / `fluidColors` set,
716
+ // generateColor() hands back the cached palette object itself, and scaling
717
+ // that multiplied the palette again on every click.
718
+ const base = opts.generateColor();
719
+ const color = {
720
+ r: base.r * opts.clickBoost,
721
+ g: base.g * opts.clickBoost,
722
+ b: base.b * opts.clickBoost,
723
+ };
714
724
  const dx = 10 * (Math.random() - 0.5);
715
725
  const dy = 30 * (Math.random() - 0.5);
716
- engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color);
726
+ // Saturating: a burst of clicks on one spot pools at this colour rather
727
+ // than summing towards white (a click has next to no velocity).
728
+ engine.splat(pointer.texcoordX, pointer.texcoordY, dx, dy, color, true);
717
729
  }
718
730
  function multipleSplats(steps) {
719
731
  // Simulate a cursor sweep along a random arc — each step on its own
@@ -780,6 +792,11 @@ export async function startWebGpuFluid(canvas, opts) {
780
792
  updatePointerDownData(pointer, -1, posX, posY);
781
793
  clickSplat(pointer);
782
794
  }
795
+ // The release half of mousedown / touchstart — `down` used to latch true for
796
+ // the life of the engine.
797
+ function handlePointerUp() {
798
+ pointers[0].down = false;
799
+ }
783
800
  function handleFirstMouseMove(e) {
784
801
  const pointer = pointers[0];
785
802
  const { x: posX, y: posY } = getCanvasPos(e.clientX, e.clientY);
@@ -823,11 +840,14 @@ export async function startWebGpuFluid(canvas, opts) {
823
840
  }
824
841
  if (opts.interactive) {
825
842
  window.addEventListener("mousedown", handleMouseDown);
843
+ window.addEventListener("mouseup", handlePointerUp);
826
844
  document.body.addEventListener("mousemove", handleFirstMouseMove);
827
845
  window.addEventListener("mousemove", handleMouseMove);
828
846
  document.body.addEventListener("touchstart", handleFirstTouchStart);
829
847
  window.addEventListener("touchstart", handleTouchStart, false);
830
848
  window.addEventListener("touchmove", handleTouchMove, false);
849
+ window.addEventListener("touchend", handlePointerUp);
850
+ window.addEventListener("touchcancel", handlePointerUp);
831
851
  }
832
852
  let observer = null;
833
853
  if (opts.pauseWhenHidden) {
@@ -910,11 +930,14 @@ export async function startWebGpuFluid(canvas, opts) {
910
930
  clearInterval(autoSplatTimer);
911
931
  if (opts.interactive) {
912
932
  window.removeEventListener("mousedown", handleMouseDown);
933
+ window.removeEventListener("mouseup", handlePointerUp);
913
934
  document.body.removeEventListener("mousemove", handleFirstMouseMove);
914
935
  window.removeEventListener("mousemove", handleMouseMove);
915
936
  document.body.removeEventListener("touchstart", handleFirstTouchStart);
916
937
  window.removeEventListener("touchstart", handleTouchStart);
917
938
  window.removeEventListener("touchmove", handleTouchMove);
939
+ window.removeEventListener("touchend", handlePointerUp);
940
+ window.removeEventListener("touchcancel", handlePointerUp);
918
941
  }
919
942
  if (opts.contained) {
920
943
  window.removeEventListener("resize", updateCanvasRectCache);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fancy-ui-svelte",
3
3
  "private": false,
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "description": "145 UI components for Svelte 5 — core primitives plus WebGL, GSAP and CSS motion, styled with Tailwind CSS v4.",
6
6
  "author": "Rama Herbin",
7
7
  "funding": "https://github.com/sponsors/RamaHerbin",
@@ -1,38 +0,0 @@
1
- /**
2
- * Theme registry (docs-site theming data).
3
- *
4
- * A named theme is a set of CSS-variable overrides applied on top of the base
5
- * light/dark token sets defined in `src/routes/layout.css`. The theme store
6
- * (`src/lib/stores/theme.svelte.ts`) applies a theme by setting these variables
7
- * inline on `<html>`, toggling the `.dark` class per `colorScheme`, and setting
8
- * a `data-theme` attribute.
9
- *
10
- * NOTE: this module is docs/theming data only. It is NOT imported by any shipped
11
- * component in `src/lib/fancy-ui/*`, so it does not affect the published package.
12
- */
13
- export type ColorScheme = "light" | "dark";
14
- export interface ThemeDef {
15
- /** Stable id, used as the `data-theme` value and persistence key. */
16
- name: string;
17
- /** Human label shown in the switcher. */
18
- label: string;
19
- /** Whether the theme reads as light or dark (drives the `.dark` class). */
20
- colorScheme: ColorScheme;
21
- /** CSS custom-property overrides applied on top of the base light/dark set. */
22
- tokens: Record<string, string>;
23
- /** Four representative colors for the switcher swatch. */
24
- swatch: [string, string, string, string];
25
- }
26
- /** Every CSS variable a theme may set. The store clears these before applying a theme. */
27
- export declare const MANAGED_VARS: readonly ["--background", "--foreground", "--card", "--card-foreground", "--popover", "--popover-foreground", "--primary", "--primary-foreground", "--secondary", "--secondary-foreground", "--muted", "--muted-foreground", "--accent", "--accent-foreground", "--border", "--input", "--ring", "--radius", "--rainbow-1", "--rainbow-2", "--rainbow-3", "--rainbow-4", "--rainbow-5", "--sidebar", "--sidebar-foreground", "--sidebar-primary", "--sidebar-primary-foreground", "--sidebar-accent", "--sidebar-accent-foreground", "--sidebar-border"];
28
- /**
29
- * The theme list.
30
- *
31
- * `light` and `dark` carry no token overrides — they are the base look defined
32
- * by `:root` / `.dark` in `layout.css`. The remaining themes are self-contained.
33
- * `sunset` / `ocean` / `mono` mirror the theme-generator presets.
34
- */
35
- export declare const themes: ThemeDef[];
36
- export declare function getTheme(name: string): ThemeDef | undefined;
37
- export declare const themeNames: string[];
38
- export declare const DEFAULT_THEME = "light";
@@ -1,356 +0,0 @@
1
- /**
2
- * Theme registry (docs-site theming data).
3
- *
4
- * A named theme is a set of CSS-variable overrides applied on top of the base
5
- * light/dark token sets defined in `src/routes/layout.css`. The theme store
6
- * (`src/lib/stores/theme.svelte.ts`) applies a theme by setting these variables
7
- * inline on `<html>`, toggling the `.dark` class per `colorScheme`, and setting
8
- * a `data-theme` attribute.
9
- *
10
- * NOTE: this module is docs/theming data only. It is NOT imported by any shipped
11
- * component in `src/lib/fancy-ui/*`, so it does not affect the published package.
12
- */
13
- /** Every CSS variable a theme may set. The store clears these before applying a theme. */
14
- export const MANAGED_VARS = [
15
- "--background",
16
- "--foreground",
17
- "--card",
18
- "--card-foreground",
19
- "--popover",
20
- "--popover-foreground",
21
- "--primary",
22
- "--primary-foreground",
23
- "--secondary",
24
- "--secondary-foreground",
25
- "--muted",
26
- "--muted-foreground",
27
- "--accent",
28
- "--accent-foreground",
29
- "--border",
30
- "--input",
31
- "--ring",
32
- "--radius",
33
- "--rainbow-1",
34
- "--rainbow-2",
35
- "--rainbow-3",
36
- "--rainbow-4",
37
- "--rainbow-5",
38
- "--sidebar",
39
- "--sidebar-foreground",
40
- "--sidebar-primary",
41
- "--sidebar-primary-foreground",
42
- "--sidebar-accent",
43
- "--sidebar-accent-foreground",
44
- "--sidebar-border",
45
- ];
46
- const DEFAULT_RAINBOW = [
47
- "hsl(0 100% 63%)",
48
- "hsl(270 100% 63%)",
49
- "hsl(210 100% 63%)",
50
- "hsl(195 100% 63%)",
51
- "hsl(90 100% 63%)",
52
- ];
53
- /** Expand a compact spec into a full ThemeDef with a complete token map. */
54
- function defineTheme(s) {
55
- const card = s.card ?? s.bg;
56
- const secondary = s.secondary ?? s.accent;
57
- const secondaryFg = s.secondaryFg ?? s.accentFg;
58
- const muted = s.muted ?? s.border ?? card;
59
- const mutedFg = s.mutedFg ?? s.fg;
60
- const border = s.border ?? s.muted ?? s.fg;
61
- const ring = s.ring ?? s.primary;
62
- const radius = s.radius ?? "0.625rem";
63
- const rainbow = s.rainbow ?? DEFAULT_RAINBOW;
64
- return {
65
- name: s.name,
66
- label: s.label,
67
- colorScheme: s.colorScheme,
68
- swatch: [s.primary, s.accent, secondary, s.bg],
69
- tokens: {
70
- "--background": s.bg,
71
- "--foreground": s.fg,
72
- "--card": card,
73
- "--card-foreground": s.fg,
74
- "--popover": card,
75
- "--popover-foreground": s.fg,
76
- "--primary": s.primary,
77
- "--primary-foreground": s.primaryFg,
78
- "--secondary": secondary,
79
- "--secondary-foreground": secondaryFg,
80
- "--muted": muted,
81
- "--muted-foreground": mutedFg,
82
- "--accent": s.accent,
83
- "--accent-foreground": s.accentFg,
84
- "--border": border,
85
- "--input": border,
86
- "--ring": ring,
87
- "--radius": radius,
88
- "--rainbow-1": rainbow[0],
89
- "--rainbow-2": rainbow[1],
90
- "--rainbow-3": rainbow[2],
91
- "--rainbow-4": rainbow[3],
92
- "--rainbow-5": rainbow[4],
93
- "--sidebar": card,
94
- "--sidebar-foreground": s.fg,
95
- "--sidebar-primary": s.primary,
96
- "--sidebar-primary-foreground": s.primaryFg,
97
- "--sidebar-accent": muted,
98
- "--sidebar-accent-foreground": s.fg,
99
- "--sidebar-border": border,
100
- },
101
- };
102
- }
103
- /**
104
- * The theme list.
105
- *
106
- * `light` and `dark` carry no token overrides — they are the base look defined
107
- * by `:root` / `.dark` in `layout.css`. The remaining themes are self-contained.
108
- * `sunset` / `ocean` / `mono` mirror the theme-generator presets.
109
- */
110
- export const themes = [
111
- {
112
- name: "light",
113
- label: "Light",
114
- colorScheme: "light",
115
- tokens: {},
116
- swatch: [
117
- "oklch(0.208 0.042 265.755)",
118
- "oklch(0.65 0.15 265)",
119
- "oklch(0.929 0.013 255.508)",
120
- "#ffffff",
121
- ],
122
- },
123
- {
124
- name: "dark",
125
- label: "Dark",
126
- colorScheme: "dark",
127
- tokens: {},
128
- swatch: [
129
- "oklch(0.929 0.013 255.508)",
130
- "oklch(0.7 0.15 265)",
131
- "oklch(0.15 0.005 260)",
132
- "#0a0a0a",
133
- ],
134
- },
135
- defineTheme({
136
- name: "cupcake",
137
- label: "Cupcake",
138
- colorScheme: "light",
139
- bg: "#faf7f5",
140
- fg: "#291334",
141
- card: "#ffffff",
142
- primary: "#65c3c8",
143
- primaryFg: "#004b50",
144
- secondary: "#ef9fbc",
145
- secondaryFg: "#5c2437",
146
- accent: "#eeaf3a",
147
- accentFg: "#4a3000",
148
- border: "#e7e2df",
149
- radius: "1rem",
150
- }),
151
- defineTheme({
152
- name: "emerald",
153
- label: "Emerald",
154
- colorScheme: "light",
155
- bg: "#ffffff",
156
- fg: "#333c4d",
157
- primary: "#66cc8a",
158
- primaryFg: "#0c3d24",
159
- secondary: "#377cfb",
160
- secondaryFg: "#ffffff",
161
- accent: "#ea5234",
162
- accentFg: "#ffffff",
163
- border: "#e5e7eb",
164
- radius: "0.5rem",
165
- }),
166
- defineTheme({
167
- name: "corporate",
168
- label: "Corporate",
169
- colorScheme: "light",
170
- bg: "#ffffff",
171
- fg: "#181a2a",
172
- primary: "#4b6bfb",
173
- primaryFg: "#ffffff",
174
- secondary: "#7b92b2",
175
- secondaryFg: "#ffffff",
176
- accent: "#67cba0",
177
- accentFg: "#00382a",
178
- border: "#e5e7eb",
179
- radius: "0.25rem",
180
- }),
181
- defineTheme({
182
- name: "retro",
183
- label: "Retro",
184
- colorScheme: "light",
185
- bg: "#ece3ca",
186
- fg: "#282425",
187
- card: "#e4d8b4",
188
- primary: "#ef9995",
189
- primaryFg: "#282425",
190
- secondary: "#a4cbb4",
191
- secondaryFg: "#282425",
192
- accent: "#dc8850",
193
- accentFg: "#282425",
194
- border: "#d1c7a3",
195
- radius: "0.5rem",
196
- }),
197
- defineTheme({
198
- name: "cyberpunk",
199
- label: "Cyberpunk",
200
- colorScheme: "light",
201
- bg: "#fff248",
202
- fg: "#0f0f00",
203
- card: "#fdf700",
204
- primary: "#ff7598",
205
- primaryFg: "#330014",
206
- secondary: "#75d1f0",
207
- secondaryFg: "#00222e",
208
- accent: "#c07eec",
209
- accentFg: "#1e0033",
210
- border: "#e6dc00",
211
- radius: "0rem",
212
- rainbow: [
213
- "hsl(330 100% 63%)",
214
- "hsl(280 100% 63%)",
215
- "hsl(190 100% 60%)",
216
- "hsl(55 100% 55%)",
217
- "hsl(150 100% 55%)",
218
- ],
219
- }),
220
- defineTheme({
221
- name: "synthwave",
222
- label: "Synthwave",
223
- colorScheme: "dark",
224
- bg: "#1a103d",
225
- fg: "#f9f7fd",
226
- card: "#241650",
227
- primary: "#e779c1",
228
- primaryFg: "#2a0e2b",
229
- secondary: "#58c7f3",
230
- secondaryFg: "#003044",
231
- accent: "#f3cc30",
232
- accentFg: "#3a2e00",
233
- muted: "#2b1c5e",
234
- mutedFg: "#b9a9e6",
235
- border: "#3a2a6b",
236
- radius: "0.75rem",
237
- rainbow: [
238
- "hsl(320 90% 65%)",
239
- "hsl(280 90% 68%)",
240
- "hsl(195 95% 60%)",
241
- "hsl(48 95% 58%)",
242
- "hsl(160 80% 55%)",
243
- ],
244
- }),
245
- defineTheme({
246
- name: "dracula",
247
- label: "Dracula",
248
- colorScheme: "dark",
249
- bg: "#282a36",
250
- fg: "#f8f8f2",
251
- card: "#343746",
252
- primary: "#ff79c6",
253
- primaryFg: "#28202b",
254
- secondary: "#bd93f9",
255
- secondaryFg: "#241a33",
256
- accent: "#ffb86c",
257
- accentFg: "#3a2600",
258
- muted: "#3a3d4d",
259
- mutedFg: "#c7c9d6",
260
- border: "#44475a",
261
- radius: "0.5rem",
262
- }),
263
- defineTheme({
264
- name: "forest",
265
- label: "Forest",
266
- colorScheme: "dark",
267
- bg: "#171212",
268
- fg: "#e0ded9",
269
- card: "#1f1a1a",
270
- primary: "#1eb854",
271
- primaryFg: "#04220f",
272
- secondary: "#1fd65f",
273
- secondaryFg: "#04220f",
274
- accent: "#d99330",
275
- accentFg: "#2b1c00",
276
- muted: "#26201f",
277
- mutedFg: "#b3b0aa",
278
- border: "#2e2726",
279
- radius: "0.75rem",
280
- }),
281
- defineTheme({
282
- name: "sunset",
283
- label: "Sunset",
284
- colorScheme: "dark",
285
- bg: "#1c1410",
286
- fg: "#f5e9e0",
287
- card: "#261a13",
288
- primary: "oklch(0.62 0.19 30)",
289
- primaryFg: "#22100a",
290
- secondary: "oklch(0.7 0.17 8)",
291
- secondaryFg: "#2a0d0a",
292
- accent: "oklch(0.7 0.17 8)",
293
- accentFg: "#2a0d0a",
294
- muted: "#33241b",
295
- mutedFg: "#d8c3b4",
296
- border: "#3a2a20",
297
- radius: "1rem",
298
- rainbow: [
299
- "hsl(12 100% 63%)",
300
- "hsl(30 100% 63%)",
301
- "hsl(350 100% 63%)",
302
- "hsl(320 100% 63%)",
303
- "hsl(50 100% 63%)",
304
- ],
305
- }),
306
- defineTheme({
307
- name: "ocean",
308
- label: "Ocean",
309
- colorScheme: "light",
310
- bg: "#f4fbff",
311
- fg: "#0b2a3a",
312
- card: "#ffffff",
313
- primary: "oklch(0.55 0.13 230)",
314
- primaryFg: "#ffffff",
315
- secondary: "oklch(0.72 0.13 190)",
316
- secondaryFg: "#00303a",
317
- accent: "oklch(0.72 0.13 190)",
318
- accentFg: "#00303a",
319
- muted: "#e3f1f9",
320
- mutedFg: "#3a5c6b",
321
- border: "#d3e7f0",
322
- radius: "0.5rem",
323
- rainbow: [
324
- "hsl(200 100% 63%)",
325
- "hsl(220 100% 63%)",
326
- "hsl(180 100% 63%)",
327
- "hsl(240 100% 63%)",
328
- "hsl(160 100% 63%)",
329
- ],
330
- }),
331
- defineTheme({
332
- name: "mono",
333
- label: "Mono",
334
- colorScheme: "dark",
335
- bg: "#0a0a0a",
336
- fg: "#fafafa",
337
- card: "#161616",
338
- primary: "oklch(0.32 0 0)",
339
- primaryFg: "#fafafa",
340
- secondary: "oklch(0.6 0 0)",
341
- secondaryFg: "#0a0a0a",
342
- accent: "oklch(0.6 0 0)",
343
- accentFg: "#0a0a0a",
344
- muted: "#1e1e1e",
345
- mutedFg: "#a3a3a3",
346
- border: "#2a2a2a",
347
- radius: "0.25rem",
348
- rainbow: ["hsl(0 0% 80%)", "hsl(0 0% 65%)", "hsl(0 0% 90%)", "hsl(0 0% 55%)", "hsl(0 0% 75%)"],
349
- }),
350
- ];
351
- const themeByName = new Map(themes.map((t) => [t.name, t]));
352
- export function getTheme(name) {
353
- return themeByName.get(name);
354
- }
355
- export const themeNames = themes.map((t) => t.name);
356
- export const DEFAULT_THEME = "light";