fancy-ui-svelte 0.12.0 → 0.14.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 (75) hide show
  1. package/dist/fancy-ui/button/Button.svelte +196 -9
  2. package/dist/fancy-ui/button/Button.test.js +117 -3
  3. package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte +32 -0
  4. package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte.d.ts +3 -0
  5. package/dist/fancy-ui/button/README.md +13 -2
  6. package/dist/fancy-ui/compare/Compare.svelte +404 -47
  7. package/dist/fancy-ui/compare/Compare.svelte.d.ts +26 -0
  8. package/dist/fancy-ui/compare/Compare.test.js +82 -2
  9. package/dist/fancy-ui/compare/README.md +9 -2
  10. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.d.ts +1 -0
  11. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.ssr.test.js +24 -0
  12. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte +479 -0
  13. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.svelte.d.ts +69 -0
  14. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.d.ts +1 -0
  15. package/dist/fancy-ui/datamosh-transition/DatamoshTransition.test.js +250 -0
  16. package/dist/fancy-ui/datamosh-transition/README.md +87 -0
  17. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.d.ts +180 -0
  18. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.js +462 -0
  19. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.d.ts +1 -0
  20. package/dist/fancy-ui/datamosh-transition/datamosh-transition-core.test.js +309 -0
  21. package/dist/fancy-ui/datamosh-transition/index.d.ts +3 -0
  22. package/dist/fancy-ui/datamosh-transition/index.js +3 -0
  23. package/dist/fancy-ui/flip-card/FlipCard.svelte +373 -32
  24. package/dist/fancy-ui/flip-card/FlipCard.svelte.d.ts +39 -2
  25. package/dist/fancy-ui/flip-card/FlipCard.test.js +125 -25
  26. package/dist/fancy-ui/flip-card/README.md +41 -14
  27. package/dist/fancy-ui/flip-card/index.d.ts +1 -1
  28. package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +97 -11
  29. package/dist/fancy-ui/fluid-cursor/README.md +3 -1
  30. package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +18 -0
  31. package/dist/fancy-ui/fluid-cursor/fluid-shared.js +22 -0
  32. package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +17 -1
  33. package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +6 -0
  34. package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +32 -9
  35. package/dist/fancy-ui/glow-border/GlowBorder.svelte +256 -33
  36. package/dist/fancy-ui/glow-border/GlowBorder.svelte.d.ts +41 -2
  37. package/dist/fancy-ui/glow-border/GlowBorder.test.js +45 -1
  38. package/dist/fancy-ui/glow-border/README.md +25 -33
  39. package/dist/fancy-ui/glow-border/index.d.ts +1 -1
  40. package/dist/fancy-ui/glow-border/index.js +1 -1
  41. package/dist/fancy-ui/gradient-button/GradientButton.svelte +123 -42
  42. package/dist/fancy-ui/gradient-button/GradientButton.svelte.d.ts +10 -5
  43. package/dist/fancy-ui/gradient-button/GradientButton.test.js +22 -5
  44. package/dist/fancy-ui/gradient-button/README.md +14 -6
  45. package/dist/fancy-ui/index.d.ts +1 -0
  46. package/dist/fancy-ui/index.js +1 -0
  47. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.svelte +166 -23
  48. package/dist/fancy-ui/interactive-hover-button/InteractiveHoverButton.test.js +18 -68
  49. package/dist/fancy-ui/interactive-hover-button/README.md +21 -15
  50. package/dist/fancy-ui/meteors/Meteors.svelte +179 -18
  51. package/dist/fancy-ui/meteors/Meteors.svelte.d.ts +32 -2
  52. package/dist/fancy-ui/meteors/Meteors.test.js +70 -22
  53. package/dist/fancy-ui/meteors/README.md +21 -6
  54. package/dist/fancy-ui/neon-border/NeonBorder.svelte +187 -58
  55. package/dist/fancy-ui/neon-border/NeonBorder.svelte.d.ts +24 -5
  56. package/dist/fancy-ui/neon-border/NeonBorder.test.js +51 -19
  57. package/dist/fancy-ui/neon-border/README.md +21 -11
  58. package/dist/fancy-ui/pagination/Pagination.svelte +144 -31
  59. package/dist/fancy-ui/pagination/Pagination.test.js +64 -8
  60. package/dist/fancy-ui/pagination/README.md +32 -22
  61. package/dist/fancy-ui/rainbow-button/README.md +3 -2
  62. package/dist/fancy-ui/rainbow-button/RainbowButton.svelte +108 -0
  63. package/dist/fancy-ui/rainbow-button/RainbowButton.test.js +12 -0
  64. package/dist/fancy-ui/registry.js +256 -41
  65. package/dist/fancy-ui/ripple-button/README.md +10 -7
  66. package/dist/fancy-ui/ripple-button/RippleButton.svelte +172 -19
  67. package/dist/fancy-ui/ripple-button/RippleButton.svelte.d.ts +20 -1
  68. package/dist/fancy-ui/ripple-button/RippleButton.test.js +47 -1
  69. package/dist/fancy-ui/shimmer-button/README.md +14 -6
  70. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte +137 -54
  71. package/dist/fancy-ui/shimmer-button/ShimmerButton.svelte.d.ts +3 -3
  72. package/dist/fancy-ui/shimmer-button/ShimmerButton.test.js +21 -7
  73. package/package.json +1 -1
  74. package/dist/fancy-ui/themes.d.ts +0 -38
  75. package/dist/fancy-ui/themes.js +0 -356
