libraries-dev-svelte 0.1.0 → 0.2.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,7 +13,7 @@ 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
 
@@ -21,15 +21,36 @@ Types, presets, palette, shape paths, simulation, renderer, and plastic-material
21
21
 
22
22
  ```svelte
23
23
  <script lang="ts">
24
- import { GooeyRadialMenu } from 'libraries-dev-svelte';
24
+ import { GooeyRadialMenu, gooeyShadowPresets } from 'libraries-dev-svelte';
25
25
  </script>
26
26
 
27
- <GooeyRadialMenu blur={6} contrast={18} shadow="0 2px 6px rgba(0, 0, 0, .08)" />
27
+ <GooeyRadialMenu bare blur={6} contrast={18} shadow={gooeyShadowPresets.light.soft} />
28
28
  ```
29
29
 
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.
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
31
 
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.
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), `onAction` (optional callback for `'new-file'`, `'add-image'`, or `'new-folder'`), `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.
33
54
 
34
55
  ## Demo routes
35
56
 
@@ -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,14 +1,14 @@
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, 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
+ onAction,
12
12
  pro = false,
13
13
  bare = false,
14
14
  }: GooeyRadialMenuProps & { fill?: string } = $props();
@@ -16,9 +16,9 @@
16
16
  const instanceId = $props.id();
17
17
  const filterId = `gooey-${instanceId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
18
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' },
19
+ { label: 'New file', action: 'new-file', x: -54, y: -34, icon: 'file' },
20
+ { label: 'Add image', action: 'add-image', x: 0, y: -64, icon: 'image' },
21
+ { label: 'New folder', action: 'new-folder', x: 54, y: -34, icon: 'folder' },
22
22
  ] as const;
23
23
  const defaults: GooeyMenuSettings = {
24
24
  openDur: 550,
@@ -58,10 +58,10 @@
58
58
  let menuSettings = $state<GooeyMenuSettings>({ ...defaults });
59
59
  let copied = $state(false);
60
60
  let reducedMotion = $state(false);
61
- let mounted = $state(false);
62
61
  let groupElement: HTMLDivElement;
63
62
  let itemWrappers: Array<HTMLDivElement | undefined> = [];
64
- let blobElements: Array<SVGRectElement | undefined> = [];
63
+ let satelliteButtons: Array<HTMLButtonElement | undefined> = [];
64
+ let mainButton: HTMLButtonElement;
65
65
  let itemBoxes = $state<Array<BlobBox | null>>(initialBoxes);
66
66
  let groupSize = $state({ w: 0, h: 0 });
67
67
 
@@ -104,10 +104,10 @@
104
104
  },
105
105
  );
106
106
 
107
- let positions = allItems.map(() => ({ x: 0, y: 0 }));
108
- let frameIds = allItems.map(() => 0);
109
107
  let anticipationTimer: ReturnType<typeof setTimeout> | undefined;
110
108
  let copyTimer: ReturnType<typeof setTimeout> | undefined;
109
+ let anticipationFrame = 0;
110
+ let focusFrame = 0;
111
111
 
112
112
  function parseCorner(value: string, size: number): number {
113
113
  const first = value.split(' ')[0];
@@ -165,67 +165,46 @@
165
165
  }
166
166
  }
167
167
 
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 };
168
+ function transformFor(index: number) {
169
+ const item = allItems[index];
170
+ if (!menuOpen || !item || (!item.x && !item.y)) return 'none';
171
+ return `translate(${item.x * menuSettings.spread}px, ${item.y * menuSettings.spread}px)`;
173
172
  }
