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.
- package/dist/fancy-ui/button/Button.svelte +174 -9
- package/dist/fancy-ui/button/Button.test.js +117 -3
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte +32 -0
- package/dist/fancy-ui/button/ButtonLeadHarness.test.svelte.d.ts +3 -0
- package/dist/fancy-ui/button/README.md +13 -2
- package/dist/fancy-ui/fluid-cursor/FluidCursor.svelte +97 -11
- package/dist/fancy-ui/fluid-cursor/README.md +3 -1
- package/dist/fancy-ui/fluid-cursor/fluid-shared.d.ts +18 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.js +22 -0
- package/dist/fancy-ui/fluid-cursor/fluid-shared.test.js +17 -1
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.d.ts +6 -0
- package/dist/fancy-ui/fluid-cursor/webgpu-engine.js +32 -9
- package/package.json +1 -1
- package/dist/fancy-ui/themes.d.ts +0 -38
- 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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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(
|
|
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>
|
|
@@ -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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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.
|
|
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";
|
package/dist/fancy-ui/themes.js
DELETED
|
@@ -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";
|