@@ -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,113 @@
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. It runs the colour
283
+ channel, and the release half of the press: letting go settles, it
284
+ does not snap back. */
285
+ --ft-btn-motion: var(--ft-duration-fast, 150ms)
286
+ var(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));
287
+ /* The down-stroke only. 150ms = tokens.DURATIONS.fast,
288
+ cubic-bezier(0.16, 1, 0.3, 1) = tokens.EASINGS.out — the arrival
289
+ curve, which spends most of the scale in the first frames. Same clock
290
+ as the release, but the button is already down by the time the eye
291
+ reads it, so the press bites instead of easing in. Pressing is an
292
+ arrival; releasing is what resolves either way. */
293
+ --ft-btn-press: var(--ft-duration-fast, 150ms) var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));
294
+ /*
295
+ * Replaces the `transition-colors` utility removed from the class string
296
+ * above, at exactly the values that utility already resolved to. Colour is
297
+ * a state change, not motion, so it stays OUTSIDE the reduced-motion query
298
+ * — gating it would only make a theme flip look broken for the people who
299
+ * asked for less movement.
300
+ *
301
+ * `box-shadow` is deliberately absent, and must stay absent: this button's
302
+ * focus ring is `focus-visible:ring-*`, which compiles to a `box-shadow`,
303
+ * and a focus ring must never animate. `text-decoration-color`, `fill` and
304
+ * `stroke` never change on this control, so the three listed here are the
305
+ * faithful subset of what the utility covered.
306
+ */
307
+ transition:
308
+ color var(--ft-btn-motion),
309
+ background-color var(--ft-btn-motion),
310
+ border-color var(--ft-btn-motion);
311
+ /* Kills the ~300ms tap delay without blocking scroll — the same rule, for
312
+ the same reason, as `.ft-pressable` and `.ft-toggle`. A press that
313
+ answers a third of a second late is not press feedback. */
314
+ touch-action: manipulation;
315
+ }
316
+
317
+ /*
318
+ * The press. Pressable's contract inlined as a plain rule on the native
319
+ * control rather than wrapping a `<button>` in a `<Pressable>` div — same
320
+ * `0.97`, same clock, one element instead of two.
321
+ *
322
+ * Only the property list is re-declared in here: `transform` joins the colour
323
+ * channel under `no-preference` and nowhere else, so with motion reduced the
324
+ * colours still cross and the button simply does not move. The resting state
325
+ * (no `transform` at all) is the ungated fallback.
326
+ *
327
+ * The two strokes are timed apart. The rule below carries the release curve,
328
+ * because that is the transition the button runs on its way back to rest;
329
+ * `:active` overrides `transform` with the arrival curve for the way down.
330
+ */
331
+ @media (prefers-reduced-motion: no-preference) {
332
+ .ft-btn {
333
+ transition:
334
+ color var(--ft-btn-motion),
335
+ background-color var(--ft-btn-motion),
336
+ border-color var(--ft-btn-motion),
337
+ transform var(--ft-btn-motion);
338
+ }
339
+
340
+ /* Both inert states are excluded so a press that does nothing does not
341
+ pretend to. `[data-disabled]` is the anchor branch, which has no native
342
+ `:disabled` to hang this off; `[aria-busy]` is the loading one, on both
343
+ branches. A native disabled `<button>` never matches `:active` anyway —
344
+ the attribute selectors are what make the anchor behave like it. */
345
+ .ft-btn:not([data-disabled="true"]):not([aria-busy="true"]):active {
346
+ transform: scale(0.97);
347
+ /* The whole list is re-declared rather than just
348
+ `transition-timing-function`, which has no per-property form here:
349
+ a single value would retime the colour channel too, and a
350
+ four-value one would restate the same three curves to change the
351
+ fourth. Only `transform` differs from the rule above. */
352
+ transition:
353
+ color var(--ft-btn-motion),
354
+ background-color var(--ft-btn-motion),
355
+ border-color var(--ft-btn-motion),
356
+ transform var(--ft-btn-press);
357
+ }
358
+ }
359
+
360
+ /*
361
+ * The lead slot: one fixed cell the spinner and `iconStart` share, sized
362
+ * exactly like the spinner it holds (`calc(1em + 1px)`, read from the
363
+ * button's own font-size, so it follows the size variant for free). Fixed
364
+ * rather than content-sized on purpose — a slot that measured whichever
365
+ * child happened to be mounted would grow while both are cross-fading and
366
+ * snap back the frame the fade ended, moving the label twice for one swap.
367
+ */
368
+ .ft-btn-lead {
369
+ display: grid;
370
+ place-items: center;
371
+ flex: none;
372
+ width: calc(1em + 1px);
373
+ height: calc(1em + 1px);
374
+ }
375
+
376
+ /* Both children occupy the one cell at once — that overlap IS the
377
+ cross-fade. Named rather than `> *` so a caller's own `iconStart` markup,
378
+ which is one level further down, is never caught by it. */
379
+ .ft-btn-lead > .ft-btn-spinner,
380
+ .ft-btn-lead > .ft-btn-lead-icon {
381
+ grid-area: 1 / 1;
382
+ }
383
+
384
+ .ft-btn-lead-icon {
385
+ display: inline-flex;
386
+ align-items: center;
387
+ justify-content: center;
201
388
  }
202
389
 
203
390
  .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. The two strokes are timed apart: going down runs on `--ft-ease-out`, the arrival curve, which spends most of the scale in the first frames so the press bites rather than easing in; coming back up runs on `--ft-ease-inout`, the reversible-state curve, so letting go settles instead of snapping. Same 150 ms clock 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 everywhere else. The colour channel and the press release run on `--ft-ease-inout` (`cubic-bezier(0.4, 0, 0.2, 1)`), the press down-stroke on `--ft-ease-out` (`cubic-bezier(0.16, 1, 0.3, 1)`). The `:active` rule re-declares the whole transition list to retime that one property, because `transition-timing-function` has no per-property form that would not restate the other three curves anyway.
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.