174
173
 
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
174
  function toggle() {
175
+ const opening = !menuOpen;
222
176
  if (menuOpen && menuSettings.anticipDist > 0) {
223
177
  if (anticipationTimer) clearTimeout(anticipationTimer);
178
+ if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
224
179
  anticipating = false;
225
- requestAnimationFrame(() => (anticipating = true));
180
+ anticipationFrame = requestAnimationFrame(() => {
181
+ anticipationFrame = 0;
182
+ anticipating = true;
183
+ });
226
184
  anticipationTimer = setTimeout(() => (anticipating = false), menuSettings.anticipDur);
227
185
  }
228
- menuOpen = !menuOpen;
186
+ menuOpen = opening;
187
+ if (focusFrame) cancelAnimationFrame(focusFrame);
188
+ if (opening) {
189
+ focusFrame = requestAnimationFrame(() => {
190
+ focusFrame = 0;
191
+ if (menuOpen) satelliteButtons[0]?.focus();
192
+ });
193
+ } else {
194
+ mainButton?.focus();
195
+ }
196
+ }
197
+
198
+ function activateSatellite(action: GooeyMenuAction) {
199
+ toggle();
200
+ onAction?.(action);
201
+ }
202
+
203
+ function handleEscape(event: KeyboardEvent) {
204
+ if (event.key === 'Escape' && menuOpen) {
205
+ event.preventDefault();
206
+ toggle();
207
+ }
229
208
  }
230
209
 
