libraries-dev-svelte 0.1.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.
@@ -0,0 +1,747 @@
1
+ <script lang="ts">
2
+ import { onDestroy, onMount } from 'svelte';
3
+ import { easingFunction } from './motion';
4
+ import { parseShadow, type ShadowLayer } from './shadow';
5
+ import type { GooeyMenuSettings, GooeyRadialMenuProps } from './types';
6
+
7
+ let {
8
+ blur = 6,
9
+ contrast = 18,
10
+ fill = 'var(--modal-bg, #fff)',
11
+ shadow = '',
12
+ pro = false,
13
+ bare = false,
14
+ }: GooeyRadialMenuProps & { fill?: string } = $props();
15
+
16
+ const instanceId = $props.id();
17
+ 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;
23
+ const defaults: GooeyMenuSettings = {
24
+ openDur: 550,
25
+ openEase: 'Bouncy',
26
+ openStagger: 40,
27
+ closeDur: 250,
28
+ closeEase: 'Snappy',
29
+ closeStagger: 0,
30
+ spread: 1,
31
+ iconDur: 180,
32
+ iconDelay: 120,
33
+ anticipDist: 5,
34
+ anticipDur: 700,
35
+ };
36
+ const eases: Record<string, string> = {
37
+ Bouncy: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
38
+ Smooth: 'cubic-bezier(0.3, 1.05, 0.4, 1)',
39
+ Snappy: 'cubic-bezier(0.22, 1, 0.36, 1)',
40
+ 'Ease in-out': 'ease-in-out',
41
+ Linear: 'linear',
42
+ };
43
+ 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);
46
+
47
+ interface BlobBox {
48
+ x: number;
49
+ y: number;
50
+ w: number;
51
+ h: number;
52
+ r: [number, number, number, number];
53
+ }
54
+
55
+ const binarize = '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 60 -29.5';
56
+ let menuOpen = $state(false);
57
+ let anticipating = $state(false);
58
+ let menuSettings = $state<GooeyMenuSettings>({ ...defaults });
59
+ let copied = $state(false);
60
+ let reducedMotion = $state(false);
61
+ let mounted = $state(false);
62
+ let groupElement: HTMLDivElement;
63
+ let itemWrappers: Array<HTMLDivElement | undefined> = [];
64
+ let blobElements: Array<SVGRectElement | undefined> = [];
65
+ let itemBoxes = $state<Array<BlobBox | null>>(initialBoxes);
66
+ let groupSize = $state({ w: 0, h: 0 });
67
+
68
+ const shadows = $derived(parseShadow(shadow));
69
+ const shadowEntries = $derived(shadows.map((layer, index) => ({ layer, index })));
70
+ const svgShadows = $derived(
71
+ shadowEntries.filter(({ layer }) => layer.inset || layer.spread !== 0),
72
+ );
73
+ const cssShadowFilter = $derived(
74
+ shadowEntries
75
+ .filter(({ layer }) => !layer.inset && layer.spread === 0)
76
+ .map(({ layer }) => `drop-shadow(${layer.x}px ${layer.y}px ${layer.blur}px ${layer.color})`)
77
+ .join(' '),
78
+ );
79
+ const shadowExtent = $derived(
80
+ svgShadows.reduce(
81
+ (max, { layer }) =>
82
+ Math.max(
83
+ max,
84
+ Math.max(Math.abs(layer.x), Math.abs(layer.y)) +
85
+ layer.blur * 1.5 +
86
+ Math.max(0, layer.spread),
87
+ ),
88
+ 0,
89
+ ),
90
+ );
91
+ const filterPad = $derived(Math.ceil(blur * 3 + shadowExtent + 24));
92
+ const intercept = $derived(Math.round((0.5 - contrast * (5 / 12)) * 100) / 100);
93
+ const phase = $derived(
94
+ menuOpen
95
+ ? {
96
+ duration: menuSettings.openDur,
97
+ ease: eases[menuSettings.openEase],
98
+ stagger: menuSettings.openStagger,
99
+ }
100
+ : {
101
+ duration: menuSettings.closeDur,
102
+ ease: eases[menuSettings.closeEase],
103
+ stagger: menuSettings.closeStagger,
104
+ },
105
+ );
106
+
107
+ let positions = allItems.map(() => ({ x: 0, y: 0 }));
108
+ let frameIds = allItems.map(() => 0);
109
+ let anticipationTimer: ReturnType<typeof setTimeout> | undefined;
110
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
111
+
112
+ function parseCorner(value: string, size: number): number {
113
+ const first = value.split(' ')[0];
114
+ return first.endsWith('%')
115
+ ? ((parseFloat(first) || 0) / 100) * size
116
+ : parseFloat(first) || 0;
117
+ }
118
+
119
+ function offsetTo(element: HTMLElement, ancestor: HTMLElement) {
120
+ let x = 0;
121
+ let y = 0;
122
+ let node: HTMLElement | null = element;
123
+ while (node && node !== ancestor && ancestor.contains(node)) {
124
+ x += node.offsetLeft;
125
+ y += node.offsetTop;
126
+ node = node.offsetParent as HTMLElement | null;
127
+ }
128
+ return { x, y };
129
+ }
130
+
131
+ function sameBox(left: BlobBox | null, right: BlobBox) {
132
+ return !!left && left.x === right.x && left.y === right.y && left.w === right.w &&
133
+ left.h === right.h && left.r.every((radius, index) => radius === right.r[index]);
134
+ }
135
+
136
+ function measure() {
137
+ if (!groupElement) return;
138
+ const w = groupElement.offsetWidth;
139
+ const h = groupElement.offsetHeight;
140
+ if (groupSize.w !== w || groupSize.h !== h) groupSize = { w, h };
141
+
142
+ const measured = itemWrappers.map((wrapper, index) => {
143
+ if (!wrapper) return itemBoxes[index];
144
+ const offset = offsetTo(wrapper, groupElement);
145
+ const boxWidth = wrapper.offsetWidth;
146
+ const boxHeight = wrapper.offsetHeight;
147
+ const target = (wrapper.firstElementChild as HTMLElement | null) ?? wrapper;
148
+ const computed = getComputedStyle(target);
149
+ const size = Math.min(boxWidth, boxHeight);
150
+ return {
151
+ x: offset.x,
152
+ y: offset.y,
153
+ w: boxWidth,
154
+ h: boxHeight,
155
+ r: [
156
+ parseCorner(computed.borderTopLeftRadius, size),
157
+ parseCorner(computed.borderTopRightRadius, size),
158
+ parseCorner(computed.borderBottomRightRadius, size),
159
+ parseCorner(computed.borderBottomLeftRadius, size),
160
+ ] as [number, number, number, number],
161
+ };
162
+ });
163
+ if (measured.some((box, index) => !box || !sameBox(itemBoxes[index], box))) {
164
+ itemBoxes = measured;
165
+ }
166
+ }
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 };
173
+ }
174
+
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
+ function toggle() {
222
+ if (menuOpen && menuSettings.anticipDist > 0) {
223
+ if (anticipationTimer) clearTimeout(anticipationTimer);
224
+ anticipating = false;
225
+ requestAnimationFrame(() => (anticipating = true));
226
+ anticipationTimer = setTimeout(() => (anticipating = false), menuSettings.anticipDur);
227
+ }
228
+ menuOpen = !menuOpen;
229
+ }
230
+
231
+ function updateSetting<K extends keyof GooeyMenuSettings>(key: K, value: GooeyMenuSettings[K]) {
232
+ menuSettings[key] = value;
233
+ }
234
+
235
+ function menuSnippet(settings: GooeyMenuSettings): string {
236
+ return [
237
+ '// liquid-gooey — Menu values',
238
+ `open: { duration: ${settings.openDur}, easing: '${eases[settings.openEase]}', stagger: ${settings.openStagger} },`,
239
+ `close: { duration: ${settings.closeDur}, easing: '${eases[settings.closeEase]}', stagger: ${settings.closeStagger} },`,
240
+ `anticipation: { distance: ${settings.anticipDist}, duration: ${settings.anticipDur} },`,
241
+ `spread: ${settings.spread}, iconFade: ${settings.iconDur}, iconDelay: ${settings.iconDelay},`,
242
+ ].join('\n');
243
+ }
244
+
245
+ function copyValues() {
246
+ navigator.clipboard.writeText(menuSnippet(menuSettings)).then(() => {
247
+ copied = true;
248
+ if (copyTimer) clearTimeout(copyTimer);
249
+ copyTimer = setTimeout(() => (copied = false), 1400);
250
+ });
251
+ }
252
+
253
+ onMount(() => {
254
+ measure();
255
+ const observer = new ResizeObserver(measure);
256
+ observer.observe(groupElement);
257
+ itemWrappers.forEach((wrapper) => wrapper && observer.observe(wrapper));
258
+
259
+ const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
260
+ reducedMotion = motionQuery.matches;
261
+ const updateMotionPreference = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
262
+ motionQuery.addEventListener('change', updateMotionPreference);
263
+ mounted = true;
264
+
265
+ return () => {
266
+ observer.disconnect();
267
+ motionQuery.removeEventListener('change', updateMotionPreference);
268
+ };
269
+ });
270
+
271
+ onDestroy(() => {
272
+ if (anticipationTimer) clearTimeout(anticipationTimer);
273
+ if (copyTimer) clearTimeout(copyTimer);
274
+ frameIds.forEach((frame) => frame && cancelAnimationFrame(frame));
275
+ });
276
+ </script>
277
+
278
+ {#snippet menuStage()}
279
+ <div
280
+ class={`pm${menuOpen ? ' pm-open' : ''}${anticipating ? ' pm-anticipating' : ''}`}
281
+ 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;`}
283
+ >
284
+ <svg
285
+ aria-hidden="true"
286
+ focusable="false"
287
+ 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};` : ''}`}
289
+ >
290
+ <defs>
291
+ <filter
292
+ id={filterId}
293
+ filterUnits="userSpaceOnUse"
294
+ x={-filterPad}
295
+ y={-filterPad}
296
+ width={groupSize.w + filterPad * 2}
297
+ height={groupSize.h + filterPad * 2}
298
+ color-interpolation-filters="sRGB"
299
+ >
300
+ <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="blur" />
301
+ <feColorMatrix
302
+ in="blur"
303
+ type="matrix"
304
+ values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${contrast} ${intercept}`}
305
+ result="goo"
306
+ />
307
+ <feComposite in="SourceGraphic" in2="goo" operator="atop" result="shape" />
308
+ {#if svgShadows.some(({ layer }) => layer.inset || layer.spread !== 0)}
309
+ <feColorMatrix in="shape" type="matrix" values={binarize} result="bin" />
310
+ {/if}
311
+ {#each svgShadows as entry (entry.index)}
312
+ {@const layer = entry.layer}
313
+ {@const suffix = `s${entry.index}`}
314
+ {#if layer.inset}
315
+ {@const eroded = layer.spread !== 0}
316
+ {@const shifted = layer.x !== 0 || layer.y !== 0}
317
+ {@const softened = layer.blur > 0}
318
+ {#if eroded}
319
+ <feMorphology
320
+ in="bin"
321
+ operator={layer.spread > 0 ? 'erode' : 'dilate'}
322
+ radius={Math.abs(layer.spread)}
323
+ result={`${suffix}-er`}
324
+ />
325
+ {/if}
326
+ {#if shifted}
327
+ <feOffset
328
+ in={softened ? eroded ? `${suffix}-er` : 'bin' : eroded ? `${suffix}-er` : 'bin'}
329
+ dx={layer.x}
330
+ dy={layer.y}
331
+ result={`${suffix}-o`}
332
+ />
333
+ {/if}
334
+ {#if softened}
335
+ <feGaussianBlur
336
+ in={shifted ? `${suffix}-o` : eroded ? `${suffix}-er` : 'bin'}
337
+ stdDeviation={layer.blur / 2}
338
+ result={`${suffix}-b`}
339
+ />
340
+ {/if}
341
+ {@const shapeSource = softened
342
+ ? `${suffix}-b`
343
+ : shifted
344
+ ? `${suffix}-o`
345
+ : eroded
346
+ ? `${suffix}-er`
347
+ : 'bin'}
348
+ <feComposite in="bin" in2={shapeSource} operator="out" result={`${suffix}-band`} />
349
+ <feFlood flood-color={layer.color} result={`${suffix}-c`} />
350
+ <feComposite
351
+ in={`${suffix}-c`}
352
+ in2={`${suffix}-band`}
353
+ operator="in"
354
+ result={suffix}
355
+ />
356
+ {:else}
357
+ {@const spread = layer.spread !== 0}
358
+ {@const softened = layer.blur > 0}
359
+ {@const shifted = layer.x !== 0 || layer.y !== 0}
360
+ {#if spread}
361
+ <feMorphology
362
+ in="bin"
363
+ operator={layer.spread > 0 ? 'dilate' : 'erode'}
364
+ radius={Math.abs(layer.spread)}
365
+ result={`${suffix}-sp`}
366
+ />
367
+ {/if}
368
+ {#if softened}
369
+ <feGaussianBlur
370
+ in={spread ? `${suffix}-sp` : 'shape'}
371
+ stdDeviation={layer.blur / 2}
372
+ result={`${suffix}-b`}
373
+ />
374
+ {/if}
375
+ {#if shifted}
376
+ <feOffset
377
+ in={softened ? `${suffix}-b` : spread ? `${suffix}-sp` : 'shape'}
378
+ dx={layer.x}
379
+ dy={layer.y}
380
+ result={`${suffix}-o`}
381
+ />
382
+ {/if}
383
+ {@const shadowSource = shifted
384
+ ? `${suffix}-o`
385
+ : softened
386
+ ? `${suffix}-b`
387
+ : spread
388
+ ? `${suffix}-sp`
389
+ : 'shape'}
390
+ <feFlood flood-color={layer.color} result={`${suffix}-c`} />
391
+ <feComposite
392
+ in={`${suffix}-c`}
393
+ in2={shadowSource}
394
+ operator="in"
395
+ result={suffix}
396
+ />
397
+ {/if}
398
+ {/each}
399
+ {#if svgShadows.length > 0}
400
+ <feMerge>
401
+ {#each svgShadows.filter(({ layer }) => !layer.inset).slice().reverse() as entry (entry.index)}
402
+ <feMergeNode in={`s${entry.index}`} />
403
+ {/each}
404
+ <feMergeNode in="shape" />
405
+ {#each svgShadows.filter(({ layer }) => layer.inset) as entry (entry.index)}
406
+ <feMergeNode in={`s${entry.index}`} />
407
+ {/each}
408
+ </feMerge>
409
+ {/if}
410
+ </filter>
411
+ </defs>
412
+ <g id={`${filterId}-sil`} filter={`url(#${filterId})`} style={`fill:${fill};`}>
413
+ {#each itemBoxes as box, index (index)}
414
+ {#if box}
415
+ <rect
416
+ bind:this={blobElements[index]}
417
+ x={box.x}
418
+ y={box.y}
419
+ width={box.w}
420
+ height={box.h}
421
+ rx={box.r[0] === box.r[1] && box.r[1] === box.r[2] && box.r[2] === box.r[3]
422
+ ? Math.max(0, Math.min(box.r[0], Math.min(box.w, box.h) / 2))
423
+ : undefined}
424
+ style={`transform:translate(${positions[index].x}px,${positions[index].y}px);transform-box:fill-box;transform-origin:center;will-change:transform;`}
425
+ />
426
+ {/if}
427
+ {/each}
428
+ </g>
429
+ </svg>
430
+
431
+ {#each satellites as satellite, index (satellite.label)}
432
+ <div bind:this={itemWrappers[index]} class="pm-slot" style="display:inline-block;will-change:transform;">
433
+ <button
434
+ type="button"
435
+ class="pm-btn pm-sat"
436
+ aria-label={satellite.label}
437
+ tabindex={menuOpen ? 0 : -1}
438
+ onclick={toggle}
439
+ >
440
+ <span
441
+ class="pm-sat-icon"
442
+ style:transition-delay={menuOpen
443
+ ? `${menuSettings.iconDelay + index * phase.stagger}ms`
444
+ : '0ms'}
445
+ >
446
+ {#if satellite.icon === 'file'}
447
+ <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
+ <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
+ <path d="M9 1.5V6h4.5" />
450
+ </svg>
451
+ {:else if satellite.icon === 'image'}
452
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
453
+ <rect x="1.5" y="1.5" width="13" height="13" rx="2" />
454
+ <circle cx="5.5" cy="5.5" r="1.25" />
455
+ <path d="M14.5 10.5L11 7l-7.5 7.5" />
456
+ </svg>
457
+ {:else}
458
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
459
+ <path d="M14.5 12.5A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5V3A1.5 1.5 0 0 1 3 1.5h3L7.5 4H13a1.5 1.5 0 0 1 1.5 1.5z" />
460
+ </svg>
461
+ {/if}
462
+ </span>
463
+ </button>
464
+ </div>
465
+ {/each}
466
+
467
+ <div bind:this={itemWrappers[satellites.length]} class="pm-slot" style="display:inline-block;will-change:transform;">
468
+ <button
469
+ type="button"
470
+ class="pm-btn pm-main"
471
+ aria-expanded={menuOpen}
472
+ aria-label={menuOpen ? 'Close menu' : 'Open menu'}
473
+ onclick={toggle}
474
+ >
475
+ <span class="pm-plus">
476
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round">
477
+ <path d="M10 4V16M4 10H16" />
478
+ </svg>
479
+ </span>
480
+ </button>
481
+ </div>
482
+ </div>
483
+ {/snippet}
484
+
485
+ {#if bare}
486
+ {@render menuStage()}
487
+ {:else}
488
+ <div class="pm-wrap">
489
+ <div class="stage">{@render menuStage()}</div>
490
+
491
+ {#if !pro}
492
+ <div class="cp-panel">
493
+ <div class="cp-section">
494
+ <div class="cp-section-head">
495
+ <span class="cp-section-title">Morph</span>
496
+ </div>
497
+ <div class="cp-row">
498
+ <span class="cp-label">Duration (ms)</span>
499
+ <span class="cp-slider">
500
+ <input type="range" min="80" max="1200" step="10" value={menuSettings.openDur} oninput={(event) => updateSetting('openDur', Number(event.currentTarget.value))} />
501
+ <span class="cp-val">{menuSettings.openDur}</span>
502
+ </span>
503
+ </div>
504
+ <div class="cp-row">
505
+ <span class="cp-label">Stagger (ms)</span>
506
+ <span class="cp-slider">
507
+ <input type="range" min="0" max="200" step="5" value={menuSettings.openStagger} oninput={(event) => updateSetting('openStagger', Number(event.currentTarget.value))} />
508
+ <span class="cp-val">{menuSettings.openStagger}</span>
509
+ </span>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ {:else}
514
+ <div class="cp-panel">
515
+ <div class="cp-section">
516
+ <div class="cp-section-head">
517
+ <span class="cp-section-title">Menu controls</span>
518
+ <span class="cp-actions">
519
+ <button type="button" class={`cp-export${copied ? ' is-copied' : ''}`} onclick={copyValues}>{copied ? 'Copied' : 'Copy values'}</button>
520
+ <button type="button" class="cp-export" onclick={() => (menuSettings = { ...defaults })}>Reset</button>
521
+ </span>
522
+ </div>
523
+ <div class="cp-subhead">Open</div>
524
+ <div class="cp-row">
525
+ <span class="cp-label">Duration (ms)</span>
526
+ <span class="cp-slider"><input type="range" min="80" max="1200" step="10" value={menuSettings.openDur} oninput={(event) => updateSetting('openDur', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.openDur}</span></span>
527
+ </div>
528
+ <div class="cp-row">
529
+ <span class="cp-label">Easing</span>
530
+ <select class="cp-ease" value={menuSettings.openEase} onchange={(event) => updateSetting('openEase', event.currentTarget.value)}>
531
+ {#each easeNames as name}<option>{name}</option>{/each}
532
+ </select>
533
+ </div>
534
+ <div class="cp-row">
535
+ <span class="cp-label">Stagger (ms)</span>
536
+ <span class="cp-slider"><input type="range" min="0" max="200" step="5" value={menuSettings.openStagger} oninput={(event) => updateSetting('openStagger', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.openStagger}</span></span>
537
+ </div>
538
+ <div class="cp-subhead">Close</div>
539
+ <div class="cp-row">
540
+ <span class="cp-label">Duration (ms)</span>
541
+ <span class="cp-slider"><input type="range" min="80" max="1200" step="10" value={menuSettings.closeDur} oninput={(event) => updateSetting('closeDur', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.closeDur}</span></span>
542
+ </div>
543
+ <div class="cp-row">
544
+ <span class="cp-label">Easing</span>
545
+ <select class="cp-ease" value={menuSettings.closeEase} onchange={(event) => updateSetting('closeEase', event.currentTarget.value)}>
546
+ {#each easeNames as name}<option>{name}</option>{/each}
547
+ </select>
548
+ </div>
549
+ <div class="cp-row">
550
+ <span class="cp-label">Stagger (ms)</span>
551
+ <span class="cp-slider"><input type="range" min="0" max="200" step="5" value={menuSettings.closeStagger} oninput={(event) => updateSetting('closeStagger', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.closeStagger}</span></span>
552
+ </div>
553
+ <div class="cp-subhead">Anticipation (on close)</div>
554
+ <div class="cp-row">
555
+ <span class="cp-label">Distance (px)</span>
556
+ <span class="cp-slider"><input type="range" min="0" max="24" step="1" value={menuSettings.anticipDist} oninput={(event) => updateSetting('anticipDist', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.anticipDist}</span></span>
557
+ </div>
558
+ <div class="cp-row">
559
+ <span class="cp-label">Duration (ms)</span>
560
+ <span class="cp-slider"><input type="range" min="120" max="1400" step="20" value={menuSettings.anticipDur} oninput={(event) => updateSetting('anticipDur', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.anticipDur}</span></span>
561
+ </div>
562
+ <div class="cp-subhead">Layout &amp; icons</div>
563
+ <div class="cp-row">
564
+ <span class="cp-label">Spread</span>
565
+ <span class="cp-slider"><input type="range" min="0.4" max="2" step="0.05" value={menuSettings.spread} oninput={(event) => updateSetting('spread', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.spread}</span></span>
566
+ </div>
567
+ <div class="cp-row">
568
+ <span class="cp-label">Icon fade (ms)</span>
569
+ <span class="cp-slider"><input type="range" min="0" max="600" step="10" value={menuSettings.iconDur} oninput={(event) => updateSetting('iconDur', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.iconDur}</span></span>
570
+ </div>
571
+ <div class="cp-row">
572
+ <span class="cp-label">Icon delay (ms)</span>
573
+ <span class="cp-slider"><input type="range" min="0" max="600" step="10" value={menuSettings.iconDelay} oninput={(event) => updateSetting('iconDelay', Number(event.currentTarget.value))} /><span class="cp-val">{menuSettings.iconDelay}</span></span>
574
+ </div>
575
+ </div>
576
+ </div>
577
+ {/if}
578
+ </div>
579
+ {/if}
580
+
581
+ <style>
582
+ .stage {
583
+ display: grid;
584
+ place-items: center;
585
+ height: 280px;
586
+ background: var(--stage-bg, rgba(225, 225, 225, 0.3));
587
+ border-radius: 10px;
588
+ overflow: hidden;
589
+ }
590
+
591
+ .pm {
592
+ width: 200px;
593
+ height: 140px;
594
+ }
595
+
596
+ .pm-slot {
597
+ position: absolute;
598
+ left: 80px;
599
+ top: 80px;
600
+ }
601
+
602
+ .pm-btn {
603
+ width: 40px;
604
+ height: 40px;
605
+ border: 0;
606
+ padding: 0;
607
+ border-radius: 50%;
608
+ background: transparent;
609
+ display: grid;
610
+ place-items: center;
611
+ cursor: pointer;
612
+ color: var(--btn-text, #17181c);
613
+ -webkit-tap-highlight-color: transparent;
614
+ }
615
+
616
+ .pm-btn:focus-visible {
617
+ outline: 2px solid var(--accent, #17181c);
618
+ outline-offset: 2px;
619
+ }
620
+
621
+ .pm-sat { pointer-events: none; }
622
+ .pm-open .pm-sat { pointer-events: auto; }
623
+ .pm-open .pm-sat:hover { background: var(--hover-veil, rgba(0, 0, 0, 0.04)); }
624
+
625
+ .pm-sat-icon {
626
+ display: grid;
627
+ place-items: center;
628
+ opacity: 0;
629
+ filter: blur(2px);
630
+ transition: opacity 120ms ease, filter 120ms ease;
631
+ }
632
+
633
+ .pm-open .pm-sat-icon {
634
+ opacity: 1;
635
+ filter: blur(0);
636
+ transition-duration: var(--pm-icon-dur, 180ms);
637
+ }
638
+
639
+ @keyframes pm-anticipate {
640
+ 0% { transform: translateY(0); }
641
+ 30% { transform: translateY(var(--pm-anticip, 5px)); }
642
+ 100% { transform: translateY(0); }
643
+ }
644
+
645
+ .pm-anticipating [data-gooey-svg],
646
+ .pm-anticipating .pm-main {
647
+ animation-name: pm-anticipate;
648
+ animation-duration: var(--pm-anticip-dur, 700ms);
649
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
650
+ animation-fill-mode: both;
651
+ }
652
+
653
+ .pm-wrap { display: flex; flex-direction: column; }
654
+
655
+ .pm-plus {
656
+ display: grid;
657
+ place-items: center;
658
+ transform: rotate(0deg);
659
+ transition: transform 250ms ease-in-out;
660
+ }
661
+
662
+ .pm-open .pm-plus { transform: rotate(45deg); }
663
+
664
+ .cp-panel {
665
+ margin-top: 14px;
666
+ background: var(--panel-bg);
667
+ border: 1px solid var(--panel-border);
668
+ border-radius: 12px;
669
+ -webkit-font-smoothing: antialiased;
670
+ }
671
+
672
+ .cp-section { padding: 14px 16px 16px; }
673
+ .cp-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
674
+ .cp-section-title {
675
+ font-family: 'Roboto Mono', ui-monospace, monospace;
676
+ font-size: 11px;
677
+ font-weight: 500;
678
+ color: var(--text);
679
+ text-transform: uppercase;
680
+ letter-spacing: 0.06em;
681
+ opacity: 0.72;
682
+ }
683
+
684
+ .cp-export {
685
+ appearance: none;
686
+ border: 0;
687
+ background: var(--accent);
688
+ color: var(--on-accent);
689
+ font-family: inherit;
690
+ font-size: 11px;
691
+ font-weight: 500;
692
+ height: 22px;
693
+ padding: 0 10px;
694
+ border-radius: 5px;
695
+ cursor: pointer;
696
+ white-space: nowrap;
697
+ flex: none;
698
+ transition: background-color 120ms ease;
699
+ }
700
+
701
+ .cp-export:hover { background: var(--accent); opacity: 0.82; }
702
+ .cp-export.is-copied { background: #178a4a; }
703
+ .cp-actions { display: flex; align-items: center; gap: 6px; }
704
+ .cp-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; padding: 3px 0; }
705
+ .cp-subhead { margin: 14px 0 2px; font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--label); }
706
+ .cp-subhead:first-of-type { margin-top: 4px; }
707
+ .cp-label { color: var(--muted); font-size: 11.5px; line-height: 1.3; font-weight: 400; user-select: none; }
708
+
709
+ .cp-row input,
710
+ .cp-row select {
711
+ width: 86px;
712
+ height: 24px;
713
+ padding: 0 6px;
714
+ border: 1px solid var(--ctl-border);
715
+ border-radius: 4px;
716
+ font-family: 'Roboto Mono', ui-monospace, monospace;
717
+ font-size: 11px;
718
+ color: var(--text);
719
+ background: var(--field-bg);
720
+ text-align: right;
721
+ -moz-appearance: textfield;
722
+ appearance: textfield;
723
+ }
724
+
725
+ .cp-row input:focus,
726
+ .cp-row select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
727
+ .cp-slider { display: inline-flex; align-items: center; gap: 8px; }
728
+ .cp-slider input[type='range'] { width: 110px; height: 24px; margin: 0; padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
729
+ .cp-slider .cp-val { font-family: 'Roboto Mono', ui-monospace, monospace; font-size: 11px; color: var(--text); min-width: 34px; text-align: right; }
730
+ .cp-row select.cp-ease {
731
+ width: 128px;
732
+ text-align: left;
733
+ padding-right: 4px;
734
+ cursor: pointer;
735
+ appearance: none;
736
+ -webkit-appearance: none;
737
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
738
+ background-position: calc(100% - 10px) 10px, calc(100% - 6px) 10px;
739
+ background-size: 4px 4px, 4px 4px;
740
+ background-repeat: no-repeat;
741
+ }
742
+
743
+ @media (prefers-reduced-motion: reduce) {
744
+ .pm-plus, .pm-sat-icon { transition: none !important; }
745
+ .pm-anticipating [data-gooey-svg], .pm-anticipating .pm-main { animation: none !important; }
746
+ }
747
+ </style>