fancy-ui-svelte 0.9.0 → 0.9.1

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,629 @@
1
+ <script lang="ts" module>
2
+ import type { FireworksHandle } from "./fireworks-shared.js";
3
+
4
+ /** Props for {@link FireworksHdr}. */
5
+ export interface FireworksHdrProps {
6
+ /**
7
+ * Brand hues (hex). Order is irrelevant — they are parsed and sorted into
8
+ * the canonical cool→warm ping-pong order (cyan, blue, violet, magenta)
9
+ * so the shell sweep is stable however they are listed.
10
+ */
11
+ palette?: string[];
12
+ /**
13
+ * Opt into the GPU engine: WebGPU HDR first, then a WebGL2 fallback
14
+ * (display-p3 SDR where supported, else plain sRGB). When false, no engine
15
+ * boots at all.
16
+ */
17
+ hdr?: boolean;
18
+ /** Display exposure multiplier, clamped [1,4]. */
19
+ exposure?: number;
20
+ /** Run the ambient auto-scheduler. */
21
+ ambient?: boolean;
22
+ /** Ambient intensity [0,1] — scales shell size/energy. */
23
+ ambientIntensity?: number;
24
+ /** Launch a shell toward the pointer on window pointerdown. */
25
+ interactive?: boolean;
26
+ /** Quality tier, or "auto" to pick from the render level + DPR. */
27
+ quality?: "auto" | "high" | "mid" | "low";
28
+ /**
29
+ * Which shells the ambient scheduler is allowed to fire, picked uniformly.
30
+ * Defaults to the weighted peony/willow/ring mix. Pattern shells ("heart",
31
+ * "star") are valid here; "glyph" and "shape" are not — they need points
32
+ * the scheduler has no way to invent, so they are ignored if listed.
33
+ */
34
+ ambientShells?: ShellKind[];
35
+ /** Force ambient off under prefers-reduced-motion (launches still work). */
36
+ respectReducedMotion?: boolean;
37
+ /** Extra classes on the canvas wrapper. */
38
+ class?: string;
39
+ /**
40
+ * Called when the engine is live, with an imperative handle. Never called
41
+ * when no GPU renderer comes up (the caller uses its own timeout). Called
42
+ * again after a GPU context loss is recovered, with a fresh handle whose
43
+ * `renderLevel` reflects the engine that came back (a lost WebGPU device
44
+ * can return as the WebGL2 fallback).
45
+ */
46
+ onReady?: (handle: FireworksHandle) => void;
47
+ /**
48
+ * Called once when the GPU context is lost and cannot be brought back.
49
+ * The component has torn itself down by then: the loop is stopped, the
50
+ * listeners are gone, and any handle it handed out is inert. Use it to
51
+ * swap in a static fallback.
52
+ */
53
+ onLost?: () => void;
54
+ }
55
+ </script>
56
+
57
+ <script lang="ts">
58
+ import { cn } from "../../utils.js";
59
+ import { onMount } from "svelte";
60
+ import {
61
+ createSim,
62
+ hexToLinearRgb,
63
+ linearToOklab,
64
+ nextShellHue,
65
+ shellHueColor,
66
+ poissonIntervalMs,
67
+ sampleZone,
68
+ rectExpandedContains,
69
+ resolveQualityTier,
70
+ createAdaptiveState,
71
+ adaptiveDowngradeStep,
72
+ adaptiveLevel,
73
+ AMBIENT_ZONES,
74
+ AMBIENT_RADIUS_MAX,
75
+ KEEP_CLEAR_DESKTOP,
76
+ QUALITY,
77
+ SHELL,
78
+ SHELL_JITTER_DEG,
79
+ EXPOSURE_AMBIENT,
80
+ type Sim,
81
+ type Rgb,
82
+ type Rect,
83
+ type ShellKind,
84
+ type FireworksRenderLevel,
85
+ type LaunchOptions,
86
+ type LaunchResult,
87
+ } from "./fireworks-shared.js";
88
+ import { startWebGpuFireworks, type FireworksEngineHandle } from "./webgpu-renderer.js";
89
+ import { startWebGl2Fireworks } from "./webgl2-renderer.js";
90
+
91
+ let {
92
+ palette = ["#ff2fd6", "#a142ff", "#3d5bff", "#42cfff"],
93
+ hdr = true,
94
+ exposure = EXPOSURE_AMBIENT,
95
+ ambient = true,
96
+ ambientIntensity = 0.35,
97
+ interactive = true,
98
+ quality = "auto",
99
+ ambientShells,
100
+ respectReducedMotion = true,
101
+ class: className = "",
102
+ onReady,
103
+ onLost,
104
+ }: FireworksHdrProps = $props();
105
+
106
+ let canvasRef: HTMLCanvasElement;
107
+
108
+ // Largest instance buffer we might need — the engine is sized for the high
109
+ // tier up front so the resolved (auto) quality can be any tier ≤ high.
110
+ const MAX_INSTANCES = QUALITY.high.maxParticles;
111
+ const DT_CLAMP = 0.05; // guards against huge dt after a tab switch
112
+ // AD §4.3 / physics §6: "ambient 2–3 (rare 4)" — the scheduler's own cadence
113
+ // stays gated at 2; a mirrored double (§5.2) may push a third shell in
114
+ // flight, but never a fourth.
115
+ const MAX_AMBIENT_INFLIGHT_SOFT = 2;
116
+ const MAX_AMBIENT_INFLIGHT_HARD = 3;
117
+ // How long a lost WebGL context is given to announce its restoration before
118
+ // the component gives up and reports the failure through `onLost`.
119
+ const RESTORE_TIMEOUT_MS = 4000;
120
+
121
+ function clampExposure(v: number): number {
122
+ return Number.isFinite(v) ? Math.max(1, Math.min(4, v)) : 1;
123
+ }
124
+
125
+ /** Parse palette hexes to linear RGB, sorted cool→warm by oklab hue angle. */
126
+ function resolvePalette(hexes: string[]): Rgb[] {
127
+ const parsed = (hexes.length ? hexes : ["#42cfff", "#3d5bff", "#a142ff", "#ff2fd6"]).map(
128
+ hexToLinearRgb
129
+ );
130
+ return parsed
131
+ .map((c) => {
132
+ const o = linearToOklab(c);
133
+ // Angle folded to [0,2π); cyan≈196°→…→magenta≈312° sort ascending.
134
+ let h = Math.atan2(o.b, o.a);
135
+ if (h < 0) h += Math.PI * 2;
136
+ return { c, h };
137
+ })
138
+ .sort((a, b) => a.h - b.h)
139
+ .map((x) => x.c);
140
+ }
141
+
142
+ /** Clamped device-pixel ratio, matching the renderers' MAX_DPR. */
143
+ function currentDpr(): number {
144
+ return Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
145
+ }
146
+
147
+ /**
148
+ * A conservative low-power signal (modest RAM or few logical cores). Only ever
149
+ * consulted on the WebGL2 fallback path (a weak-GPU signal already), where it
150
+ * drops the auto tier from "mid" to "low". WebGPU machines are never affected.
151
+ */
152
+ function detectLowPower(): boolean {
153
+ if (typeof navigator === "undefined") return false;
154
+ const mem = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
155
+ const cores = navigator.hardwareConcurrency;
156
+ return (typeof mem === "number" && mem <= 4) || (typeof cores === "number" && cores <= 4);
157
+ }
158
+
159
+ onMount(() => {
160
+ const canvas = canvasRef;
161
+ if (!canvas) return;
162
+
163
+ let disposed = false;
164
+ let engine: FireworksEngineHandle | null = null;
165
+ let sim: Sim | null = null;
166
+ let instances: Float32Array<ArrayBuffer> | null = null;
167
+ let rafId = 0;
168
+ let running = false;
169
+ let lastTime = 0;
170
+
171
+ // Adaptive downgrade: session-sticky quality ladder driven by an
172
+ // EMA of frame time. Applied only on active frames (see `loop`).
173
+ const adaptive = createAdaptiveState();
174
+ let currentRenderScale = 1;
175
+ let currentSpawnScale = 1;
176
+
177
+ let currentExposure = clampExposure(exposure);
178
+ // Reused per-frame uniforms object (no per-frame allocation in the loop).
179
+ const frameUniforms = { exposure: currentExposure };
180
+ let keepClear: Rect | null = KEEP_CLEAR_DESKTOP;
181
+ let paletteRgb = resolvePalette(palette);
182
+
183
+ const prefersReduced =
184
+ respectReducedMotion &&
185
+ typeof window !== "undefined" &&
186
+ typeof window.matchMedia === "function" &&
187
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
188
+
189
+ // Sampled once; only consulted on the WebGL2 fallback tier resolution.
190
+ const lowPower = detectLowPower();
191
+
192
+ let ambientOn = ambient && !prefersReduced;
193
+ let ambientLevel = Math.max(0, Math.min(1, ambientIntensity));
194
+
195
+ // Ambient scheduling state.
196
+ const hueState = { i: 0, dir: 1 as 1 | -1 };
197
+ let ambientElapsed = 0;
198
+ let nextInterval = poissonIntervalMs(2200, Math.random, 900, 5200);
199
+ let ambientInFlight = 0;
200
+ const pendingDoubles: { at: number; mirror: boolean }[] = [];
201
+
202
+ let isVisible = true;
203
+ let observer: IntersectionObserver | null = null;
204
+ let resizeObs: ResizeObserver | null = null;
205
+
206
+ // GPU context loss: one recovery attempt (WebGPU asks for a new device,
207
+ // WebGL waits for `webglcontextrestored`), then a full teardown so the
208
+ // caller can fall back instead of holding a dead handle.
209
+ let wired = false; // observers/listeners are registered exactly once
210
+ let recovering = false;
211
+ let recoveryUsed = false;
212
+ let restoreTimer = 0;
213
+
214
+ function updateAspect() {
215
+ if (!sim || !canvas) return;
216
+ const w = canvas.clientWidth || 1;
217
+ const h = canvas.clientHeight || 1;
218
+ sim.setAspect(w / h);
219
+ }
220
+
221
+ function currentAspect(): number {
222
+ return (canvas.clientWidth || 1) / (canvas.clientHeight || 1);
223
+ }
224
+
225
+ // Shells the scheduler can fire unaided — "glyph" and "shape" need caller
226
+ // points, so they are dropped from any list rather than breaking blank.
227
+ const SELF_DRAWING: ShellKind[] = ["peony", "willow", "ring", "heart", "star"];
228
+ const ambientMix = (ambientShells ?? []).filter((k) => SELF_DRAWING.includes(k));
229
+
230
+ /** The stock weighted mix — what a launch uses when nothing overrides it. */
231
+ function pickShell(): ShellKind {
232
+ const r = Math.random();
233
+ if (r < 0.62) return "peony";
234
+ if (r < 0.82) return "willow";
235
+ return "ring";
236
+ }
237
+
238
+ /**
239
+ * The scheduler's own pick. `ambientShells` is scoped to the scheduler, so
240
+ * pointer launches keep the stock mix — a prop named for the ambient loop
241
+ * must not quietly rewrite what a click does.
242
+ */
243
+ function pickAmbientShell(): ShellKind {
244
+ if (ambientMix.length) return ambientMix[Math.floor(Math.random() * ambientMix.length)];
245
+ return pickShell();
246
+ }
247
+
248
+ function ambientColor(): Rgb {
249
+ return shellHueColor(nextShellHue(hueState), paletteRgb, SHELL_JITTER_DEG, Math.random);
250
+ }
251
+
252
+ /**
253
+ * AD §4.4: ambient bursts must stay within 0.10–0.20 (fraction of
254
+ * min-dim) — distinct from the larger "feature" band reserved for
255
+ * intro/feature shells. `SHELL[shell].radius[1]` is each shell's own
256
+ * frozen max (willow runs a touch larger than peony/ring), so the scale
257
+ * ceiling is derived per shell rather than shared, guaranteeing the
258
+ * 0.20 cap holds at ambientLevel 1 for every shell kind.
259
+ */
260
+ function ambientScale(shell: ShellKind, level: number): number {
261
+ const raw = 0.7 + 0.4 * level;
262
+ const ceiling = AMBIENT_RADIUS_MAX / SHELL[shell].radius[1];
263
+ return Math.min(raw, ceiling);
264
+ }
265
+
266
+ function doAmbientLaunch(mirror: boolean) {
267
+ if (!sim || ambientInFlight >= MAX_AMBIENT_INFLIGHT_HARD) return;
268
+ let picked = sampleZone(AMBIENT_ZONES, Math.random, keepClear);
269
+ let apex = picked.apex;
270
+ if (mirror) {
271
+ const mirrored = { x: 1 - picked.apex.x, y: picked.apex.y };
272
+ // sampleZone validated the ORIGINAL point; reflecting it can drop the
273
+ // double straight into an asymmetric keep-clear rect. Re-test, and
274
+ // take a freshly sampled (validated) apex when the mirror lands in it.
275
+ if (keepClear && rectExpandedContains(keepClear, mirrored.x, mirrored.y)) {
276
+ picked = sampleZone(AMBIENT_ZONES, Math.random, keepClear);
277
+ apex = picked.apex;
278
+ } else {
279
+ apex = mirrored;
280
+ }
281
+ }
282
+ const shell = pickAmbientShell();
283
+ const res = sim.launch({
284
+ apex,
285
+ shell,
286
+ color: ambientColor(),
287
+ depth: picked.depth,
288
+ scale: ambientScale(shell, ambientLevel),
289
+ intensity: "ambient",
290
+ });
291
+ ambientInFlight++;
292
+ // Release the in-flight slot shortly after the shell detonates.
293
+ window.setTimeout(() => {
294
+ ambientInFlight = Math.max(0, ambientInFlight - 1);
295
+ }, res.breakMs + 200);
296
+ }
297
+
298
+ function ambientTick(dtMs: number) {
299
+ if (!ambientOn) return;
300
+ // Fire any scheduled double-launches whose delay has elapsed.
301
+ for (let i = pendingDoubles.length - 1; i >= 0; i--) {
302
+ pendingDoubles[i].at -= dtMs;
303
+ if (pendingDoubles[i].at <= 0) {
304
+ doAmbientLaunch(pendingDoubles[i].mirror);
305
+ pendingDoubles.splice(i, 1);
306
+ }
307
+ }
308
+ ambientElapsed += dtMs;
309
+ if (ambientElapsed >= nextInterval && ambientInFlight < MAX_AMBIENT_INFLIGHT_SOFT) {
310
+ doAmbientLaunch(false);
311
+ ambientElapsed = 0;
312
+ nextInterval = poissonIntervalMs(2200, Math.random, 900, 5200);
313
+ // 18% chance of a mirrored double, +120–320 ms later.
314
+ if (Math.random() < 0.18) {
315
+ pendingDoubles.push({ at: 120 + Math.random() * 200, mirror: true });
316
+ }
317
+ }
318
+ }
319
+
320
+ function applyAdaptiveLevel() {
321
+ const level = adaptiveLevel(adaptive);
322
+ if (level.renderScale !== currentRenderScale) {
323
+ currentRenderScale = level.renderScale;
324
+ engine?.setRenderScale(currentRenderScale);
325
+ }
326
+ if (level.spawnScale !== currentSpawnScale) {
327
+ currentSpawnScale = level.spawnScale;
328
+ sim?.setSpawnScale(currentSpawnScale);
329
+ }
330
+ if (import.meta.env.DEV) {
331
+ console.info(
332
+ `[FireworksHdr] adaptive downgrade → step ${adaptive.step} ` +
333
+ `(renderScale ${currentRenderScale}, spawnScale ${currentSpawnScale})`
334
+ );
335
+ }
336
+ }
337
+
338
+ function loop(now: number) {
339
+ if (!running || disposed || !sim || !engine || !instances) return;
340
+ // A lost device/context never recovers on its own: reboot or tear down
341
+ // rather than leave a live component driving a dead engine.
342
+ if (engine.lost) {
343
+ handleEngineLoss();
344
+ return;
345
+ }
346
+ const rawFrameMs = now - lastTime;
347
+ const dt = Math.min((now - lastTime) / 1000, DT_CLAMP);
348
+ lastTime = now;
349
+
350
+ ambientTick(dt * 1000);
351
+ engine.resizeIfNeeded();
352
+ sim.step(dt);
353
+ const n = sim.writeInstances(instances);
354
+ frameUniforms.exposure = currentExposure;
355
+ engine.frame(dt, instances, n, frameUniforms);
356
+
357
+ // Fold only ACTIVE, foreground frames into the adaptive controller so
358
+ // paused / reduced-motion-idle frames (n === 0) never skew the average.
359
+ if (n > 0 && !document.hidden && adaptiveDowngradeStep(adaptive, rawFrameMs)) {
360
+ applyAdaptiveLevel();
361
+ }
362
+
363
+ rafId = requestAnimationFrame(loop);
364
+ }
365
+
366
+ function startLoop() {
367
+ if (running) return;
368
+ running = true;
369
+ lastTime = performance.now();
370
+ rafId = requestAnimationFrame(loop);
371
+ }
372
+ function stopLoop() {
373
+ running = false;
374
+ if (rafId) cancelAnimationFrame(rafId);
375
+ rafId = 0;
376
+ }
377
+
378
+ // --- pointer-driven launches -----------------------------------------
379
+ function handlePointerDown(e: PointerEvent) {
380
+ if (!sim) return;
381
+ const rect = canvas.getBoundingClientRect();
382
+ if (rect.width <= 0 || rect.height <= 0) return;
383
+ const x = (e.clientX - rect.left) / rect.width;
384
+ const y = (e.clientY - rect.top) / rect.height;
385
+ if (x < 0 || x > 1 || y < 0 || y > 1) return;
386
+ sim.launch({
387
+ apex: { x, y },
388
+ shell: pickShell(),
389
+ color: ambientColor(),
390
+ intensity: "ambient",
391
+ });
392
+ }
393
+
394
+ function handleVisibility() {
395
+ if (document.hidden) stopLoop();
396
+ else if (isVisible && !disposed && sim) startLoop();
397
+ }
398
+
399
+ // --- imperative handle (§1) ------------------------------------------
400
+ function buildHandle(level: FireworksRenderLevel): FireworksHandle {
401
+ return {
402
+ launch(o: LaunchOptions): LaunchResult {
403
+ if (!sim) return { flightMs: 0, breakMs: 0 };
404
+ return sim.launch(o);
405
+ },
406
+ setAmbient(on: boolean, intensity?: number) {
407
+ ambientOn = on && !prefersReduced;
408
+ if (intensity !== undefined) ambientLevel = Math.max(0, Math.min(1, intensity));
409
+ },
410
+ setKeepClear(rect: Rect | null) {
411
+ keepClear = rect;
412
+ // Also drives the per-particle soft dim (AD §4.5) — apex sampling
413
+ // alone only keeps burst origins clear, not drift-in trails/embers.
414
+ sim?.setKeepClear(rect);
415
+ },
416
+ setExposure(v: number) {
417
+ currentExposure = clampExposure(v);
418
+ },
419
+ renderLevel: level,
420
+ cleanup: teardown,
421
+ };
422
+ }
423
+
424
+ function teardown() {
425
+ disposed = true;
426
+ stopLoop();
427
+ observer?.disconnect();
428
+ resizeObs?.disconnect();
429
+ if (interactive) window.removeEventListener("pointerdown", handlePointerDown);
430
+ document.removeEventListener("visibilitychange", handleVisibility);
431
+ canvas.removeEventListener("webglcontextrestored", onContextRestored);
432
+ if (restoreTimer) window.clearTimeout(restoreTimer);
433
+ restoreTimer = 0;
434
+ wired = false;
435
+ engine?.destroy();
436
+ engine = null;
437
+ sim = null;
438
+ instances = null;
439
+ }
440
+
441
+ /** Drop the dead engine and its sim, keeping observers/listeners alive. */
442
+ function releaseEngine() {
443
+ stopLoop();
444
+ engine?.destroy();
445
+ engine = null;
446
+ sim = null;
447
+ instances = null;
448
+ }
449
+
450
+ function giveUp() {
451
+ if (disposed) return;
452
+ teardown();
453
+ onLost?.();
454
+ }
455
+
456
+ async function reboot() {
457
+ recovering = true;
458
+ let revived = false;
459
+ try {
460
+ revived = await boot();
461
+ } catch (error) {
462
+ if (import.meta.env.DEV) {
463
+ console.warn("[FireworksHdr] engine reboot failed:", error);
464
+ }
465
+ }
466
+ recovering = false;
467
+ if (!revived && !disposed) giveUp();
468
+ }
469
+
470
+ function onContextRestored() {
471
+ if (restoreTimer) window.clearTimeout(restoreTimer);
472
+ restoreTimer = 0;
473
+ if (disposed) return;
474
+ void reboot();
475
+ }
476
+
477
+ /**
478
+ * A WebGPU device loss or a `webglcontextlost` leaves the engine dead: no
479
+ * frame it draws lands. Reboot once (which also re-runs the WebGPU →
480
+ * WebGL2 fallback), and if that fails tear the component down and report
481
+ * it instead of leaving observers, listeners, and a handle wired to
482
+ * nothing.
483
+ */
484
+ function handleEngineLoss() {
485
+ if (disposed || recovering) return;
486
+ const wasWebgl = engine ? engine.renderLevel.startsWith("webgl") : false;
487
+ releaseEngine();
488
+ if (recoveryUsed) {
489
+ giveUp();
490
+ return;
491
+ }
492
+ recoveryUsed = true;
493
+ if (wasWebgl) {
494
+ // A lost WebGL context only vends a usable one again once the browser
495
+ // fires webglcontextrestored — which it may never do.
496
+ recovering = true;
497
+ canvas.addEventListener("webglcontextrestored", onContextRestored, { once: true });
498
+ restoreTimer = window.setTimeout(() => {
499
+ restoreTimer = 0;
500
+ canvas.removeEventListener("webglcontextrestored", onContextRestored);
501
+ recovering = false;
502
+ giveUp();
503
+ }, RESTORE_TIMEOUT_MS);
504
+ } else {
505
+ // WebGPU: a fresh device can be requested straight away.
506
+ void reboot();
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Wire the sim, observers, and listeners around a live engine and hand out
512
+ * a handle. Shared by both the WebGPU and WebGL2 paths so the render level
513
+ * flows through from whichever engine actually came up — and re-entrant,
514
+ * so a post-loss reboot re-seats the sim without double-wiring the
515
+ * observers and listeners it already owns.
516
+ */
517
+ function activate(eng: FireworksEngineHandle) {
518
+ engine = eng;
519
+ const level = eng.renderLevel;
520
+ const minDim = Math.min(canvas.clientWidth, canvas.clientHeight);
521
+ const q = resolveQualityTier(quality, level, currentDpr(), minDim, lowPower);
522
+
523
+ sim = createSim({
524
+ quality: q,
525
+ aspect: currentAspect(),
526
+ hdr: true,
527
+ palette: paletteRgb,
528
+ });
529
+ sim.setKeepClear(keepClear);
530
+ instances = new Float32Array(sim.capacity * 8);
531
+
532
+ if (!wired) {
533
+ // Observers: pause off-screen / when hidden; track aspect + resize.
534
+ observer = new IntersectionObserver(
535
+ ([entry]) => {
536
+ isVisible = entry.isIntersecting;
537
+ if (isVisible && !document.hidden && !disposed) startLoop();
538
+ else stopLoop();
539
+ },
540
+ { threshold: 0 }
541
+ );
542
+ observer.observe(canvas);
543
+
544
+ resizeObs = new ResizeObserver(() => {
545
+ updateAspect();
546
+ engine?.resizeIfNeeded();
547
+ });
548
+ resizeObs.observe(canvas);
549
+
550
+ if (interactive) window.addEventListener("pointerdown", handlePointerDown);
551
+ document.addEventListener("visibilitychange", handleVisibility);
552
+ wired = true;
553
+ }
554
+
555
+ updateAspect();
556
+ startLoop();
557
+ // Re-fired after a recovery: the level may have dropped to the WebGL2
558
+ // fallback, and the caller needs a handle that says so.
559
+ onReady?.(buildHandle(level));
560
+ }
561
+
562
+ // --- boot the engine, then wire everything ---------------------------
563
+ // WebGPU first (if present), then the WebGL2 fallback. Only if BOTH fail do
564
+ // we stay silent — the app's own timeout then shows the static fallback
565
+ // (renderLevel "none"). Resolves to whether an engine actually activated,
566
+ // which is what the post-loss reboot decides on.
567
+ async function boot(): Promise<boolean> {
568
+ let eng: FireworksEngineHandle | null = null;
569
+
570
+ if (typeof navigator !== "undefined" && "gpu" in navigator) {
571
+ eng = await startWebGpuFireworks(canvas, {
572
+ maxInstances: MAX_INSTANCES,
573
+ renderScale: 1,
574
+ exposure: currentExposure,
575
+ }).catch((error) => {
576
+ if (import.meta.env.DEV) {
577
+ console.warn("[FireworksHdr] WebGPU start failed:", error);
578
+ }
579
+ return null;
580
+ });
581
+ }
582
+
583
+ if (disposed) {
584
+ eng?.destroy();
585
+ return false;
586
+ }
587
+
588
+ if (!eng) {
589
+ // WebGL2 fallback. It only ever reports webgl-p3 / webgl-sdr, which
590
+ // resolve to the same tier, so the tier is resolved up front to size
591
+ // the instance buffer to the resolved tier (not high's 4096).
592
+ const minDim = Math.min(canvas.clientWidth, canvas.clientHeight);
593
+ const webglTier = resolveQualityTier(quality, "webgl-p3", currentDpr(), minDim, lowPower);
594
+ eng = startWebGl2Fireworks(canvas, {
595
+ maxInstances: QUALITY[webglTier].maxParticles,
596
+ renderScale: 1,
597
+ exposure: currentExposure,
598
+ hdr: true,
599
+ });
600
+ if (disposed) {
601
+ eng?.destroy();
602
+ return false;
603
+ }
604
+ }
605
+
606
+ // Both paths exhausted: no GPU renderer. Stay silent (no onReady); the
607
+ // app's timeout owns the static fallback.
608
+ if (!eng) return false;
609
+
610
+ activate(eng);
611
+ return true;
612
+ }
613
+
614
+ if (hdr) {
615
+ boot().catch((error) => {
616
+ if (import.meta.env.DEV) {
617
+ console.warn("[FireworksHdr] engine boot failed:", error);
618
+ }
619
+ return false;
620
+ });
621
+ }
622
+
623
+ return teardown;
624
+ });
625
+ </script>
626
+
627
+ <div class={cn("pointer-events-none absolute inset-0 h-full w-full", className)} aria-hidden="true">
628
+ <canvas bind:this={canvasRef} class="block h-full w-full"></canvas>
629
+ </div>
@@ -0,0 +1,56 @@
1
+ import type { FireworksHandle } from "./fireworks-shared.js";
2
+ /** Props for {@link FireworksHdr}. */
3
+ export interface FireworksHdrProps {
4
+ /**
5
+ * Brand hues (hex). Order is irrelevant — they are parsed and sorted into
6
+ * the canonical cool→warm ping-pong order (cyan, blue, violet, magenta)
7
+ * so the shell sweep is stable however they are listed.
8
+ */
9
+ palette?: string[];
10
+ /**
11
+ * Opt into the GPU engine: WebGPU HDR first, then a WebGL2 fallback
12
+ * (display-p3 SDR where supported, else plain sRGB). When false, no engine
13
+ * boots at all.
14
+ */
15
+ hdr?: boolean;
16
+ /** Display exposure multiplier, clamped [1,4]. */
17
+ exposure?: number;
18
+ /** Run the ambient auto-scheduler. */
19
+ ambient?: boolean;
20
+ /** Ambient intensity [0,1] — scales shell size/energy. */
21
+ ambientIntensity?: number;
22
+ /** Launch a shell toward the pointer on window pointerdown. */
23
+ interactive?: boolean;
24
+ /** Quality tier, or "auto" to pick from the render level + DPR. */
25
+ quality?: "auto" | "high" | "mid" | "low";
26
+ /**
27
+ * Which shells the ambient scheduler is allowed to fire, picked uniformly.
28
+ * Defaults to the weighted peony/willow/ring mix. Pattern shells ("heart",
29
+ * "star") are valid here; "glyph" and "shape" are not — they need points
30
+ * the scheduler has no way to invent, so they are ignored if listed.
31
+ */
32
+ ambientShells?: ShellKind[];
33
+ /** Force ambient off under prefers-reduced-motion (launches still work). */
34
+ respectReducedMotion?: boolean;
35
+ /** Extra classes on the canvas wrapper. */
36
+ class?: string;
37
+ /**
38
+ * Called when the engine is live, with an imperative handle. Never called
39
+ * when no GPU renderer comes up (the caller uses its own timeout). Called
40
+ * again after a GPU context loss is recovered, with a fresh handle whose
41
+ * `renderLevel` reflects the engine that came back (a lost WebGPU device
42
+ * can return as the WebGL2 fallback).
43
+ */
44
+ onReady?: (handle: FireworksHandle) => void;
45
+ /**
46
+ * Called once when the GPU context is lost and cannot be brought back.
47
+ * The component has torn itself down by then: the loop is stopped, the
48
+ * listeners are gone, and any handle it handed out is inert. Use it to
49
+ * swap in a static fallback.
50
+ */
51
+ onLost?: () => void;
52
+ }
53
+ import { type ShellKind } from "./fireworks-shared.js";
54
+ declare const FireworksHdr: import("svelte").Component<FireworksHdrProps, {}, "">;
55
+ type FireworksHdr = ReturnType<typeof FireworksHdr>;
56
+ export default FireworksHdr;
@@ -0,0 +1 @@
1
+ export {};