@signal9/era-ui 3.6.0 → 3.7.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,744 @@
1
+ <script module lang="ts">
2
+ /**
3
+ * SIGNALNINE dish — the logo, in one self-contained file.
4
+ *
5
+ * Zero imports and zero dependencies, deliberately: this file is the mark's
6
+ * portable master copy, so it stays droppable into any Svelte 5 project
7
+ * unchanged. It is the one component in the library that does NOT consume
8
+ * --era-* tokens — a logo has fixed brand geometry and colour, and must
9
+ * render identically whatever density, surface, or theme surrounds it.
10
+ * Do not "fix" that by tokenising it.
11
+ *
12
+ * A nonagon satellite dish modeled as a true closed shell (inner bowl, outer
13
+ * underside cone, rim wall), orthographically projected to plain SVG polygons
14
+ * with painter depth-sorting, backface culling, and Lambert shading in oklch.
15
+ * No rendering library — the math below is the product.
16
+ *
17
+ * The render math and physics tables are exported from this module block, so
18
+ * tooling (labs, exporters, tests) can reuse them without duplicating
19
+ * geometry:
20
+ * import { Logo, renderLogoMarkup, PAL } from './index.js';
21
+ *
22
+ * Colors, geometry constants, and defaults are final brand decisions.
23
+ *
24
+ * Ported from blog.signalnine.dev (src/lib/SignalnineDish.svelte); era-ui is
25
+ * now the canonical home. Public types/functions were renamed Dish* -> Logo*;
26
+ * the geometry and physics are untouched.
27
+ */
28
+
29
+ export type LogoPalette = 'amber' | 'chrome' | 'steel';
30
+ export type LogoState =
31
+ 'manual' | 'idle' | 'loading' | 'search' | 'transmit' | 'success' | 'error' | 'sleep';
32
+ export type HueOverride = { hD: number; hL: number; C: number };
33
+ export type Ring = { r: number; rise: number; a: number; w: number };
34
+
35
+ export interface LogoParams {
36
+ /** 0 flat plate → 100 deepest dish (mouth always full width). */
37
+ depth?: number;
38
+ /** Core-inlay dilate, 40–240 (%). */
39
+ inset?: number;
40
+ /** View elevation, 0 flat 2D face-on → 100 edge-on/underside. */
41
+ tilt?: number;
42
+ /** Screen-plane rotation, degrees. */
43
+ aim?: number;
44
+ /** Key-light orbit around the dish axis, degrees. */
45
+ light?: number;
46
+ palette?: LogoPalette;
47
+ /** Global luminance multiplier (sleep dims to 0.5). */
48
+ dim?: number;
49
+ /** Rotor spin, degrees (loading preset). */
50
+ spin?: number;
51
+ /** Core opacity multiplier (error blink). */
52
+ feedBlink?: number;
53
+ /** Core radius multiplier (transmit pulse). */
54
+ feedPulse?: number;
55
+ /** Rising transmit/success rings. */
56
+ rings?: Ring[] | null;
57
+ /** State recolor (error red, success green). */
58
+ hueOv?: HueOverride | null;
59
+ }
60
+
61
+ export const LOGO_STATES: LogoState[] = [
62
+ 'manual',
63
+ 'idle',
64
+ 'loading',
65
+ 'search',
66
+ 'transmit',
67
+ 'success',
68
+ 'error',
69
+ 'sleep'
70
+ ];
71
+
72
+ const D2R = Math.PI / 180;
73
+ const R = 80;
74
+
75
+ export const wrap = (d: number) => ((((d + 180) % 360) + 360) % 360) - 180;
76
+ export const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
77
+ export const norm360 = (v: number) => ((v % 360) + 360) % 360;
78
+
79
+ // extreme dynamic range: L spans deep shadow → near-white; hue warms in shadow
80
+ // and pales toward the highlight; chroma peaks in the midtones (bell), like
81
+ // real metal.
82
+ export const PAL: Record<
83
+ LogoPalette,
84
+ {
85
+ Cm: number;
86
+ hD: number;
87
+ hL: number;
88
+ loI: number;
89
+ hiI: number;
90
+ loO: number;
91
+ hiO: number;
92
+ dot: string;
93
+ tile: string;
94
+ }
95
+ > = {
96
+ amber: {
97
+ Cm: 0.15,
98
+ // Burned amber. The shadow end sits at scorched orange-red (38°) and the
99
+ // HIGHLIGHT deliberately stops at 70° / L 0.86 — pushing it to white or
100
+ // past 80° is what made the mark read as pale yellow instead of hot
101
+ // metal. Verified in-gamut across the whole ramp, so nothing posterizes
102
+ // on the way to the highlight.
103
+ hD: 38,
104
+ hL: 70,
105
+ loI: 0.24,
106
+ hiI: 0.86,
107
+ loO: 0.18,
108
+ hiO: 0.36,
109
+ dot: 'oklch(0.93 0.08 72)',
110
+ tile: '#1c1206'
111
+ },
112
+ chrome: {
113
+ Cm: 0.09,
114
+ hD: 258,
115
+ hL: 208,
116
+ loI: 0.25,
117
+ hiI: 0.975,
118
+ loO: 0.22,
119
+ hiO: 0.4,
120
+ dot: 'oklch(0.985 0.008 215)',
121
+ tile: '#0c141b'
122
+ },
123
+ steel: {
124
+ Cm: 0.035,
125
+ hD: 252,
126
+ hL: 240,
127
+ loI: 0.27,
128
+ hiI: 0.94,
129
+ loO: 0.24,
130
+ hiO: 0.38,
131
+ dot: 'oklch(0.98 0.006 245)',
132
+ tile: '#101418'
133
+ }
134
+ };
135
+ export const PALETTES = Object.keys(PAL) as LogoPalette[];
136
+
137
+ function faceColor(
138
+ t: number,
139
+ P: (typeof PAL)[LogoPalette],
140
+ dim: number,
141
+ hueOv: HueOverride | null,
142
+ back: boolean
143
+ ) {
144
+ const x = Math.max(0, Math.min(1, t));
145
+ const smooth = x * x * (3 - 2 * x);
146
+ // Two curves on purpose. LUMINANCE blends smoothstep toward linear, which
147
+ // spaces the facets evenly instead of crushing the shadow end. HUE and
148
+ // CHROMA stay on the original smoothstep: they decide how amber the metal
149
+ // reads, and running them off the lifted curve drags every facet toward the
150
+ // pale end of the ramp — the mark goes yellow instead of amber.
151
+ const sL = 0.45 * smooth + 0.55 * x;
152
+ const lo = back ? P.loO : P.loI;
153
+ const hi = back ? P.hiO : P.hiI;
154
+ const L = (lo + (hi - lo) * sL) * dim;
155
+ const hD = hueOv ? hueOv.hD : P.hD;
156
+ const hL = hueOv ? hueOv.hL : P.hL;
157
+ const C = (hueOv ? hueOv.C : P.Cm) * (0.72 + 1.12 * smooth * (1 - smooth)); // chroma bell
158
+ return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${(hD + (hL - hD) * smooth).toFixed(1)})`;
159
+ }
160
+
161
+ // ---------- vec helpers ----------
162
+ type V3 = [number, number, number];
163
+ const sub = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
164
+ const cross = (a: V3, b: V3): V3 => [
165
+ a[1] * b[2] - a[2] * b[1],
166
+ a[2] * b[0] - a[0] * b[2],
167
+ a[0] * b[1] - a[1] * b[0]
168
+ ];
169
+ const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
170
+ const norm = (a: V3): V3 => {
171
+ const l = Math.hypot(a[0], a[1], a[2]) || 1;
172
+ return [a[0] / l, a[1] / l, a[2] / l];
173
+ };
174
+ const ptsAttr = (pts: V3[]) => pts.map((p) => p[0].toFixed(2) + ',' + p[1].toFixed(2)).join(' ');
175
+
176
+ // ---------- model: dish shell in model space (axis = +Y up, apex at origin) ----------
177
+ // Single-phase DEPTH: the mouth stays full width while the bowl deepens.
178
+ // 0% = flat plate, 100% = deepest dish. (No closed-bud regime — the core
179
+ // inlay can never be swallowed by the fold.)
180
+ const A_DISH = Math.atan2(55, R); // ~34.5°: deepest dish wall angle
181
+
182
+ // True closed shell: inner bowl + real underside cone + rim band. No
183
+ // screen-space tricks — every viewing angle renders the actual solid, so it
184
+ // sits on any background.
185
+ function buildModel(depthT: number, spinDeg: number) {
186
+ const h = R * Math.tan(depthT * A_DISH);
187
+ const t = 4.5 + 5 * depthT; // wall thickness
188
+ const verts: V3[] = [[0, 0, 0]]; // 0 = inner apex; 1..9 inner rim
189
+ for (let k = 0; k < 9; k++) {
190
+ const th = (-90 + k * 40 + spinDeg) * D2R;
191
+ verts.push([R * Math.cos(th), h, R * Math.sin(th)]);
192
+ }
193
+ verts.push([0, -t, 0]); // 10 = outer apex; 11..19 outer rim
194
+ for (let k = 0; k < 9; k++) {
195
+ const v = verts[1 + k];
196
+ verts.push([v[0], v[1] - t, v[2]]);
197
+ }
198
+ const faces: { ix: number[]; kind: 'in' | 'out' | 'band' }[] = [];
199
+ for (let k = 0; k < 9; k++) {
200
+ const j = (k + 1) % 9;
201
+ faces.push({ ix: [0, 1 + k, 1 + j], kind: 'in' }); // bowl gore
202
+ faces.push({ ix: [10, 11 + j, 11 + k], kind: 'out' }); // underside gore
203
+ faces.push({ ix: [1 + k, 1 + j, 11 + j, 11 + k], kind: 'band' }); // rim edge wall
204
+ }
205
+ return { verts, faces, h };
206
+ }
207
+
208
+ // ---------- projection ----------
209
+ // rho=90°: axis at viewer (flat 2D nonagon). 0°: edge-on. <0: underside shows.
210
+ function makeProjector(rho: number) {
211
+ const c = Math.cos(rho),
212
+ s = Math.sin(rho);
213
+ return (p: V3): V3 => [p[0], -(p[1] * c - p[2] * s), p[1] * s + p[2] * c];
214
+ }
215
+ const FILL = 0.1; // head-on sheen weight
216
+ const AMBIENT = 0.13; // bounce fill: keeps the away-facing facets readable
217
+ const TILT_SPAN = 106; // deg swept from flat (90°) to underside (-16°)
218
+
219
+ /** oklch → sRGB hex, for portable export. */
220
+ export function oklchToHex(Lc: number, C: number, H: number) {
221
+ const hr = (H * Math.PI) / 180,
222
+ a = C * Math.cos(hr),
223
+ b = C * Math.sin(hr);
224
+ const l_ = Lc + 0.3963377774 * a + 0.2158037573 * b;
225
+ const m_ = Lc - 0.1055613458 * a - 0.0638541728 * b;
226
+ const s_ = Lc - 0.0894841775 * a - 1.291485548 * b;
227
+ const l = l_ ** 3,
228
+ m = m_ ** 3,
229
+ s = s_ ** 3;
230
+ const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
231
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
232
+ const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
233
+ const gam = (c: number) => {
234
+ c = Math.max(0, Math.min(1, c));
235
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
236
+ };
237
+ const to = (v: number) =>
238
+ Math.round(gam(v) * 255)
239
+ .toString(16)
240
+ .padStart(2, '0');
241
+ return '#' + to(r) + to(g) + to(bl);
242
+ }
243
+
244
+ /** One frame of the dish, as inner SVG markup for a -100 -100 200 200 canvas. */
245
+ export function renderLogoMarkup(params: LogoParams = {}): string {
246
+ const {
247
+ depth = 52,
248
+ inset = 100,
249
+ tilt = 55,
250
+ aim = 0,
251
+ light = REST_LIGHT,
252
+ palette = 'amber',
253
+ dim = 1,
254
+ spin = 0,
255
+ feedBlink = 1,
256
+ feedPulse = 1,
257
+ rings = null,
258
+ hueOv = null
259
+ } = params;
260
+ const P = PAL[palette] ?? PAL.amber;
261
+
262
+ const rr = (90 - (tilt / 100) * TILT_SPAN) * D2R;
263
+ const M = buildModel(depth / 100, spin);
264
+ const pr = makeProjector(rr);
265
+ const PV = M.verts.map(pr); // project every vertex ONCE
266
+ // key light ORBITS the dish axis (model space) so sliding it rotates the
267
+ // bright petal evenly around the ring, independent of the view tilt.
268
+ const phi = light * D2R;
269
+ const Lkey = norm([Math.cos(phi), 0.55, Math.sin(phi)]);
270
+
271
+ // shade + cull + depth-sort the shell (painter). Inner bowl gets full
272
+ // Lambert; underside and rim band stay flat dark tones.
273
+ const underFill = faceColor(0.16, P, dim, hueOv, true);
274
+ const bandFill = faceColor(0.3, P, dim, hueOv, true);
275
+ const polys: { z: number; d: string; fill: string }[] = [];
276
+ for (const f of M.faces) {
277
+ const pts = f.ix.map((i) => M.verts[i]);
278
+ let n = norm(cross(sub(pts[1], pts[0]), sub(pts[2], pts[0])));
279
+ const orient: V3 =
280
+ f.kind === 'in'
281
+ ? [0, 1, 0]
282
+ : f.kind === 'out'
283
+ ? [0, -1, 0]
284
+ : [(pts[0][0] + pts[1][0]) / 2, 0, (pts[0][2] + pts[1][2]) / 2];
285
+ if (dot(n, orient) < 0) n = n.map((v) => -v) as V3; // explicit outward orientation
286
+ const ns = pr(n);
287
+ if (ns[2] <= 0.015) continue; // backface cull — the shell is closed
288
+ const sp = f.ix.map((i) => PV[i]);
289
+ let fill;
290
+ if (f.kind === 'in') {
291
+ const lit = Math.max(0, dot(n, Lkey)) + Math.max(0, ns[2]) * FILL;
292
+ fill = faceColor(Math.min(1, AMBIENT + (1 - AMBIENT) * lit), P, dim, hueOv, false);
293
+ } else fill = f.kind === 'out' ? underFill : bandFill;
294
+ polys.push({ z: sp.reduce((a, p) => a + p[2], 0) / sp.length, d: ptsAttr(sp), fill });
295
+ }
296
+ polys.sort((p, q) => p.z - q.z);
297
+
298
+ // vertical recenter over the whole projected solid
299
+ let miny = 1e9,
300
+ maxy = -1e9;
301
+ for (const p of PV) {
302
+ if (p[1] < miny) miny = p[1];
303
+ if (p[1] > maxy) maxy = p[1];
304
+ }
305
+ const dy = -(miny + maxy) / 2;
306
+
307
+ // aim: rotate in the screen plane — exact for a 9-fold-symmetric solid
308
+ let s = `<g><g transform="rotate(${aim.toFixed(1)}) translate(0,${dy.toFixed(2)})">`;
309
+ // stroke = own fill at hairline width: kills antialiasing seams without
310
+ // visible separators between facets
311
+ for (const p of polys)
312
+ s += `<polygon points="${p.d}" fill="${p.fill}" stroke="${p.fill}" stroke-width="0.5" stroke-linejoin="miter"/>`;
313
+
314
+ // CORE INLAY: dark nonagon socket + bright nonagon core on the bowl
315
+ // surface. Visible exactly when the sightline over the near rim reaches
316
+ // the apex (true geometry, not a hack): tan(view elevation) must exceed
317
+ // the wall slope h/R.
318
+ const axisZ = pr([0, 1, 0])[2];
319
+ const vis = M.h < 0.01 ? 1 : Math.max(0, Math.min(1, ((Math.tan(rr) * R) / M.h - 1) * 2.5));
320
+ const K = axisZ > 0 ? Math.min(1, axisZ * 1.6) * vis : 0;
321
+ if (K > 0.02) {
322
+ const ring = (t: number) =>
323
+ M.verts
324
+ .slice(1, 10)
325
+ .map((v) => pr([v[0] * t, v[1] * t, v[2] * t]))
326
+ .map((p) => p[0].toFixed(2) + ',' + p[1].toFixed(2))
327
+ .join(' ');
328
+ const hD = hueOv ? hueOv.hD : P.hD,
329
+ Cm = hueOv ? hueOv.C : P.Cm;
330
+ const dil = inset / 100;
331
+ const tS = Math.min(0.88, 0.21 * dil),
332
+ tC = Math.min(0.7, 0.115 * dil);
333
+ s +=
334
+ `<polygon points="${ring(tS)}" fill="oklch(${(0.14 * dim).toFixed(3)} ${(Cm * 0.45).toFixed(3)} ${hD})" opacity="${K.toFixed(2)}"/>` +
335
+ `<polygon points="${ring(tC * feedPulse)}" fill="${P.dot}" opacity="${(K * feedBlink).toFixed(2)}"/>`;
336
+ }
337
+ if (rings)
338
+ for (const ring of rings) {
339
+ let pts = '';
340
+ for (let i = 0; i <= 36; i++) {
341
+ const th = (i / 36) * 2 * Math.PI;
342
+ const p = pr([ring.r * Math.cos(th), M.h + ring.rise, ring.r * Math.sin(th)]);
343
+ pts += p[0].toFixed(1) + ',' + p[1].toFixed(1) + ' ';
344
+ }
345
+ s += `<polyline points="${pts.trim()}" fill="none" stroke="${P.dot}" stroke-width="${ring.w.toFixed(2)}" opacity="${ring.a.toFixed(2)}" stroke-linejoin="round"/>`;
346
+ }
347
+ return s + '</g></g>';
348
+ }
349
+
350
+ /** Standalone portable SVG of a pose — oklch converted to sRGB hex. */
351
+ export function logoSvgDocument(params: LogoParams = {}, size = 400): string {
352
+ const inner = renderLogoMarkup(params).replace(
353
+ /oklch\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s*\)/g,
354
+ (_m, l, c, h) => oklchToHex(+l, +c, +h)
355
+ );
356
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-100 -100 200 200" width="${size}" height="${size}">${inner}</svg>`;
357
+ }
358
+
359
+ // ---------- pedestal physics ----------
360
+ // Every degree of freedom is a damped torsion spring, as if the dish were a
361
+ // mass on a real gimbal mount. Presets set TARGETS (and fire impulses); the
362
+ // springs do the moving. Direct input bypasses them entirely.
363
+ export const SPR = {
364
+ depth: { k: 70, c: 12 },
365
+ inset: { k: 80, c: 13 },
366
+ tilt: { k: 34, c: 7 },
367
+ aim: { k: 26, c: 5.2 },
368
+ light: { k: 90, c: 17 }
369
+ } as const;
370
+ export type Dof = keyof typeof SPR;
371
+ export type LogoSim = Record<Dof, { v: number; vel: number }> & {
372
+ /** Where a spinning light has committed to stop. Always AHEAD of the
373
+ * current angle, so the sweep never reverses to get home. */
374
+ light: { v: number; vel: number; park?: number | null };
375
+ };
376
+ export type Fx = {
377
+ spinV: number;
378
+ dim: number;
379
+ feedBlink: number;
380
+ feedPulse: number;
381
+ rings: Ring[] | null;
382
+ hueOv: HueOverride | null;
383
+ };
384
+
385
+ export function createSim(p: Required<Pick<LogoParams, Dof>>): LogoSim {
386
+ return {
387
+ depth: { v: p.depth, vel: 0 },
388
+ inset: { v: p.inset, vel: 0 },
389
+ tilt: { v: p.tilt, vel: 0 },
390
+ aim: { v: p.aim, vel: 0 },
391
+ light: { v: p.light, vel: 0 }
392
+ };
393
+ }
394
+
395
+ /** Orbit speed above which the light is treated as spinning and must finish
396
+ * its rotation rather than be sprung to the nearest angle. */
397
+ const SPIN_CARRY = 60; // deg/s
398
+ /** How fast the key light sweeps while loading — about 1.2 turns/sec. */
399
+ const SWEEP_RATE = 440; // deg/s
400
+ /** Where the key light parks at rest. Every preset targets this (or an offset
401
+ * from it), so moving it re-lights the mark everywhere at once. */
402
+ export const REST_LIGHT = 250; // deg
403
+
404
+ /** Fixed physics step. Long frames are sub-stepped rather than integrated in
405
+ * one huge leap — a stalled frame (e.g. the main thread finishing a
406
+ * navigation) would otherwise blow the spring up and snap the pose. */
407
+ export const FIXED_STEP = 1 / 120;
408
+
409
+ /**
410
+ * Advance every spring one step toward its target.
411
+ *
412
+ * `coastLight` cuts the light's spring drive while it is still orbiting fast,
413
+ * leaving only drag — the sweep spins down like a motor losing power instead
414
+ * of being yanked to the nearest rest angle (which reverses it) or whipped to
415
+ * one a full turn ahead (which speeds it up). Once it drops below SPIN_CARRY
416
+ * the spring takes over and parks it.
417
+ */
418
+ export function integrate(
419
+ sim: LogoSim,
420
+ tgt: Record<Dof, number>,
421
+ dt: number,
422
+ coastLight = false
423
+ ) {
424
+ if (!coastLight) sim.light.park = null; // drive restored — abandon any parking plan
425
+ for (const key of Object.keys(SPR) as Dof[]) {
426
+ const s = sim[key];
427
+ const p = SPR[key];
428
+ // A spinning light always FINISHES ITS ROTATION: it commits to the next
429
+ // arrival of the rest angle that lies ahead, then decelerates to land on
430
+ // it with ~zero speed. Never reverses, never speeds up. If that arrival
431
+ // is too close to stop gracefully, it goes around once more.
432
+ // A spinning light FINISHES ITS ROTATION at full speed: on losing drive it
433
+ // commits to the next pass of the rest angle ahead of it and holds the
434
+ // sweep rate the whole way — no easing, no reversal — then stops dead on
435
+ // its mark. Landing exactly on home means the halt shows as motion
436
+ // ending, not as the mark jumping.
437
+ if (key === 'light' && coastLight) {
438
+ const L = sim.light;
439
+ if (L.park != null || L.vel > SPIN_CARRY) {
440
+ if (L.park == null) L.park = L.v + norm360(tgt.light - L.v); // next pass of home
441
+ const step = L.vel * dt; // velocity untouched: constant cruise
442
+ if (L.park - L.v > step) {
443
+ L.v += step;
444
+ continue;
445
+ }
446
+ L.v = L.park; // arrived
447
+ L.vel = 0;
448
+ L.park = null;
449
+ }
450
+ }
451
+ let d = tgt[key] - s.v;
452
+ if (key === 'aim' || key === 'light') d = wrap(d);
453
+ s.vel += (p.k * d - p.c * s.vel) * dt;
454
+ s.v += s.vel * dt;
455
+ }
456
+ sim.depth.v = clamp(sim.depth.v, 0, 100);
457
+ sim.tilt.v = clamp(sim.tilt.v, 0, 100);
458
+ sim.inset.v = clamp(sim.inset.v, 40, 240);
459
+ }
460
+
461
+ /**
462
+ * Per-state spring targets and visual effects. Mutates `sim` for impulses.
463
+ *
464
+ * Tilt targets are expressed RELATIVE to `baseTilt` (the mark's resting
465
+ * elevation), so a mark parked at a different tilt keeps its silhouette
466
+ * through every preset instead of springing back to the stock 55. At the
467
+ * default 55 the numbers are identical to the original absolute table.
468
+ */
469
+ export function stateFrame(
470
+ st: LogoState,
471
+ t: number,
472
+ scl: number,
473
+ sim: LogoSim,
474
+ jolt: { last: number; sign: number },
475
+ baseTilt = 55
476
+ ): { tgt: Record<Dof, number>; fx: Fx } {
477
+ const fx: Fx = { spinV: 0, dim: 1, feedBlink: 1, feedPulse: 1, rings: null, hueOv: null };
478
+ let tgt: Record<Dof, number> = {
479
+ depth: 52,
480
+ tilt: baseTilt,
481
+ aim: 0,
482
+ light: REST_LIGHT,
483
+ inset: scl
484
+ };
485
+ switch (st) {
486
+ case 'idle':
487
+ tgt = {
488
+ depth: 52 + 4 * Math.sin(t * 0.9),
489
+ tilt: baseTilt,
490
+ aim: 6 * Math.sin(t * 0.45),
491
+ light: REST_LIGHT + 15 * Math.sin(t * 0.3),
492
+ inset: scl
493
+ };
494
+ break;
495
+ case 'loading':
496
+ // Pure light orbit: the solid holds its resting pose and only the key
497
+ // light sweeps around the axis, so the mark reads as a spinner without
498
+ // the shape itself thrashing. No depth bob, no rotor spin.
499
+ tgt = { depth: 52, tilt: baseTilt, aim: 0, light: REST_LIGHT + t * SWEEP_RATE, inset: scl };
500
+ break;
501
+ case 'search':
502
+ // slew between hard targets — the spring gives momentum + overshoot
503
+ tgt = {
504
+ depth: 57,
505
+ tilt: baseTilt + 6 * Math.sin(t * 0.8),
506
+ aim: Math.floor(t / 2.4) % 2 ? 55 : -55,
507
+ light: REST_LIGHT,
508
+ inset: scl
509
+ };
510
+ break;
511
+ case 'transmit': {
512
+ tgt = { depth: 98, tilt: baseTilt + 19, aim: 0, light: REST_LIGHT, inset: scl }; // deepest collector
513
+ fx.rings = [0, 1, 2].map((i) => {
514
+ const ph = (t * 0.9 + i / 3) % 1;
515
+ return { r: 14 + ph * 95, rise: ph * 26, a: (1 - ph) * 0.85, w: 2.6 - 1.8 * ph };
516
+ });
517
+ fx.feedPulse = 1 + 0.5 * Math.sin(t * 6);
518
+ break;
519
+ }
520
+ case 'success': {
521
+ tgt = { depth: 52, tilt: baseTilt + 5, aim: 0, light: REST_LIGHT, inset: scl };
522
+ const ph = (t * 0.4) % 1;
523
+ fx.hueOv = { hD: 138, hL: 165, C: 0.12 };
524
+ fx.feedPulse = 1.15 + 0.15 * Math.sin(t * 3);
525
+ fx.rings = [{ r: 14 + ph * 100, rise: ph * 18, a: (1 - ph) * 0.5, w: 2 - 1.2 * ph }];
526
+ break;
527
+ }
528
+ case 'error':
529
+ tgt = { depth: 100, tilt: baseTilt, aim: 0, light: REST_LIGHT, inset: scl }; // slams to full depth
530
+ fx.hueOv = { hD: 16, hL: 34, C: 0.18 };
531
+ fx.feedBlink = Math.sin(t * 10) > 0 ? 1 : 0.15;
532
+ if (t - jolt.last > 2.0) {
533
+ // physical jolt: velocity impulse
534
+ jolt.last = t;
535
+ jolt.sign = -jolt.sign;
536
+ sim.aim.vel += jolt.sign * 150;
537
+ sim.tilt.vel += 35;
538
+ }
539
+ break;
540
+ case 'sleep':
541
+ tgt = {
542
+ depth: 71 + 4 * Math.sin(t * 0.5),
543
+ tilt: baseTilt - 5,
544
+ aim: 150,
545
+ light: REST_LIGHT - 10,
546
+ inset: scl
547
+ }; // droops on its mount
548
+ fx.dim = 0.5;
549
+ fx.feedBlink = 0.25 + 0.1 * Math.sin(t * 0.7);
550
+ break;
551
+ }
552
+ return { tgt, fx };
553
+ }
554
+ </script>
555
+
556
+ <script lang="ts">
557
+ let {
558
+ depth = 52,
559
+ tilt = 55,
560
+ aim = 0,
561
+ light = REST_LIGHT,
562
+ inset = 100,
563
+ palette = 'amber',
564
+ state: stateProp = 'manual',
565
+ size = 272,
566
+ pressable = false,
567
+ class: className
568
+ }: {
569
+ depth?: number;
570
+ tilt?: number;
571
+ aim?: number;
572
+ light?: number;
573
+ inset?: number;
574
+ palette?: LogoPalette;
575
+ /** 'manual' renders a static pose; any other state animates its preset. */
576
+ state?: LogoState;
577
+ size?: number;
578
+ /** Deepen the bowl while held, then spring back on release. */
579
+ pressable?: boolean;
580
+ class?: string;
581
+ } = $props();
582
+
583
+ let reduced = $state(false);
584
+ // Reduced motion forces the static mark.
585
+ $effect(() => {
586
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
587
+ reduced = mq.matches;
588
+ const on = (e: MediaQueryListEvent) => (reduced = e.matches);
589
+ mq.addEventListener('change', on);
590
+ return () => mq.removeEventListener('change', on);
591
+ });
592
+ const eff: LogoState = $derived(reduced ? 'manual' : stateProp);
593
+
594
+ // ---------- press spring ----------
595
+ // Hold and the bowl deepens; release and it springs back with a little
596
+ // overshoot. Its own short-lived rAF, because a `manual` mark has no loop.
597
+ const PRESS_SPR = { k: 600, c: 30 }; // stiffer => quicker press, same feel
598
+ const PRESS_DEPTH = 70; // extra depth (0–100 scale) at full press
599
+ let pressV = $state(0);
600
+ let pressVel = 0;
601
+ let pressing = false;
602
+ let pressRaf = 0;
603
+
604
+ function pressLoop() {
605
+ if (pressRaf) return; // already settling
606
+ let last = performance.now();
607
+ const step = (now: number) => {
608
+ const dt = Math.min(0.05, (now - last) / 1000) || 0.016;
609
+ last = now;
610
+ const target = pressing ? 1 : 0;
611
+ pressVel += (PRESS_SPR.k * (target - pressV) - PRESS_SPR.c * pressVel) * dt;
612
+ pressV += pressVel * dt;
613
+ // snap to exact rest once the residual is imperceptible
614
+ if (!pressing && Math.abs(pressV) < 0.004 && Math.abs(pressVel) < 0.06) {
615
+ pressV = 0;
616
+ pressVel = 0;
617
+ pressRaf = 0;
618
+ return;
619
+ }
620
+ pressRaf = requestAnimationFrame(step);
621
+ };
622
+ pressRaf = requestAnimationFrame(step);
623
+ }
624
+
625
+ /** Pointer handling attached imperatively, so this decorative SVG doesn't
626
+ * need an interactive ARIA role — whatever link or button wraps the mark
627
+ * stays the real affordance. */
628
+ function press(node: Element) {
629
+ if (!pressable) return;
630
+ const down = () => {
631
+ pressing = true;
632
+ pressLoop();
633
+ };
634
+ const up = () => {
635
+ if (!pressing) return;
636
+ pressing = false;
637
+ pressLoop();
638
+ };
639
+ node.addEventListener('pointerdown', down);
640
+ node.addEventListener('pointercancel', up);
641
+ window.addEventListener('pointerup', up); // release anywhere
642
+ return {
643
+ destroy() {
644
+ node.removeEventListener('pointerdown', down);
645
+ node.removeEventListener('pointercancel', up);
646
+ window.removeEventListener('pointerup', up);
647
+ cancelAnimationFrame(pressRaf);
648
+ pressRaf = 0;
649
+ }
650
+ };
651
+ }
652
+
653
+ // Static pose — a pure derived, so manual renders cost zero rAF.
654
+ const staticMarkup = $derived(
655
+ renderLogoMarkup({
656
+ depth: clamp(depth + pressV * PRESS_DEPTH, 0, 100),
657
+ inset,
658
+ tilt,
659
+ aim,
660
+ light,
661
+ palette
662
+ })
663
+ );
664
+ // Preset frames land here; seeded with a static frame so SSR paints a real
665
+ // mark before hydration.
666
+ // svelte-ignore state_referenced_locally
667
+ let animMarkup = $state(renderLogoMarkup({ depth, inset, tilt, aim, light, palette }));
668
+ const markup = $derived(eff === 'manual' ? staticMarkup : animMarkup);
669
+
670
+ // ---------- preset loop ----------
671
+ // The simulation lives at component scope, NOT inside the loop effect, so a
672
+ // state change only swaps the spring TARGETS — position and velocity carry
673
+ // over. Re-seeding per state would throw both away and snap the pose (a
674
+ // retargeted spring must preserve position and velocity to stay continuous).
675
+ // svelte-ignore state_referenced_locally
676
+ const sim = createSim({ depth, inset, tilt, aim, light });
677
+ const jolt = { last: -9, sign: 1 };
678
+ let spinVel = 0;
679
+ let spinA = 0;
680
+
681
+ // Runs ONLY for preset states; a static mark costs zero CPU. Client-only
682
+ // ($effect never runs during SSR).
683
+ $effect(() => {
684
+ const st = eff;
685
+ if (st === 'manual') return;
686
+ let raf = 0;
687
+ const t0 = performance.now();
688
+ let lastNow = t0;
689
+ let acc = 0;
690
+ let frameLast = t0;
691
+ const loop = (now: number) => {
692
+ // Cap the catch-up window, then burn it down in FIXED_STEP slices: a
693
+ // stalled frame advances the springs by many small steps instead of one
694
+ // explosive one, so the pose stays continuous across a jank spike.
695
+ acc += Math.min(0.25, (now - lastNow) / 1000) || FIXED_STEP;
696
+ lastNow = now;
697
+ const t = (now - t0) / 1000;
698
+ const { tgt, fx } = stateFrame(st, t, sim.inset.v, sim, jolt, tilt);
699
+ for (let i = 0; acc >= FIXED_STEP && i < 30; i++) {
700
+ integrate(sim, tgt, FIXED_STEP, st !== 'loading');
701
+ acc -= FIXED_STEP;
702
+ }
703
+ const frameDt = Math.min(0.05, (now - frameLast) / 1000) || FIXED_STEP;
704
+ frameLast = now;
705
+ spinVel += (fx.spinV - spinVel) * Math.min(1, frameDt * 2.5); // rotor inertia
706
+ spinA = (spinA + spinVel * frameDt) % 360;
707
+ animMarkup = renderLogoMarkup({
708
+ depth: clamp(sim.depth.v + pressV * PRESS_DEPTH, 0, 100),
709
+ inset: sim.inset.v,
710
+ tilt: sim.tilt.v,
711
+ aim: wrap(sim.aim.v),
712
+ light: norm360(sim.light.v),
713
+ palette,
714
+ spin: spinA,
715
+ dim: fx.dim,
716
+ feedBlink: fx.feedBlink,
717
+ feedPulse: fx.feedPulse,
718
+ rings: fx.rings,
719
+ hueOv: fx.hueOv
720
+ });
721
+ raf = requestAnimationFrame(loop);
722
+ };
723
+ raf = requestAnimationFrame(loop);
724
+ return () => cancelAnimationFrame(raf);
725
+ });
726
+
727
+ /** Standalone SVG of the pose as currently rendered. */
728
+ export function exportSVG(): string {
729
+ return logoSvgDocument({ depth, inset, tilt, aim, light, palette });
730
+ }
731
+ </script>
732
+
733
+ <!-- eslint-disable svelte/no-at-html-tags -- markup comes from our own pure
734
+ render math over numeric parameters; never user-controlled input. -->
735
+ <svg
736
+ use:press
737
+ viewBox="-100 -100 200 200"
738
+ width={size}
739
+ height={size}
740
+ shape-rendering="geometricPrecision"
741
+ role="img"
742
+ aria-label="signalnine"
743
+ class={className}>{@html markup}</svg
744
+ >