231
210
  function updateSetting<K extends keyof GooeyMenuSettings>(key: K, value: GooeyMenuSettings[K]) {
@@ -260,8 +239,6 @@
260
239
  reducedMotion = motionQuery.matches;
261
240
  const updateMotionPreference = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
262
241
  motionQuery.addEventListener('change', updateMotionPreference);
263
- mounted = true;
264
-
265
242
  return () => {
266
243
  observer.disconnect();
267
244
  motionQuery.removeEventListener('change', updateMotionPreference);
@@ -271,7 +248,8 @@
271
248
  onDestroy(() => {
272
249
  if (anticipationTimer) clearTimeout(anticipationTimer);
273
250
  if (copyTimer) clearTimeout(copyTimer);
274
- frameIds.forEach((frame) => frame && cancelAnimationFrame(frame));
251
+ if (anticipationFrame) cancelAnimationFrame(anticipationFrame);
252
+ if (focusFrame) cancelAnimationFrame(focusFrame);
275
253
  });
276
254
  </script>
277
255
 
@@ -279,13 +257,13 @@
279
257
  <div
280
258
  class={`pm${menuOpen ? ' pm-open' : ''}${anticipating ? ' pm-anticipating' : ''}`}
281
259
  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;`}
260
+ 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
261
  >
284
262
  <svg
285
263
  aria-hidden="true"
286
264
  focusable="false"
287
265
  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};` : ''}`}
266
+ style={`position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:-1;${cssShadowFilter ? `filter:${cssShadowFilter};` : ''}`}
289
267
  >
290
268
  <defs>
291
269
  <filter
@@ -409,11 +387,10 @@
409
387
  {/if}
410
388
  </filter>
411
389
  </defs>
412
- <g id={`${filterId}-sil`} filter={`url(#${filterId})`} style={`fill:${fill};`}>
390
+ <g id={`${filterId}-sil`} class="pm-blobs" filter={`url(#${filterId})`} style={`fill:${fill};`}>
413
391
  {#each itemBoxes as box, index (index)}
414
392
  {#if box}
415
393
  <rect
416
- bind:this={blobElements[index]}
417
394
  x={box.x}
418
395
  y={box.y}
419
396
  width={box.w}
@@ -421,7 +398,7 @@
421
398
  rx={box.r[0] === box.r[1] && box.r[1] === box.r[2] && box.r[2] === box.r[3]
422
399
  ? Math.max(0, Math.min(box.r[0], Math.min(box.w, box.h) / 2))
423
400
  : undefined}
424
- style={`transform:translate(${positions[index].x}px,${positions[index].y}px);transform-box:fill-box;transform-origin:center;will-change:transform;`}
401
+ style={`--pm-transition-delay:${reducedMotion || index >= satellites.length ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}
425
402
  />
426
403
  {/if}
427
404
  {/each}
@@ -429,13 +406,16 @@
429
406
  </svg>
430
407
 
431
408
  {#each satellites as satellite, index (satellite.label)}
432
- <div bind:this={itemWrappers[index]} class="pm-slot" style="display:inline-block;will-change:transform;">
409
+ <div bind:this={itemWrappers[index]} class="pm-slot" style={`--pm-transition-delay:${reducedMotion ? 0 : index * phase.stagger}ms;transform:${transformFor(index)};`}>
433
410
  <button
434
411
  type="button"
435
412
  class="pm-btn pm-sat"
436
413
  aria-label={satellite.label}
437
414
  tabindex={menuOpen ? 0 : -1}
438
- onclick={toggle}
415
+ inert={!menuOpen}
416
+ bind:this={satelliteButtons[index]}
417
+ onclick={() => activateSatellite(satellite.action)}
418
+ onkeydown={handleEscape}
439
419
  >
440
420
  <span
441
421
  class="pm-sat-icon"
@@ -464,13 +444,15 @@
464
444
  </div>
465
445
  {/each}
466
446
 
467
- <div bind:this={itemWrappers[satellites.length]} class="pm-slot" style="display:inline-block;will-change:transform;">
447
+ <div bind:this={itemWrappers[satellites.length]} class="pm-slot" style={`transform:${transformFor(satellites.length)};`}>
468
448
  <button
449
+ bind:this={mainButton}
469
450
  type="button"
470
451
  class="pm-btn pm-main"
471
452
  aria-expanded={menuOpen}
472
453
  aria-label={menuOpen ? 'Close menu' : 'Open menu'}
473
454
  onclick={toggle}
455
+ onkeydown={handleEscape}
474
456
  >
475
457
  <span class="pm-plus">
476
458
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
@@ -597,9 +579,20 @@
597
579
  position: absolute;
598
580
  left: 80px;
599
581
  top: 80px;
582
+ display: inline-block;
583
+ transition: transform var(--pm-transition-duration, 0ms)
584
+ var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
585
+ }
586
+
587
+ .pm-blobs rect {
588
+ transform-box: fill-box;
589
+ transform-origin: center;
590
+ transition: transform var(--pm-transition-duration, 0ms)
591
+ var(--pm-transition-ease, ease) var(--pm-transition-delay, 0ms);
600
592
  }
601
593
 
602
594
  .pm-btn {
595
+ position: relative;
603
596
  width: 40px;
604
597
  height: 40px;
605
598
  border: 0;
@@ -611,6 +604,14 @@
611
604
  cursor: pointer;
612
605
  color: var(--btn-text, #17181c);
613
606
  -webkit-tap-highlight-color: transparent;
607
+ touch-action: manipulation;
608
+ }
609
+
610
+ .pm-btn::before {
611
+ content: '';
612
+ position: absolute;
613
+ inset: -2px;
614
+ border-radius: inherit;
614
615
  }
615
616
 
616
617
  .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;
@@ -6,6 +6,8 @@ export interface GooeyRadialMenuProps {
6
6
  contrast?: number;
7
7
  /** CSS box-shadow layers drawn on the merged liquid silhouette. */
8
8
  shadow?: string;
9
+ /** Called when a sample action is selected. The menu closes after activation. */
10
+ onAction?: (action: GooeyMenuAction) => void;
9
11
  /** Surface color for the merged liquid. Defaults to the host theme's modal color. */
10
12
  fill?: string;
11
13
  /** Show all raw tuning controls, export, and reset controls. */
@@ -13,6 +15,7 @@ export interface GooeyRadialMenuProps {
13
15
  /** Render only the menu stage for embedding in a host layout. */
14
16
  bare?: boolean;
15
17
  }
18
+ export type GooeyMenuAction = 'new-file' | 'add-image' | 'new-folder';
16
19
  export interface GooeyMenuSettings {
17
20
  openDur: number;
18
21
  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, 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.2.0",
4
4
  "description": "Svelte ports of Bot Avatars and the Gooey radial menu from Libraries.dev",
5
5
  "license": "MIT",
6
6
  "scripts": {