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,74 @@
1
+ import { render, cleanup } from "@testing-library/svelte";
2
+ import { afterEach, describe, it, expect, vi } from "vitest";
3
+ import FireworksHdr from "./FireworksHdr.svelte";
4
+ // jsdom exposes no `navigator.gpu`, so these exercise the no-renderer path:
5
+ // the component mounts inertly and never fires onReady (the app's own timeout
6
+ // owns the fallback decision).
7
+ describe("FireworksHdr", () => {
8
+ afterEach(cleanup);
9
+ it("renders a wrapper div", () => {
10
+ const { container } = render(FireworksHdr);
11
+ const div = container.firstElementChild;
12
+ expect(div).toBeInTheDocument();
13
+ });
14
+ it("renders a canvas element", () => {
15
+ const { container } = render(FireworksHdr);
16
+ expect(container.querySelector("canvas")).toBeInTheDocument();
17
+ });
18
+ it("wrapper is pointer-events-none and absolutely fills its parent", () => {
19
+ const { container } = render(FireworksHdr);
20
+ const div = container.firstElementChild;
21
+ expect(div.className).toContain("pointer-events-none");
22
+ expect(div.className).toContain("absolute");
23
+ expect(div.className).toContain("inset-0");
24
+ });
25
+ it("wrapper is aria-hidden (decorative)", () => {
26
+ const { container } = render(FireworksHdr);
27
+ const div = container.firstElementChild;
28
+ expect(div.getAttribute("aria-hidden")).toBe("true");
29
+ });
30
+ it("applies a custom class name", () => {
31
+ const { container } = render(FireworksHdr, { props: { class: "my-fireworks" } });
32
+ const div = container.firstElementChild;
33
+ expect(div.className).toContain("my-fireworks");
34
+ });
35
+ it("does not fire onReady when no GPU renderer is available (jsdom)", async () => {
36
+ const onReady = vi.fn();
37
+ render(FireworksHdr, { props: { onReady } });
38
+ await new Promise((resolve) => setTimeout(resolve, 0));
39
+ expect(onReady).not.toHaveBeenCalled();
40
+ });
41
+ it("does not fire onLost when no engine ever booted (that is not a loss)", async () => {
42
+ const onLost = vi.fn();
43
+ render(FireworksHdr, { props: { onLost } });
44
+ await new Promise((resolve) => setTimeout(resolve, 0));
45
+ expect(onLost).not.toHaveBeenCalled();
46
+ });
47
+ it("re-exports the figure helpers consumers are told to import", async () => {
48
+ // The package barrel is `export * from "./fireworks-hdr/index.js"`, so what
49
+ // this module exposes is exactly what `import { … } from "fancy-ui-svelte"`
50
+ // can reach.
51
+ const barrel = await import("./index.js");
52
+ expect(typeof barrel.heartOutline).toBe("function");
53
+ expect(typeof barrel.starOutline).toBe("function");
54
+ expect(typeof barrel.polygonOutline).toBe("function");
55
+ expect(typeof barrel.outlineBurst).toBe("function");
56
+ });
57
+ it("does not attempt WebGPU when hdr={false}", () => {
58
+ const { container } = render(FireworksHdr, { props: { hdr: false } });
59
+ expect(container.querySelector("canvas")).toBeInTheDocument();
60
+ });
61
+ it("unmounts without throwing", () => {
62
+ const { unmount } = render(FireworksHdr, { props: { interactive: true, ambient: true } });
63
+ expect(() => unmount()).not.toThrow();
64
+ });
65
+ it("mounts without error across quality tiers and reduced-motion", () => {
66
+ for (const quality of ["auto", "high", "mid", "low"]) {
67
+ const { container, unmount } = render(FireworksHdr, {
68
+ props: { quality, respectReducedMotion: true },
69
+ });
70
+ expect(container.querySelector("canvas")).toBeInTheDocument();
71
+ unmount();
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,492 @@
1
+ # FireworksHdr
2
+
3
+ A GPU fireworks background that burns shells into the night sky. A DOM/GPU-free
4
+ physics core drives a WebGPU renderer into an `rgba16float`, wide-gamut (P3),
5
+ extended-tone-mapping canvas, so bursts, flashes, and comet trails render
6
+ _brighter than SDR white_ on an HDR display. It degrades silently — a clamped
7
+ WebGPU-SDR path, then a WebGL2 fallback (display-p3 or plain sRGB), then nothing
8
+ at all — so the effect stays safe on everything else.
9
+
10
+ The simulation (rockets, peony/willow/ring shells, heart/star/custom pattern
11
+ shells, glyph shells, sparks, embers, smoke, seekers) is fully deterministic
12
+ given a seed, and pointer- or code-driven. The
13
+ intro "word" burned by the host app (glyph shells spelling out letters) is just
14
+ the generic `glyph` shell fed explicit target points — the component itself
15
+ ships no copy.
16
+
17
+ ## Usage
18
+
19
+ ```svelte
20
+ <script>
21
+ import { FireworksHdr } from "fancy-ui-svelte";
22
+
23
+ let handle;
24
+ </script>
25
+
26
+ <div class="relative h-screen w-screen bg-black">
27
+ <FireworksHdr
28
+ palette={["#ff2fd6", "#a142ff", "#3d5bff", "#42cfff"]}
29
+ exposure={2.2}
30
+ ambient
31
+ interactive
32
+ onReady={(h) => (handle = h)}
33
+ />
34
+ </div>
35
+ ```
36
+
37
+ Drive it programmatically (e.g. pair with `interactive={false}`):
38
+
39
+ ```js
40
+ handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "peony" });
41
+ handle.setAmbient(true, 0.5);
42
+ handle.setKeepClear({ x0: 0.28, y0: 0.3, x1: 0.72, y1: 0.82 });
43
+ handle.setExposure(2.6);
44
+ ```
45
+
46
+ ## Props
47
+
48
+ | Prop | Type | Default | Description |
49
+ | ---------------------- | ------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `palette` | `string[]` (hex) | `["#ff2fd6","#a142ff","#3d5bff","#42cfff"]` | Brand hues. Order is irrelevant — parsed and sorted cool→warm (by oklab hue angle) for the shell sweep. |
51
+ | `hdr` | `boolean` | `true` | Opt into the GPU engine (WebGPU HDR first, then a WebGL2 fallback). When `false`, no engine boots at all. |
52
+ | `exposure` | `number` | `2.2` | Display exposure multiplier, clamped to `[1,4]`. |
53
+ | `ambient` | `boolean` | `true` | Run the ambient auto-scheduler (Poisson-timed background shells). |
54
+ | `ambientIntensity` | `number` | `0.35` | Ambient energy `[0,1]` — scales shell size. |
55
+ | `interactive` | `boolean` | `true` | Launch a shell toward the pointer on window `pointerdown`. |
56
+ | `quality` | `"auto" \| "high" \| "mid" \| "low"` | `"auto"` | Particle budget; `auto` picks from the render level + DPR. |
57
+ | `ambientShells` | `ShellKind[]` | — | Restrict the ambient scheduler to these shells, picked uniformly (default: weighted peony/willow/ring). `"glyph"` and `"shape"` are ignored — they need caller points. |
58
+ | `respectReducedMotion` | `boolean` | `true` | Force ambient off under `prefers-reduced-motion` (explicit launches still work). |
59
+ | `class` | `string` | `""` | Extra classes on the canvas wrapper. |
60
+ | `onReady` | `(handle: FireworksHandle) => void` | — | Fired when the engine is live, and again with a fresh handle after a recovered GPU context loss. Never fired when no GPU renderer comes up. |
61
+ | `onLost` | `() => void` | — | Fired once when a GPU context loss could not be recovered; the component has torn itself down and any handle it gave out is inert. |
62
+
63
+ The canvas wrapper is `pointer-events-none` and `aria-hidden` — it is a
64
+ background. `interactive` listens at the window level, so clicks pass through to
65
+ your UI while still launching a shell.
66
+
67
+ ## Handle (`FireworksHandle`)
68
+
69
+ The imperative control surface passed to `onReady`. Coordinates are normalized
70
+ `[0,1]`, top-left origin.
71
+
72
+ | Member | Signature | Description |
73
+ | -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------- |
74
+ | `launch` | `(opts: LaunchOptions) => LaunchResult` | Fire a shell; returns the resolved `{ flightMs, breakMs }`. |
75
+ | `setAmbient` | `(on: boolean, intensity?: number) => void` | Toggle the ambient scheduler / set its intensity `[0,1]`. |
76
+ | `setKeepClear` | `(rect: Rect \| null) => void` | Set (or clear) the rect ambient apexes avoid; also soft-dims stray particles that drift in. |
77
+ | `setExposure` | `(v: number) => void` | Update exposure (clamped `[1,4]`). |
78
+ | `renderLevel` | `FireworksRenderLevel` | Which path actually engaged (see below). |
79
+ | `cleanup` | `() => void` | Stop the loop, remove listeners, release GPU memory. |
80
+
81
+ `setKeepClear` drives two things at once: the ambient scheduler steers new apexes
82
+ out of the rect, and the sim soft-dims any spark, ember, or trail that drifts
83
+ inside it — so the area behind a foreground card stays calm without the scheduler
84
+ alone being enough.
85
+
86
+ ## Types
87
+
88
+ - `LaunchOptions` — `{ apex, from?, shell?, color?, glyphPoints?, shapePoints?, scale?, flightMs?, intensity?, depth?, seed?, releaseAtMs? }`. `glyphPoints` is **required** when `shell === "glyph"`, `shapePoints` when `shell === "shape"`. `flightMs` (when given) wins over the apex height for timing — apex only sets the horizontal target; omit it and the flight time is solved from gravity. `depth ∈ [0,1]` pushes a shell "back" (dimmer, smaller, desaturated).
89
+ - `LaunchResult` — `{ flightMs, breakMs }` where `breakMs = flightMs + fuse-hang` (when the shell detonates).
90
+ - `ShellKind` — `"peony" | "willow" | "ring" | "heart" | "star" | "shape" | "glyph"`.
91
+ - `QualityTier` — `"high" | "mid" | "low"`.
92
+ - `FireworksRenderLevel` — `"webgpu-hdr" | "webgpu-sdr" | "webgl-p3" | "webgl-sdr" | "none"`.
93
+
94
+ ## Pattern shells
95
+
96
+ `heart` and `star` break into a figure instead of a sphere, and `shape` draws
97
+ any closed outline you hand it:
98
+
99
+ ```js
100
+ handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "heart" });
101
+ handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "star" });
102
+
103
+ // Any closed figure: y-UP points around the origin, scale is free — the outline
104
+ // is normalized to the unit circle and then drawn at the shell's radius.
105
+ handle.launch({
106
+ apex: { x: 0.5, y: 0.3 },
107
+ shell: "shape",
108
+ shapePoints: [
109
+ { x: 0.1, y: 1 },
110
+ { x: -0.55, y: 0.05 },
111
+ { x: -0.1, y: 0.05 },
112
+ { x: -0.3, y: -1 },
113
+ { x: 0.5, y: 0.1 },
114
+ { x: 0.05, y: 0.1 },
115
+ ],
116
+ });
117
+ ```
118
+
119
+ How it works: the burst is **cut from the outline** rather than from a sphere.
120
+ Each spark leaves the break point in the direction of its outline sample, with a
121
+ speed proportional to that sample's distance from the centre — under linear drag
122
+ every spark coasts `v0 / drag`, so the figure redraws itself in the sky at the
123
+ shell's radius and then droops. Points are walked along the outline's edges, so
124
+ a six-point polygon draws as well as a sampled curve.
125
+
126
+ Pattern shells are marked `crisp`: they drop the ±12% break asymmetry to ±3%,
127
+ carry no stragglers, keep embers at 5%, and give every spark the **same**
128
+ `dragScale` — all four exist to make a peony look natural, and all four read as
129
+ a _broken figure_ on a heart. The drag one matters most: a spark coasts
130
+ `v0 / (drag · dragScale)`, so a per-spark drag spread scales each spark's reach
131
+ independently and the outline never expands as one shape. Radial jitter (±6%)
132
+ stays, because a mathematically perfect outline looks printed.
133
+
134
+ `shapePoints` are walked by **perimeter**, not per vertex, and centred on their
135
+ bounding box: inserting a vertex in the middle of an edge leaves both the figure
136
+ and its particle density unchanged, and a short edge does not collect as many
137
+ sparks as a long one.
138
+
139
+ A `shape` shell launched without `shapePoints` falls back to a plain sphere
140
+ burst rather than vanishing.
141
+
142
+ The ambient scheduler can fire them too:
143
+
144
+ ```svelte
145
+ <FireworksHdr ambientShells={["peony", "heart", "star"]} />
146
+ ```
147
+
148
+ `glyph` and `shape` are ignored in that list — they need points the scheduler
149
+ has no way to invent. The prop is scoped to the scheduler: pointer launches keep
150
+ the stock peony/willow/ring mix.
151
+
152
+ The figure helpers are exported from the package root, so an outline can be
153
+ built without reimplementing them:
154
+
155
+ ```js
156
+ import { heartOutline, starOutline, polygonOutline, outlineBurst } from "fancy-ui-svelte";
157
+ ```
158
+
159
+ ## Render levels
160
+
161
+ `renderLevel` reports the path that _actually_ engaged. It is sampled once at
162
+ startup and does **not** follow the window to another display. The value is
163
+ honest — the component never claims HDR it is not delivering.
164
+
165
+ | Level | Meaning |
166
+ | ------------ | --------------------------------------------------------------------------------------------------------------------- |
167
+ | `webgpu-hdr` | WebGPU with extended tone mapping active **and** a `(dynamic-range: high)` display — true brighter-than-white output. |
168
+ | `webgpu-sdr` | WebGPU float16 + P3, but the browser clamped tone mapping or the display is SDR. |
169
+ | `webgl-p3` | WebGL2 fallback with a `display-p3` drawing buffer (wider gamut, no over-white headroom). |
170
+ | `webgl-sdr` | WebGL2 fallback, plain sRGB. |
171
+ | `none` | No GPU rendering available; `onReady` never fires. |
172
+
173
+ Honesty invariants:
174
+
175
+ - `webgpu-hdr` is reported **only** when extended tone mapping reads back as
176
+ active from `getConfiguration()` **and** `matchMedia("(dynamic-range: high)")`
177
+ matches. A WebGPU context that renders float16 + P3 but cannot prove extended
178
+ tone mapping downgrades to `webgpu-sdr`.
179
+ - `webgl-p3` is reported **only** when the `drawingBufferColorSpace = "display-p3"`
180
+ assignment reads back as `display-p3`; otherwise the buffer stays sRGB and the
181
+ level is `webgl-sdr`.
182
+ - `onReady` fires once per live engine, and never when both engines fail. The
183
+ consumer owns the fallback decision (e.g. a short timeout that swaps in a
184
+ static poster) — the component stays silent rather than showing a broken
185
+ canvas.
186
+ - The `sdr` uniform the shaders branch on uses the **same** predicate as
187
+ `webgpu-hdr`. An SDR display therefore always gets the exposure-`1.0`
188
+ soft-knee path, even when the browser did keep extended tone mapping — the
189
+ render level and the pixels can never disagree.
190
+
191
+ ### GPU context loss
192
+
193
+ A lost WebGPU device or a `webglcontextlost` event kills the engine: nothing it
194
+ draws lands again. The component detects it on the next frame and recovers
195
+ **once** — WebGPU asks for a fresh device (re-running the WebGPU → WebGL2
196
+ fallback), WebGL waits up to 4 s for `webglcontextrestored`. A successful
197
+ recovery re-seats the sim and fires `onReady` again with a handle whose
198
+ `renderLevel` reflects the engine that came back. If recovery fails, or a second
199
+ loss follows, the component tears itself down — loop stopped, observers and
200
+ listeners removed, GPU memory released — and calls `onLost` so the consumer can
201
+ swap in its static fallback. It never sits there driving a dead engine.
202
+
203
+ On an SDR display the effect is still safe: exposure is pinned to `1.0`, a
204
+ soft-knee compression keeps highlights from clipping, and a small saturation lift
205
+ compensates for the lost range (see _SDR degradation_ below). Everything
206
+ _sub-white_ renders identically on HDR and SDR.
207
+
208
+ ## Quality & adaptive downgrade
209
+
210
+ `quality="auto"` resolves a tier from the render level, device-pixel-ratio, and a
211
+ conservative low-power signal:
212
+
213
+ | Condition | Tier |
214
+ | ---------------------------------------- | ------ |
215
+ | container min-dimension `< 480px` | `low` |
216
+ | WebGL2 fallback **and** low-power device | `low` |
217
+ | `webgpu-hdr` | `high` |
218
+ | `webgpu-sdr`, DPR ≥ 2 | `high` |
219
+ | `webgpu-sdr`, DPR < 2 | `mid` |
220
+ | WebGL2 (`webgl-p3` / `webgl-sdr`) | `mid` |
221
+
222
+ Tiers set the particle budget and trail emission rate:
223
+
224
+ | Tier | maxParticles | trailRate (/s) |
225
+ | ------ | ------------ | -------------- |
226
+ | `high` | 4096 | 180 |
227
+ | `mid` | 2560 | 120 |
228
+ | `low` | 1280 | 60 |
229
+
230
+ On top of the tier, a **session-sticky adaptive ladder** watches an EMA of frame
231
+ time (α = 0.1) and drops a notch when the average holds above 24 ms (~<42 fps)
232
+ for ~60 consecutive active frames. It only ever downgrades — never hunts back up
233
+ — and sheds GPU cost before CPU cost:
234
+
235
+ | Step | renderScale | spawnScale | Sheds |
236
+ | ---- | ----------- | ---------- | ------------------------ |
237
+ | 0 | 1.0 | 1.0 | — (full) |
238
+ | 1 | 0.75 | 1.0 | GPU: accumulation buffer |
239
+ | 2 | 0.5 | 1.0 | GPU: accumulation buffer |
240
+ | 3 | 0.5 | 0.66 | CPU: burst spawn density |
241
+ | 4 | 0.5 | 0.4 | CPU: burst spawn density |
242
+
243
+ The pool is fixed-size (frozen at `createSim`), so the ladder never rebuilds it
244
+ mid-show — it shrinks _new burst_ counts instead, which is pop-free. Glyph
245
+ seekers are driven by explicit points and are never scaled, so the intro word
246
+ stays intact under downgrade. Only active foreground frames (particles present,
247
+ not `document.hidden`, not reduced-motion-idle) fold into the average, so
248
+ paused/quiet frames never trigger a spurious drop.
249
+
250
+ ## Performance
251
+
252
+ The CPU sim has large headroom; the real budget risk is GPU fill-rate on the HDR
253
+ float path, which is what the adaptive ladder targets first. Measured on Apple
254
+ Silicon at the `high` tier:
255
+
256
+ - **Intro peak** (four glyph shells + garnish): `step()` + `writeInstances()`
257
+ ≈ **0.10 ms** mean per frame.
258
+ - **Saturation** (4096 live particles): ≈ **1.03 ms** mean per frame — still well
259
+ under a 60 fps CPU budget, leaving the frame to the GPU.
260
+
261
+ ---
262
+
263
+ ## Art direction
264
+
265
+ Design rationale for the frame. Brightness values are _encoded linear,
266
+ pre-exposure_; `1.0` = SDR white. Shipped exposure is 2.2 ambient / 2.6 intro on
267
+ HDR; SDR pins exposure to 1.0 with a soft knee.
268
+
269
+ ### Palette & color
270
+
271
+ - Family (ping-pong index order): cyan `#42cfff` (196°), blue `#3d5bff` (231°),
272
+ violet `#a142ff` (271°), magenta `#ff2fd6` (312°). Electric blue `#3d5bff` is
273
+ the signature hue.
274
+ - **White-hot life ramp** (life fraction `L`): white hold to `L 0.04–0.11`
275
+ (magnesium bias `#f4f8ff`, per-spark), crossfade white→hue in oklab (never
276
+ grey) completed by `L 0.34`, full hue to `0.72`, then cool and dim to
277
+ warm-white `#ffcf9c`. The magnesium reads as the _detonation_, so handing over
278
+ late leaves the shell white for most of the time anyone is looking at it.
279
+ - **Shell assignment**: 85% single-hue via a ping-pong sweep
280
+ cyan→blue→violet→magenta→violet→blue→cyan; 12% adjacent-duo only
281
+ (`cyan+blue`, `blue+violet`, `violet+magenta`). Never rainbow, never
282
+ cyan+magenta. 6% of a shell's sparks crackle — an on/off chatter at 24 Hz
283
+ (`flickerNoise > 0.5 ? ×1.9 : ×0.25`) instead of a smooth fade.
284
+ - Intro glyph hues: **H = cyan, D = blue, R = violet, ? = magenta**.
285
+ - Hue jitter ±7° per shell, ±3° per spark.
286
+ - Embers are palette-tinted (dimmed, desaturated parent hue), cooling to
287
+ `#ffcf9c` only at low luminance — **not** orange. Smoke is a near-black cool
288
+ grey `#0a0b10` that picks up 8–12% of the nearest shell hue when re-lit.
289
+ - Ascent comet head is white-hot `#fff2e0` with a short tail tinted toward the
290
+ incoming hue.
291
+
292
+ ### Brightness ladder (pre-exposure)
293
+
294
+ Intro flash **3.4** · ambient flash **2.1** · fresh spark **2.2** · steady
295
+ **0.9** · terminal **0.15** · hue-hot **2.5 → 1.2** · ascent head **2.8** / tail
296
+ **0.45** · burst streak **0.5** · ember mean **0.35** (flicker ×0.6–2.0 @
297
+ 8–14 Hz irregular) · smoke **0.05–0.12** · glyph hold **1.3** · glyph release
298
+ **2.6** · haze ceiling behind the card **≤ 0.18** (hard).
299
+
300
+ - **Must exceed white** (HDR, transient, small area): flashes, fresh sparks,
301
+ comet, glyph release, hue-hot.
302
+ - **Must not bloom**: steady sparks, embers, smoke, haze, streaks. The frame
303
+ reads as mostly deep black with surgical over-white points.
304
+
305
+ ### Fades
306
+
307
+ - Spark, two-stage: `L 0→0.7` flash 2.2→0.9 (first ~150 ms drop, then slow burn);
308
+ `L 0.7→1.0` cubic ease-in to 0, shrink ~15%, desaturate warm.
309
+ - Core flash, exponential: 50% @ 60 ms, 10% @ 180 ms, gone by 350 ms; crossfades
310
+ to hue while decaying.
311
+ - Accumulation decay: ascent comet persists 350–500 ms (×0.90–0.93/frame @
312
+ 60 fps); burst streaks 150–220 ms (×0.82–0.86). Smoke expands + fades over
313
+ 1.5–3 s.
314
+ - Universal: the last 10% of life is multiplied by `smoothstep(1.0, 0.9, L)`;
315
+ alpha and size ease together; nothing is ever removed at non-zero brightness.
316
+
317
+ ### Composition & negative space
318
+
319
+ - Launch origin off-screen: `y ∈ [1.02, 1.08]`, `x ∈ [0.08, 0.92]` (ambient x
320
+ weighted to the sides).
321
+ - Ambient apex zones (`x0,y0,x1,y1 · weight`): UL `.04,.08,.34,.40 · .28` · UR
322
+ `.66,.08,.96,.40 · .28` · top-centre `.34,.05,.66,.24 · .18` · left-mid
323
+ `.02,.34,.20,.62 · .13` · right-mid `.80,.34,.98,.62 · .13`.
324
+ - Max simultaneous: ambient 2–3 (rare 4); intro exactly 4, staggered.
325
+ - Burst radius (fraction of min-dimension): ambient 0.10–0.20, feature 0.24,
326
+ glyph 0.14, hard max 0.28.
327
+ - **Keep-clear** rect: desktop `(0.28, 0.30, 0.72, 0.82)`, mobile
328
+ `(0.10, 0.22, 0.90, 0.90)`. Apexes are hard-rejected inside it; drift-in
329
+ particles are soft-dimmed. Rockets may transit thin/fast; their trails dim
330
+ inside. Haze behind the card is capped at 0.18 encoded.
331
+
332
+ ### Rhythm
333
+
334
+ - Ambient: Poisson, mean 2200 ms, clamped `[900, 5200]`.
335
+ - Double-launch 18% of the time, +120–320 ms later into a mirrored zone. No
336
+ finales.
337
+ - **Intro (≈2.7 s)**: launches at t = 0 / 180 / 360 / 540 ms; detonations ≈ 700 /
338
+ 880 / 1060 / 1300 ms; the "?" dot accent (magenta) ~1450 ms; hold until ~2.0 s
339
+ (word readable 500–600 ms); release cascade 500 ms; card panel-in ~2.3–2.4 s;
340
+ settled ≈ 3.1 s. Post-intro pause 800–1000 ms, then a first small single low
341
+ shell.
342
+
343
+ ### Realism anchors
344
+
345
+ Session wind ±0.02–0.05 on ascents and drift · detonation at `vy ≈ 0` plus an
346
+ 80–140 ms fuse hang · all sparks ballistic (willow = slower, longer-lived) ·
347
+ smallest embers hit terminal velocity and outlive everything · apex smoke puffs
348
+ drift and are re-lit by later bursts · per-shell depth `∈ [0,1]` dims
349
+ size/brightness/saturation, far shells edge-biased · break asymmetry ±12% plus 5%
350
+ stragglers at 1.4× · color-cooling white→hue→warm-white · crackle strobe ~24 Hz
351
+ (1-in-6 sparks, or the 3% silver shells), 0.2 ↔ 2.4.
352
+
353
+ ### SDR degradation
354
+
355
+ Exposure pinned to 1.0 · soft-knee start 0.8, asymptotic to 1.0 · ladder
356
+ unchanged (the over-white separation is simply lost) · bigger-not-brighter: core
357
+ radius ×1.3–1.5 with a longer falloff · crossfade to hue earlier + global
358
+ saturation +10–15% · everything sub-white is identical to HDR.
359
+
360
+ ## Physics model
361
+
362
+ A DOM/GPU-free simulation core (`fireworks-shared.ts`). Positions are normalized
363
+ `[0,1]`, top-left, y-down. Velocities and accelerations are in **visual units** —
364
+ screen-heights per second, isotropic — with aspect `A = canvasW / canvasH`
365
+ applied only at horizontal integration (`pos.x += (vel.x / A)·dt`) and when
366
+ converting a normalized position error to visual units. All `step()` randomness
367
+ is seed-derived (deterministic); `Math.random` only ever sits behind an
368
+ injectable `rng()` at spawn/schedule time.
369
+
370
+ ### World
371
+
372
+ `G = 3.6` (visual, +y down) · `DT_MAX = 0.033` · launch `y ∈ [1.02, 1.08]`,
373
+ `x ∈ [0.08, 0.92]`. Semi-implicit Euler: `vel += a·dt; pos.x += (vel.x/A)·dt;
374
+ pos.y += vel.y·dt`. Drag: `vel /= (1 + drag·dragScale·dt)`. Death gate:
375
+ `brightnessOut = curve · smoothstep(1.0, 0.9, lifeT)`, `lifeT = age / ttl`.
376
+
377
+ ### Per-type behavior
378
+
379
+ | Type | gravScale | drag (1/s) | ttl (s) | size (norm-h) | windScale |
380
+ | ------ | --------- | -------------------- | ----------------- | ------------- | --------- |
381
+ | rocket | 1.0 | 0.6 | flight + hang | 0.004 head | 0.05 |
382
+ | spark | 0.15 | 2.2 (terminal ~0.25) | 0.75–1.3 | 0.0025–0.004 | 0.30 |
383
+ | ember | 0.30 | 2.4 (terminal ~0.45) | 1.4–2.4 | 0.002–0.003 | 0.70 |
384
+ | smoke | −0.05 → 0 | 3.0 | 1.5–3.0 | 0.010 → 0.050 | 1.0 |
385
+ | flash | 0 | 0 | 0.05–0.09 | 0.025–0.05 | 0 |
386
+ | seeker | 0 → 1.0 | 1.2 (pop) | phased (0.9 rel.) | 0.003–0.005 | 0.4 |
387
+
388
+ Debris gravity is a **fraction** of the rocket's, and that is a look decision,
389
+ not a physical one: a spark's terminal velocity is `gravScale·G / drag`, and at
390
+ full gravity that is several times the burst's own expansion speed — the shell
391
+ never opens into a sphere, it rains straight down as a fountain. Ember gravity
392
+ stays above spark gravity so a willow still droops harder than a peony. Each
393
+ non-straggler spark also draws its own `dragScale ∈ [0.85, 1.30]`: identical
394
+ drag makes every spark fall at exactly the same speed, which combs the debris
395
+ into a parallel curtain.
396
+
397
+ Session wind is seeded once: `wx ∈ ±[0.02, 0.05]`, `wy ∈ ±[0.01, 0.02]` visual
398
+ accel, applied as `a += w · windScale[type]`.
399
+
400
+ ### Deterministic flicker
401
+
402
+ ```
403
+ hash11(x) = fract(sin(x·127.1)·43758.5453)
404
+ flickerNoise(seed, age, freq): t = age·freq; i = floor(t); f = t − i;
405
+ a = hash11(seed+i); b = hash11(seed+i+1); u = f·f·(3−2f); return a + (b−a)·u
406
+ ember mul = 0.6 + 1.4·flickerNoise(seed, age, 8 + 6·hash11(seed))
407
+ crackle = flickerNoise(seed, age, 24) > 0.5 ? hi : lo
408
+ ```
409
+
410
+ ### Shells
411
+
412
+ | Kind | high | mid | low | ember frac | smoke | radius (×scale) | builder |
413
+ | ------ | ----------- | ---- | ---- | ---------- | ----- | --------------------- | ----------- |
414
+ | peony | 320 | 200 | 120 | 0.15 | 1 | 0.10–0.20 (feat 0.24) | sphereBurst |
415
+ | willow | 200 | 130 | 80 | 0.80 | 1–2 | 0.14–0.22 | willowBurst |
416
+ | ring | 180 | 120 | 70 | 0.10 | 0 | 0.12–0.20 | ringBurst |
417
+ | glyph | pts+garnish | +25% | +15% | garnish | 1 | 0.14 | glyphPoints |
418
+
419
+ Break asymmetry ±12% via `hash11(seed+i)`; 5% stragglers at 1.4× speed,
420
+ `dragScale ×0.7`. Depth `d`: `size ×(1 − 0.5d)`, `brightness ×(1 − 0.45d)`,
421
+ `saturation ×(1 − 0.35d)`, `burst radius ×(1 − 0.25d)`. The dim rides on the
422
+ particle (pool field `depthDim`) and multiplies whatever the type's brightness
423
+ curve resolves to each frame, so it reaches sparks, embers, smoke, the
424
+ detonation flash, and glyph seekers alike — not just the spawn value.
425
+
426
+ ### Rockets
427
+
428
+ `solveLaunch` inverts the same model `integrate` runs the rocket through —
429
+ gravity `G`, linear drag `k = 0.6`, the session wind (rocket `windScale`), and
430
+ the `pos.x += (vel.x / A)·dt` aspect divide — so the shell breaks at the apex it
431
+ was asked for. Vertically, `vu(t) = (vu0 + g/k)·e^(−kt) − g/k`: given `flightMs`
432
+ that solves to `vu0 = (g/k)·(e^(k·flight) − 1)` (`vy = 0` exactly at flight time,
433
+ apex only sets the horizontal target); otherwise `vu0` is Newton-solved from the
434
+ rise `vu0/k − (g/k²)·ln(1 + k·vu0/g) = from.y − apex.y` and the flight follows
435
+ from it. Horizontally `Δx·A = (ax/k)·t + (vx0 − ax/k)·(1 − e^(−kt))/k`. Passing
436
+ no medium (`drag = 0`, `A = 1`, no wind) collapses both back to the undamped
437
+ `flight = sqrt(2·(from.y − apex.y)/G)`, `v0x = (apex.x − from.x)/flight` forms.
438
+ A ballistic solve fed to the damped integrator undershoots the apex by ~25% of
439
+ the rise and misses horizontally on any non-square canvas.
440
+
441
+ Fuse hang 80–140 ms seeded (the intro "?" uses
442
+ 140); `breakMs = flightMs + hangMs`. At break: enqueue the shell at the current
443
+ position plus one flash, then kill the rocket. Trail uses a fractional
444
+ accumulator (`emit += trailRate·dt; while (emit ≥ 1) { spawn; emit-- }`) — trail
445
+ sparks are ttl 0.22–0.45, size 0.0018, ascent-tail brightness, and scatter
446
+ laterally (`±0.035` x, `±0.02` y) — a rigidly co-linear trail reads as a drawn
447
+ wire rather than a comet shedding sparks. The head is ascent-head brightness,
448
+ and the rocket's **position** (never its velocity) carries a 11 Hz lateral
449
+ wobble of `0.045`/s so the climb is not a ruler-straight column; leaving the
450
+ velocity alone keeps the solved apex intact. The trail hue is the comet head mixed 25%
451
+ toward **that launch's** first shell hue (stored on the launch spec), so a
452
+ branded palette never gets the built-in cyan on its ascent.
453
+
454
+ ### Glyph seekers
455
+
456
+ 1. **Pop** (0–80 ms): velocity toward the seeker's own target, magnitude
457
+ `dist / 0.08`; spring off, drag 1.2, gravity off.
458
+ 2. **Spring** (ω = 6, ζ = 0.9), per axis in visual units:
459
+ `a = −ω²(p − target) − 2ζω·v`. Stable (`ω·dt = 0.198 ≪ 2` at 33 ms).
460
+ 3. **Hold** until `releaseAt`: brightness `1.3·(0.85 + 0.3·flicker)`.
461
+ 4. **Release**: spring off, gravity → 1.0, pop-2.6 decay, ttl 0.9 s, two-stage
462
+ fade. The sim self-triggers release from `releaseAtMs`, so the renderer stays
463
+ agnostic.
464
+
465
+ ### Per-frame pipeline (`step`)
466
+
467
+ 1. Clamp `dt`.
468
+ 2. Drain the spawn queue (previous-frame detonations; over capacity →
469
+ `droppedSpawns++`).
470
+ 3. Per live particle: apply accel (gravity + wind) → type behavior (rocket
471
+ trail/break; seeker phases; smoke expand; spark two-stage + life-color; ember
472
+ flicker + terminal velocity; flash exp decay) → integrate → age → brightness
473
+ `= curve · smoothstep(1, 0.9, lifeT)` → dead → **swap-remove** (re-examine the
474
+ swapped index).
475
+ 4. `writeInstances`.
476
+
477
+ **Instance layout (8 floats)**: `[posX, posY, size, r·bright, g·bright, b·bright,
478
+ velX·STRETCH, velY·STRETCH]` with `STRETCH = 0.010 s`. The renderer draws a
479
+ velocity-stretched quad (major axis from the velocity terms, capped at 2.5× the
480
+ minor), two-lobe gaussian (hot core + soft halo), additive. Swap-remove keeps
481
+ the pool contiguous — no freelist; allocate at `count++`, reject at capacity.
482
+
483
+ Two things about that quad are load-bearing and identical in both renderers:
484
+
485
+ - **`QUAD_PAD = 1.35`.** The quad is oversized and the gaussian is windowed to
486
+ exactly zero at its edge (`smoothstep(PAD, 0.6·PAD, r)`). Unwindowed, the halo
487
+ still carries ~7% of its peak where the geometry stops, and additive blending
488
+ renders that step as a **hard-edged square** around anything bright — a
489
+ detonation flash reads as a lit box, not a glow.
490
+ - **Stretch is deliberately short.** The accumulation buffer already smears a
491
+ moving particle into a line; a long per-instance stretch on top of it turns
492
+ every spark into a needle.