libraries-dev-svelte 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,23 +13,93 @@ Svelte 5 ports of the Libraries.dev Bot Avatars and Gooey radial plus menu. The
13
13
  <BotAvatar type="clover" {state} size={64} />
14
14
  ```
15
15
 
16
- The port retains all 18 shapes; idle, working and sleeping animation states; eyes and optional state-aware mouth; pointer following and click jumps; `plastic`, `crisp`, `smooth` and `flat` shading; lighting, whirl and jump controls; reduced-motion and paused poses; seed synchronization; accessible labels; and the canvas drawing exports. Canvas properties and native click handlers pass through.
16
+ The port retains all 18 shapes; idle, working and sleeping animation states; eyes and optional state-aware mouth; pointer following and click jumps; `plastic`, `crisp`, `smooth` and `flat` shading; lighting, whirl and jump controls; reduced-motion and paused poses; seed synchronization; accessible labels; and the canvas drawing exports. Canvas properties and native click handlers pass through. It updates at 30 fps with a maximum device pixel ratio of 1.5 by default to reduce continuous drawing and backing-store memory on high-density screens; set `fps={60}` and `maxDpr={2}` when maximum smoothness and resolution matter more.
17
17
 
18
18
  Types, presets, palette, shape paths, simulation, renderer, and plastic-material helpers are exported from the package entry point. See `packages/bot-avatars/README.md` in the source clone for the complete original prop descriptions.
19
19
 
20
20
  ## Gooey radial menu
21
21
 
22
+ ```svelte
23
+ <script lang="ts">
24
+ import { GooeyRadialMenu, gooeyShadowPresets } from 'libraries-dev-svelte';
25
+ </script>
26
+
27
+ <GooeyRadialMenu bare blur={6} contrast={18} shadow={gooeyShadowPresets.light.soft} />
28
+ ```
29
+
30
+ This ports the source `PlusMenu.tsx` demo and the liquid-gooey behavior it uses: filtered merged silhouettes and shadows, synchronized CSS transform transitions for each button and its SVG blob, stagger and easing controls, close anticipation, theme-aware fill, and the full tuning panel. The normal playground exposes duration and stagger; add `?pro` on `/gooey` for the full controls, copy-values output, and reset.
31
+
32
+ ### Shadow styles
33
+
34
+ `shadow` styles one silhouette behind the menu. When the buttons join, their shadow joins into one continuous outline; it is not a separate shadow on each button. The presets are split by surface theme:
35
+
36
+ ```ts
37
+ gooeyShadowPresets.light.soft
38
+ gooeyShadowPresets.light.floating
39
+ gooeyShadowPresets.dark.soft
40
+ gooeyShadowPresets.dark.floating
41
+ gooeyShadowPresets.none
42
+ ```
43
+
44
+ You can also pass CSS `box-shadow` layers directly. Layer order is horizontal offset, vertical offset, blur, optional spread, then color. Separate layers with commas; add `inset` for an inner ring or highlight. Lengths must be numbers in pixels (`px`) or unitless values.
45
+
46
+ ```svelte
47
+ <GooeyRadialMenu
48
+ bare
49
+ shadow="0 0 0 1px rgba(0, 0, 0, .06), 0 4px 12px rgba(0, 0, 0, .12)"
50
+ />
51
+ ```
52
+
53
+ Props: `blur` (6), `contrast` (18), `fill` (`var(--modal-bg, #fff)`), `shadow` (empty), `items` (optional custom satellite items), `onAction` (optional callback; receives the selected item's `action` string), `pro` (false), and `bare` (false). `bare` renders only the menu stage and is the recommended mode when embedding it in an application; without it, the tuning panel is included. Selecting a satellite calls `onAction` when supplied and closes the menu. The host can set `--modal-bg`, `--btn-text`, `--stage-bg`, `--accent`, and `--hover-veil` to match its theme.
54
+
55
+ ### Custom items
56
+
57
+ **Note for package users:** `GooeyRadialMenu` no longer ships fixed demo items. By default it still renders the original `New file` / `Add image` / `New folder` demo, but you can pass your own via the `items` prop. Each item is `{ label, action, x, y, iconSnippet? }`, where `x`/`y` are pixel offsets from the main button (positive `y` is down) and `action` is any string reported back through `onAction`. Keep offsets within the stage (the default stage is 200×140 with the main button centered) so satellites stay inside the goo filter. Icons are snippets, so any markup works — including an icon library. If `iconSnippet` is omitted, the built-in `icon` key (`'file' | 'image' | 'folder'`) is used; when neither is set, the item renders unlabeled.
58
+
22
59
  ```svelte
23
60
  <script lang="ts">
24
61
  import { GooeyRadialMenu } from 'libraries-dev-svelte';
62
+
63
+ const items = [
64
+ { label: 'Reply', action: 'reply', x: -54, y: -34 },
65
+ { label: 'Share', action: 'share', x: 0, y: -64 },
66
+ { label: 'Delete', action: 'delete', x: 54, y: -34 },
67
+ ];
68
+
69
+ function handleAction(action: string) {
70
+ if (action === 'delete') confirmDelete();
71
+ }
25
72
  </script>
26
73
 
27
- <GooeyRadialMenu blur={6} contrast={18} shadow="0 2px 6px rgba(0, 0, 0, .08)" />
74
+ <GooeyRadialMenu bare {items} onAction={handleAction} />
28
75
  ```
29
76
 
30
- This ports the source `PlusMenu.tsx` demo and the liquid-gooey behavior it uses: filtered merged silhouettes and shadows, a shared animation clock for each button and its SVG blob, stagger and easing controls, close anticipation, theme-aware fill, and the full tuning panel. The normal playground exposes duration and stagger; add `?pro` on `/gooey` for the full controls, copy-values output, and reset.
77
+ With custom icons:
31
78
 
32
- Props: `blur` (6), `contrast` (18), `fill` (`var(--modal-bg, #fff)`), `shadow` (empty), `pro` (false), and `bare` (false). `bare` renders only the menu stage. The host can set `--modal-bg`, `--btn-text`, `--stage-bg`, `--accent`, and `--hover-veil` to match its theme.
79
+ ```svelte
80
+ <script lang="ts">
81
+ import { GooeyRadialMenu } from 'libraries-dev-svelte';
82
+ import { Camera, Send, Trash } from 'reicon-svelte';
83
+
84
+ const items = [
85
+ { label: 'Camera', action: 'camera', x: -54, y: -34 },
86
+ { label: 'Send', action: 'send', x: 0, y: -64 },
87
+ { label: 'Delete', action: 'delete', x: 54, y: -34 },
88
+ ];
89
+ </script>
90
+
91
+ <GooeyRadialMenu
92
+ bare
93
+ {items}
94
+ onAction={(action) => console.log(action)}
95
+ >
96
+ {#snippet iconSnippet(item)}
97
+ {#if item.action === 'camera'}<Camera size={16} weight="Outline" />{/if}
98
+ {#if item.action === 'send'}<Send size={16} weight="Outline" />{/if}
99
+ {#if item.action === 'delete'}<Trash size={16} weight="Outline" />{/if}
100
+ {/snippet}
101
+ </GooeyRadialMenu>
102
+ ```
33
103
 
34
104
  ## Demo routes
35
105
 
@@ -15,6 +15,8 @@
15
15
  face,
16
16
  state: avatarState = 'default',
17
17
  size = 64,
18
+ fps = 30,
19
+ maxDpr = 1.5,
18
20
  color,
19
21
  ink,
20
22
  brightness = 1,
@@ -69,6 +71,8 @@
69
71
  let sim: Sim | null = null;
70
72
  let cfg: DrawConfig | null = null;
71
73
  let cssSize = 0;
74
+ let drawContext: CanvasRenderingContext2D | null = null;
75
+ let bounds: DOMRect | null = null;
72
76
  let instanceSeed = 0;
73
77
  let speedValue = 1;
74
78
  let interactiveValue = true;
@@ -117,14 +121,18 @@
117
121
  /* A hidden ancestor measures 0: keep the last size rather than wiping the backing store. */
118
122
  const px = el.clientWidth / OVERSCAN || cssSize || (typeof size === 'number' ? size : 64);
119
123
  if (!px) return;
120
- const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
124
+ const dpr = Math.min(
125
+ 2,
126
+ clamp(maxDpr, 1, 2),
127
+ (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1,
128
+ );
121
129
  const wanted = Math.round(px * OVERSCAN * dpr);
122
130
  if (el.width !== wanted || el.height !== wanted) {
123
131
  el.width = wanted;
124
132
  el.height = wanted;
125
133
  }
126
134
  cssSize = px;
127
- const ctx = el.getContext('2d');
135
+ const ctx = drawContext ?? (drawContext = el.getContext('2d'));
128
136
  if (!ctx) return;
129
137
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
130
138
  current.dpr = dpr;
@@ -164,6 +172,34 @@
164
172
  };
165
173
  media?.addEventListener?.('change', updateMotion);
166
174
 
175
+ const el = canvas;
176
+ if (el) {
177
+ const measure = () => {
178
+ const measuredSize = el.clientWidth / OVERSCAN;
179
+ if (measuredSize > 0) cssSize = measuredSize;
180
+ bounds = el.getBoundingClientRect();
181
+ paint();
182
+ };
183
+ const updateBounds = () => {
184
+ bounds = el.getBoundingClientRect();
185
+ };
186
+ const observer = typeof ResizeObserver === 'function'
187
+ ? new ResizeObserver(measure)
188
+ : null;
189
+ observer?.observe(el);
190
+ window.addEventListener('scroll', updateBounds, { capture: true, passive: true });
191
+ window.addEventListener('resize', measure, { passive: true });
192
+ mounted = true;
193
+ measure();
194
+
195
+ return () => {
196
+ media?.removeEventListener?.('change', updateMotion);
197
+ observer?.disconnect();
198
+ window.removeEventListener('scroll', updateBounds, true);
199
+ window.removeEventListener('resize', measure);
200
+ };
201
+ }
202
+
167
203
  mounted = true;
168
204
 
169
205
  return () => {
@@ -228,7 +264,11 @@
228
264
  $effect(() => {
229
265
  if (!mounted || shadingMode !== 'plastic' || typeof Path2D === 'undefined') return;
230
266
  const path = bodyPath(SHAPE_PATHS[type] ?? SHAPE_PATHS.clover);
231
- const dpr = Math.min(2, (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1);
267
+ const dpr = Math.min(
268
+ 2,
269
+ clamp(maxDpr, 1, 2),
270
+ (typeof devicePixelRatio === 'number' && devicePixelRatio) || 1,
271
+ );
232
272
  const devicePx = (typeof size === 'number' ? size : 64) * dpr;
233
273
  const runtime = globalThis as typeof globalThis & {
234
274
  requestIdleCallback?: (callback: () => void) => number;
@@ -247,20 +287,37 @@
247
287
  const el = canvas;
248
288
  const current = sim;
249
289
  if (!mounted || !el || !current || frozen || motionReduced) return;
250
- let onScreen = true;
251
290
  let unsubscribe: (() => void) | null = null;
252
291
  const reach = 3;
292
+ const frameInterval = 1 / clamp(fps, 10, 60);
293
+ let frameElapsed = 0;
294
+ let lastPointerVersion = -1;
253
295
  const tick = (dt: number) => {
296
+ frameElapsed += dt;
297
+ if (frameElapsed < frameInterval) return;
298
+ const frameDt = frameElapsed;
299
+ frameElapsed = 0;
300
+
254
301
  if (interactiveValue && !Number.isNaN(pointer.x)) {
255
- const rect = el.getBoundingClientRect();
302
+ if (pointer.version !== lastPointerVersion) {
303
+ bounds = el.getBoundingClientRect();
304
+ lastPointerVersion = pointer.version;
305
+ }
306
+ }
307
+
308
+ if (interactiveValue && !Number.isNaN(pointer.x) && bounds) {
309
+ const rect = bounds;
256
310
  const box = rect.width / OVERSCAN || 1;
257
311
  const dx = (pointer.x - (rect.left + rect.width / 2)) / box;
258
312
  const dy = (pointer.y - (rect.top + rect.height / 2 + RISE * box)) / box;
259
- const distance = Math.hypot(dx, dy);
260
- const strength = distance < 1 ? 1 : distance > reach ? 0 : 1 - (distance - 1) / (reach - 1);
261
- current.setPointer(dx / Math.max(1, distance), dy / Math.max(1, distance), strength);
313
+ const distanceSquared = dx * dx + dy * dy;
314
+ if (distanceSquared < 1) current.setPointer(dx, dy, 1);
315
+ else if (distanceSquared < reach * reach) {
316
+ const distance = Math.sqrt(distanceSquared);
317
+ current.setPointer(dx / distance, dy / distance, 1 - (distance - 1) / (reach - 1));
318
+ } else current.setPointer(0, 0, 0);
262
319
  } else current.setPointer(0, 0, 0);
263
- current.update(dt * speedValue);
320
+ current.update(frameDt * speedValue);
264
321
  paint();
265
322
  };
266
323
  const run = () => {
@@ -273,8 +330,7 @@
273
330
  let observer: IntersectionObserver | null = null;
274
331
  if (typeof IntersectionObserver === 'function') {
275
332
  observer = new IntersectionObserver((entries) => {
276
- onScreen = entries[0]?.isIntersecting ?? true;
277
- if (onScreen) run();
333
+ if (entries[0]?.isIntersecting ?? true) run();
278
334
  else stop();
279
335
  });
280
336
  observer.observe(el);
@@ -3,6 +3,7 @@ type Tick = (dt: number) => void;
3
3
  export declare const pointer: {
4
4
  x: number;
5
5
  y: number;
6
+ version: number;
6
7
  };
7
8
  export declare function subscribe(fn: Tick): () => void;
8
9
  export {};
@@ -2,7 +2,7 @@
2
2
  the seconds since their last tick; the loop stops while the tab is
3
3
  hidden and while nobody is subscribed. */
4
4
  /** The last pointer position on the page, NaN while it is away. */
5
- export const pointer = { x: NaN, y: NaN };
5
+ export const pointer = { x: NaN, y: NaN, version: 0 };
6
6
  const subs = new Set();
7
7
  let raf = 0;
8
8
  let last = 0;
@@ -25,7 +25,11 @@ function wire() {
25
25
  if (wired || typeof document === 'undefined')
26
26
  return;
27
27
  wired = true;
28
- document.addEventListener('pointermove', (e) => { pointer.x = e.clientX; pointer.y = e.clientY; }, { passive: true });
28
+ document.addEventListener('pointermove', (e) => {
29
+ pointer.x = e.clientX;
30
+ pointer.y = e.clientY;
31
+ pointer.version++;
32
+ }, { passive: true });
29
33
  document.addEventListener('pointerleave', () => { pointer.x = NaN; pointer.y = NaN; });
30
34
  window.addEventListener('blur', () => { pointer.x = NaN; pointer.y = NaN; });
31
35
  document.addEventListener('visibilitychange', () => {
@@ -64,6 +64,10 @@ export interface BotAvatarProps extends Omit<HTMLCanvasAttributes, 'color' | 'st
64
64
  saturation?: number;
65
65
  /** Multiplier on every animation's speed. Default `1`. */
66
66
  speed?: number;
67
+ /** Maximum animation updates per second, clamped to 10–60. Default `30`. */
68
+ fps?: number;
69
+ /** Maximum backing-store device pixel ratio, clamped to 1–2. Default `1.5`. */
70
+ maxDpr?: number;
67
71
  /** Freeze every animation on its current frame. */
68
72
  paused?: boolean;
69
73
  /**
@@ -1,25 +1,28 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy, onMount } from 'svelte';
3
- import { easingFunction } from './motion';
4
3
  import { parseShadow, type ShadowLayer } from './shadow';
5
- import type { GooeyMenuSettings, GooeyRadialMenuProps } from './types';
4
+ import type { GooeyMenuAction, GooeyMenuItem, GooeyMenuSettings, GooeyRadialMenuProps } from './types';
6
5
 
7
6
  let {
8
7
  blur = 6,
9
8
  contrast = 18,
10
9
  fill = 'var(--modal-bg, #fff)',
11
10
  shadow = '',
11
+ items,
12
+ onAction,
13
+ iconSnippet,
12
14
  pro = false,
13
15
  bare = false,
14
16
  }: GooeyRadialMenuProps & { fill?: string } = $props();
15
17
 
16
18
  const instanceId = $props.id();
17
19
  const filterId = `gooey-${instanceId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
18
- const satellites = [
19
- { label: 'New file', x: -54, y: -34, icon: 'file' },
20
- { label: 'Add image', x: 0, y: -64, icon: 'image' },
21
- { label: 'New folder', x: 54, y: -34, icon: 'folder' },
22
- ] as const;
20
+ const demoItems: GooeyMenuItem[] = [
21
+ { label: 'New file', action: 'new-file', x: -54, y: -34, icon: 'file' },
22
+ { label: 'Add image', action: 'add-image', x: 0, y: -64, icon: 'image' },
23
+ { label: 'New folder', action: 'new-folder', x: 54, y: -34, icon: 'folder' },
24
+ ];
25
+ const satellites = $derived(items ?? demoItems);
23
26
  const defaults: GooeyMenuSettings = {
24
27
  openDur: 550,
25
28
  openEase: 'Bouncy',
@@ -41,8 +44,7 @@
41
44
  Linear: 'linear',
42
45
  };
43
46
  const easeNames = Object.keys(eases);
44
- const allItems = [...satellites, { label: 'Menu', x: 0, y: 0, icon: 'main' as const }];
45
- const initialBoxes: Array<BlobBox | null> = allItems.map(() => null);
47
+ const allItems = $derived([...satellites, { label: 'Menu', x: 0, y: 0, icon: 'main' as const }]);
46
48
 
47
49
  interface BlobBox {
48
50
  x: number;
@@ -58,11 +60,11 @@
58
60
  let menuSettings = $state<GooeyMenuSettings>({ ...defaults });
59
61
  let copied = $state(false);
60
62
  let reducedMotion = $state(false);
61
- let mounted = $state(false);
62
63
  let groupElement: HTMLDivElement;
63
64
  let itemWrappers: Array<HTMLDivElement | undefined> = [];
64
- let blobElements: Array<SVGRectElement | undefined> = [];
65
- let itemBoxes = $state<Array<BlobBox | null>>(initialBoxes);
65
+ let satelliteButtons: Array<HTMLButtonElement | undefined> = [];
66
+ let mainButton: HTMLButtonElement;
67
+ let itemBoxes = $state<Array<BlobBox | null>>([]);
66
68
  let groupSize = $state({ w: 0, h: 0 });
67
69
 
68
70
  const shadows = $derived(parseShadow(shadow));
@@ -104,10 +106,10 @@
104
106
  },
105
107
  );
106
108
 
107
- let positions = allItems.map(() => ({ x: 0, y: 0 }));
108
- let frameIds = allItems.map(() => 0);
109
109
  let anticipationTimer: ReturnType<typeof setTimeout> | undefined;
110
110
  let copyTimer: ReturnType<typeof setTimeout> | undefined;
111
+ let anticipationFrame = 0;
112
+ let focusFrame = 0;
111
113
 
112
114
  function parseCorner(value: string, size: number): number {
113
115
  const first = value.split(' ')[0];
@@ -165,67 +167,46 @@
165
167
  }
166
168
  }
167
169
 
168
- function writeTransform(index: number, x: number, y: number) {
169
- const transform = `translate(${x}px, ${y}px)`;
170
- if (itemWrappers[index]) itemWrappers[index]!.style.transform = transform;
171
- if (blobElements[index]) blobElements[index]!.style.transform = transform;
172
- positions[index] = { x, y };
170
+ function transformFor(index: number) {
171
+ const item = allItems[index];
172
+ if (!menuOpen || !item || (!item.x && !item.y)) return 'none';
173
+ return `translate(${item.x * menuSettings.spread}px, ${item.y * menuSettings.spread}px)`;
173
174
  }
174
175
 
175
- function animateItem(index: number, x: number, y: number, duration: number, ease: string, delay: number) {
176
- if (frameIds[index]) cancelAnimationFrame(frameIds[index]);
177
- const from = positions[index];
178
- if (duration <= 0 || (from.x === x && from.y === y)) {
179
- writeTransform(index, x, y);
180
- frameIds[index] = 0;
181
- return;
182
- }
183
-
184
- const startPosition = { ...from };
185
- const evaluate = easingFunction(ease);
186
- const start = performance.now() + delay;
187
- const tick = (now: number) => {
188
- const progress = Math.min(1, Math.max(0, (now - start) / duration));
189
- const eased = evaluate(progress);
190
- writeTransform(
191
- index,
192
- startPosition.x + (x - startPosition.x) * eased,
193
- startPosition.y + (y - startPosition.y) * eased,
194
- );
195
- if (progress < 1) frameIds[index] = requestAnimationFrame(tick);
196
- else frameIds[index] = 0;
197
- };
198
- frameIds[index] = requestAnimationFrame(tick);
199
- }
200
-
201
- $effect(() => {
202
- const open = menuOpen;
203
- const duration = reducedMotion ? 0 : phase.duration;
204
- const ease = phase.ease;
205
- const stagger = phase.stagger;
206
- const spread = menuSettings.spread;
207
- if (!mounted) return;
208
- satellites.forEach((satellite, index) => {
209
- animateItem(
210
- index,
211
- open ? satellite.x * spread : 0,
212
- open ? satellite.y * spread : 0,
213
- duration,
214
- ease,
215
- index * stagger,
216
- );
217
- });
218
- animateItem(allItems.length - 1, 0, 0, duration, ease, 0);
219
- });
220
-
221
176
  function toggle() {
177
+ const opening = !menuOpen;
222
178
  if (menuOpen && menuSettings.anticipDist > 0) {
223
179
  if (anticipationTimer) clearTimeout(anticipationTimer);
180
+ if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
224
181
  anticipating = false;
225
- requestAnimationFrame(() => (anticipating = true));
182
+ anticipationFrame = requestAnimationFrame(() => {
183
+ anticipationFrame = 0;
184
+ anticipating = true;
185
+ });
226
186
  anticipationTimer = setTimeout(() => (anticipating = false), menuSettings.anticipDur);
227
187
  }
228
- menuOpen = !menuOpen;
188
+ menuOpen = opening;
189
+ if (focusFrame) cancelAnimationFrame(focusFrame);
190
+ if (opening) {
191
+ focusFrame = requestAnimationFrame(() => {
192
+ focusFrame = 0;
193
+ if (menuOpen) satelliteButtons[0]?.focus();
194
+ });
195
+ } else {
196
+ mainButton?.focus();
197
+ }
198
+ }
199
+
200
+ function activateSatellite(action: GooeyMenuAction) {
201
+ toggle();
202
+ onAction?.(action);
203
+ }
204
+
205
+ function handleEscape(event: KeyboardEvent) {
206
+ if (event.key === 'Escape' && menuOpen) {
207
+ event.preventDefault();
208
+ toggle();
209
+ }
229
210
  }
230
211
 
231
212
  function updateSetting<K extends keyof GooeyMenuSettings>(key: K, value: GooeyMenuSettings[K]) {
@@ -260,8 +241,6 @@
260
241
  reducedMotion = motionQuery.matches;
261
242
  const updateMotionPreference = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
262
243
  motionQuery.addEventListener('change', updateMotionPreference);
263
- mounted = true;
264
-
265
244
  return () => {
266
245
  observer.disconnect();
267
246
  motionQuery.removeEventListener('change', updateMotionPreference);
@@ -271,7 +250,8 @@
271
250
  onDestroy(() => {
272
251
  if (anticipationTimer) clearTimeout(anticipationTimer);
273
252
  if (copyTimer) clearTimeout(copyTimer);
274
- frameIds.forEach((frame) => frame && cancelAnimationFrame(frame));
253
+ if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
254
+ if (focusFrame) cancelAnimationFrame(focusFrame);
275
255
  });
276
256
  </script>
277
257
 
@@ -279,13 +259,13 @@
279
259
  <div
280
260
  class={`pm${menuOpen ? ' pm-open' : ''}${anticipating ? ' pm-anticipating' : ''}`}
281
261
  bind:this={groupElement}
282
- style={`--pm-anticip:${menuSettings.anticipDist}px;--pm-anticip-dur:${menuSettings.anticipDur}ms;--pm-icon-dur:${menuSettings.iconDur}ms;position:relative;isolation:isolate;`}
262
+ style={`--pm-anticip:${menuSettings.anticipDist}px;--pm-anticip-dur:${menuSettings.anticipDur}ms;--pm-icon-dur:${menuSettings.iconDur}ms;--pm-transition-duration:${reducedMotion ? 0 : phase.duration}ms;--pm-transition-ease:${phase.ease};position:relative;isolation:isolate;`}
283
263
  >
284
264
  <svg
285
265
  aria-hidden="true"
286
266
  focusable="false"
287
267
  data-gooey-svg=""
288
- style={`position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:-1;will-change:filter,transform;${cssShadowFilter ? `filter:${cssShadowFilter};` : ''}`}
268
+ style={`position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:-1;${cssShadowFilter ? `filter:${cssShadowFilter};` : ''}`}
289
269
  >
290
270
  <defs>
291
271
  <filter
@@ -409,11 +389,10 @@
409
389
  {/if}
410
390
  </filter>
411
391
  </defs>
412
- <g id={`${filterId}-sil`} filter={`url(#${filterId})`} style={`fill:${fill};`}>
392
+ <g id={`${filterId}-sil`} class="pm-blobs" filter={`url(#${filterId})`} style={`fill:${fill};`}>
413
393
  {#each itemBoxes as box, index (index)}
414
394
  {#if box}
415
395
  <rect
416
- bind:this={blobElements[index]}
417
396
  x={box.x}
418
397
  y={box.y}
419
398
  width={box.w}
@@ -421,7 +400,7 @@
421
400
  rx={box.r[0] === box.r[1] && box.r[1] === box.r[2] && box.r[2] === box.r[3]
422
401
  ? Math.max(0, Math.min(box.r[0], Math.min(box.w, box.h) / 2))
423
402
  : undefined}
424
- style={`transform:translate(${positions[index].x}px,${positions[index].y}px);transform-box:fill-box;transform-origin:center;will-change:transform;`}
403
+ style={`--pm-transition-delay:${reducedMotion || index >= satellites.length ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}
425
404
  />
426
405
  {/if}
427
406
  {/each}
@@ -429,13 +408,16 @@
429
408
  </svg>
430
409
 
431
410
  {#each satellites as satellite, index (satellite.label)}
432
- <div bind:this={itemWrappers[index]} class="pm-slot" style="display:inline-block;will-change:transform;">
411
+ <div bind:this={itemWrappers[index]} class="pm-slot" style={`--pm-transition-delay:${reducedMotion ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}>
433
412
  <button
434
413
  type="button"
435
414
  class="pm-btn pm-sat"
436
415
  aria-label={satellite.label}
437
416
  tabindex={menuOpen ? 0 : -1}
438
- onclick={toggle}
417
+ inert={!menuOpen}
418
+ bind:this={satelliteButtons[index]}
419
+ onclick={() => activateSatellite(satellite.action)}
420
+ onkeydown={handleEscape}
439
421
  >
440
422
  <span
441
423
  class="pm-sat-icon"
@@ -443,7 +425,9 @@
443
425
  ? `${menuSettings.iconDelay + index * phase.stagger}ms`
444
426
  : '0ms'}
445
427
  >
446
- {#if satellite.icon === 'file'}
428
+ {#if iconSnippet}
429
+ {@render iconSnippet(satellite)}
430
+ {:else if satellite.icon === 'file'}
447
431
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
448
432
  <path d="M9 1.5H4A1.5 1.5 0 0 0 2.5 3v10A1.5 1.5 0 0 0 4 14.5h8a1.5 1.5 0 0 0 1.5-1.5V6z" />
449
433
  <path d="M9 1.5V6h4.5" />
@@ -464,13 +448,15 @@
464
448
  </div>
465
449
  {/each}
466
450
 
467
- <div bind:this={itemWrappers[satellites.length]} class="pm-slot" style="display:inline-block;will-change:transform;">
451
+ <div bind:this={itemWrappers[satellites.length]} class="pm-slot" style={`transform:${transformFor(satellites.length)};`}>
468
452
  <button
453
+ bind:this={mainButton}
469
454
  type="button"
470
455
  class="pm-btn pm-main"
471
456
  aria-expanded={menuOpen}
472
457
  aria-label={menuOpen ? 'Close menu' : 'Open menu'}
473
458
  onclick={toggle}
459
+ onkeydown={handleEscape}
474
460
  >
475
461
  <span class="pm-plus">
476
462
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
@@ -597,9 +583,20 @@
597
583
  position: absolute;
598
584
  left: 80px;
599
585
  top: 80px;
586
+ display: inline-block;
587
+ transition: transform var(--pm-transition-duration, 0ms)
588
+ var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
589
+ }
590
+
591
+ .pm-blobs rect {
592
+ transform-box: fill-box;
593
+ transform-origin: center;
594
+ transition: transform var(--pm-transition-duration, 0ms)
595
+ var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
600
596
  }
601
597
 
602
598
  .pm-btn {
599
+ position: relative;
603
600
  width: 40px;
604
601
  height: 40px;
605
602
  border: 0;
@@ -611,6 +608,14 @@
611
608
  cursor: pointer;
612
609
  color: var(--btn-text, #17181c);
613
610
  -webkit-tap-highlight-color: transparent;
611
+ touch-action: manipulation;
612
+ }
613
+
614
+ .pm-btn::before {
615
+ content: '';
616
+ position: absolute;
617
+ inset: -2px;
618
+ border-radius: inherit;
614
619
  }
615
620
 
616
621
  .pm-btn:focus-visible {
@@ -7,4 +7,16 @@ export interface ShadowLayer {
7
7
  color: string;
8
8
  inset: boolean;
9
9
  }
10
+ /** Ready-to-use box-shadow values for the Gooey menu's merged liquid silhouette. */
11
+ export declare const gooeyShadowPresets: {
12
+ readonly light: {
13
+ readonly soft: "0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.05), 0 4px 42px rgba(0, 0, 0, 0.06)";
14
+ readonly floating: "0 2px 6px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.18)";
15
+ };
16
+ readonly dark: {
17
+ readonly soft: "0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 1px 0 rgba(255, 255, 255, 0.06) inset";
18
+ readonly floating: "0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.55)";
19
+ };
20
+ readonly none: "";
21
+ };
10
22
  export declare function parseShadow(input?: string | null): ShadowLayer[];
@@ -1,3 +1,15 @@
1
+ /** Ready-to-use box-shadow values for the Gooey menu's merged liquid silhouette. */
2
+ export const gooeyShadowPresets = {
3
+ light: {
4
+ soft: '0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.05), 0 4px 42px rgba(0, 0, 0, 0.06)',
5
+ floating: '0 2px 6px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.18)',
6
+ },
7
+ dark: {
8
+ soft: '0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 1px 0 rgba(255, 255, 255, 0.06) inset',
9
+ floating: '0 2px 6px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.55)',
10
+ },
11
+ none: '',
12
+ };
1
13
  function splitTop(value, separator) {
2
14
  const parts = [];
3
15
  let depth = 0;
@@ -1,4 +1,4 @@
1
- /** Inputs accepted by the Goey radial-menu playground component. */
1
+ /** Inputs accepted by the Gooey radial-menu component. */
2
2
  export interface GooeyRadialMenuProps {
3
3
  /** Goo filter blur sigma in px. */
4
4
  blur?: number;
@@ -6,6 +6,12 @@ export interface GooeyRadialMenuProps {
6
6
  contrast?: number;
7
7
  /** CSS box-shadow layers drawn on the merged liquid silhouette. */
8
8
  shadow?: string;
9
+ /** Custom satellite items. Defaults to the original demo items. */
10
+ items?: GooeyMenuItem[];
11
+ /** Called when an item is selected. The menu closes after activation. */
12
+ onAction?: (action: GooeyMenuAction) => void;
13
+ /** Snippet rendering each item's icon; receives the item. Falls back to the built-in demo icons. */
14
+ iconSnippet?: Snippet<[GooeyMenuItem]>;
9
15
  /** Surface color for the merged liquid. Defaults to the host theme's modal color. */
10
16
  fill?: string;
11
17
  /** Show all raw tuning controls, export, and reset controls. */
@@ -13,6 +19,20 @@ export interface GooeyRadialMenuProps {
13
19
  /** Render only the menu stage for embedding in a host layout. */
14
20
  bare?: boolean;
15
21
  }
22
+ /** A satellite item rendered around the main button. */
23
+ import type { Snippet } from 'svelte';
24
+ export interface GooeyMenuItem {
25
+ /** Accessible label (also the `aria-label`). */
26
+ label: string;
27
+ /** Stable identifier reported to `onAction`. */
28
+ action: string;
29
+ /** Offset in px from the main button (positive y is down). */
30
+ x: number;
31
+ y: number;
32
+ /** Built-in demo icon key: 'file' | 'image' | 'folder'. Used when the component's `iconSnippet` prop is not set. */
33
+ icon?: 'file' | 'image' | 'folder';
34
+ }
35
+ export type GooeyMenuAction = string;
16
36
  export interface GooeyMenuSettings {
17
37
  openDur: number;
18
38
  openEase: string;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { default as BotAvatar } from './bot-avatars/BotAvatar.svelte';
2
2
  export { default } from './bot-avatars/BotAvatar.svelte';
3
3
  export { default as GooeyRadialMenu } from './gooey-radial-menu/GooeyRadialMenu.svelte';
4
- export type { GooeyMenuSettings, GooeyRadialMenuProps } from './gooey-radial-menu/types';
4
+ export { gooeyShadowPresets } from './gooey-radial-menu/shadow';
5
+ export type { GooeyMenuAction, GooeyMenuItem, GooeyMenuSettings, GooeyRadialMenuProps } from './gooey-radial-menu/types';
5
6
  export { botAvatarPresets, botAvatarPalette, botAvatarTypes, botAvatarFaces, botAvatarStates, } from './bot-avatars/presets';
6
7
  export { SHAPE_PATHS as botAvatarShapes, SHAPE_PARTS as botAvatarParts } from './bot-avatars/shapes';
7
8
  export { autoInk, luminance, parseColor, shade } from './bot-avatars/color';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as BotAvatar } from './bot-avatars/BotAvatar.svelte';
2
2
  export { default } from './bot-avatars/BotAvatar.svelte';
3
3
  export { default as GooeyRadialMenu } from './gooey-radial-menu/GooeyRadialMenu.svelte';
4
+ export { gooeyShadowPresets } from './gooey-radial-menu/shadow';
4
5
  export { botAvatarPresets, botAvatarPalette, botAvatarTypes, botAvatarFaces, botAvatarStates, } from './bot-avatars/presets';
5
6
  export { SHAPE_PATHS as botAvatarShapes, SHAPE_PARTS as botAvatarParts } from './bot-avatars/shapes';
6
7
  export { autoInk, luminance, parseColor, shade } from './bot-avatars/color';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libraries-dev-svelte",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Svelte ports of Bot Avatars and the Gooey radial menu from Libraries.dev",
5
5
  "license": "MIT",
6
6
  "scripts": {