@rydr/game-sdk 8.13.0 → 8.15.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.
Files changed (70) hide show
  1. package/dist/protocol/identity.d.ts +39 -0
  2. package/dist/protocol/identity.d.ts.map +1 -1
  3. package/dist/protocol/version.d.ts +1 -1
  4. package/dist/protocol/version.js +1 -1
  5. package/dist/three/README.md +132 -4
  6. package/dist/three/character/riders.d.ts.map +1 -1
  7. package/dist/three/character/riders.js.map +1 -1
  8. package/dist/three/controller/index.d.ts +2 -0
  9. package/dist/three/controller/index.d.ts.map +1 -1
  10. package/dist/three/controller/index.js +2 -0
  11. package/dist/three/controller/index.js.map +1 -1
  12. package/dist/three/controller/joycon-designs.d.ts +15 -1
  13. package/dist/three/controller/joycon-designs.d.ts.map +1 -1
  14. package/dist/three/controller/joycon-designs.js +302 -60
  15. package/dist/three/controller/joycon-designs.js.map +1 -1
  16. package/dist/three/controller/joycon-object.d.ts +216 -0
  17. package/dist/three/controller/joycon-object.d.ts.map +1 -0
  18. package/dist/three/controller/joycon-object.js +578 -0
  19. package/dist/three/controller/joycon-object.js.map +1 -0
  20. package/dist/three/controller/joycon-spec.d.ts +116 -4
  21. package/dist/three/controller/joycon-spec.d.ts.map +1 -1
  22. package/dist/three/controller/joycon-spec.js +128 -22
  23. package/dist/three/controller/joycon-spec.js.map +1 -1
  24. package/dist/three/controller/press-pulse.d.ts +89 -0
  25. package/dist/three/controller/press-pulse.d.ts.map +1 -0
  26. package/dist/three/controller/press-pulse.js +309 -0
  27. package/dist/three/controller/press-pulse.js.map +1 -0
  28. package/dist/three/controller/three-pad-renderer.d.ts +11 -22
  29. package/dist/three/controller/three-pad-renderer.d.ts.map +1 -1
  30. package/dist/three/controller/three-pad-renderer.js +44 -258
  31. package/dist/three/controller/three-pad-renderer.js.map +1 -1
  32. package/dist/three/rider/three-rider-rig.d.ts +12 -0
  33. package/dist/three/rider/three-rider-rig.d.ts.map +1 -1
  34. package/dist/three/rider/three-rider-rig.js +102 -9
  35. package/dist/three/rider/three-rider-rig.js.map +1 -1
  36. package/dist/ui/README.md +133 -7
  37. package/dist/ui/choice-card.d.ts +8 -0
  38. package/dist/ui/choice-card.d.ts.map +1 -1
  39. package/dist/ui/choice-card.js +1 -1
  40. package/dist/ui/choice-card.js.map +1 -1
  41. package/dist/ui/controller/controller-pad.d.ts +105 -2
  42. package/dist/ui/controller/controller-pad.d.ts.map +1 -1
  43. package/dist/ui/controller/controller-pad.js +347 -16
  44. package/dist/ui/controller/controller-pad.js.map +1 -1
  45. package/dist/ui/controller/renderer.d.ts +13 -0
  46. package/dist/ui/controller/renderer.d.ts.map +1 -1
  47. package/dist/ui/controller/renderer.js.map +1 -1
  48. package/dist/ui/controller/styles.js +50 -1
  49. package/dist/ui/controller/styles.js.map +1 -1
  50. package/dist/ui/dialogue-card.d.ts +8 -0
  51. package/dist/ui/dialogue-card.d.ts.map +1 -1
  52. package/dist/ui/dialogue-card.js +1 -1
  53. package/dist/ui/dialogue-card.js.map +1 -1
  54. package/dist/ui/hud-placement.d.ts +81 -0
  55. package/dist/ui/hud-placement.d.ts.map +1 -0
  56. package/dist/ui/hud-placement.js +74 -0
  57. package/dist/ui/hud-placement.js.map +1 -0
  58. package/dist/ui/index.d.ts +1 -0
  59. package/dist/ui/index.d.ts.map +1 -1
  60. package/dist/ui/index.js +1 -0
  61. package/dist/ui/index.js.map +1 -1
  62. package/dist/ui/rider-rig.d.ts +108 -10
  63. package/dist/ui/rider-rig.d.ts.map +1 -1
  64. package/dist/ui/rider-rig.js +299 -48
  65. package/dist/ui/rider-rig.js.map +1 -1
  66. package/dist/ui/solo-labeled-diamond.d.ts +13 -0
  67. package/dist/ui/solo-labeled-diamond.d.ts.map +1 -1
  68. package/dist/ui/solo-labeled-diamond.js +1 -1
  69. package/dist/ui/solo-labeled-diamond.js.map +1 -1
  70. package/package.json +4 -2
@@ -60,7 +60,8 @@
60
60
  * The scale runs `0 → 1.5 × ftp`, so the rider's own threshold sits at two thirds of the way up with
61
61
  * headroom above it for a sprint, and it carries a labelled FTP reference mark. A 150 W rider and a
62
62
  * 350 W rider therefore see the same gauge do the same thing for the same effort — never hard-code a
63
- * raw-watt scale (the same rule as {@link createPowerRace}). Zone colours are computed from %FTP and
63
+ * raw-watt scale (the same rule as {@link createPowerRace}). The effort colour is a continuous ramp
64
+ * over %FTP — grey-blue, blue, violet, pink, red, with no band boundaries to jump at — and is
64
65
  * are **local to this component**: they are deliberately NOT `@rydr/game-sdk/difficulty`'s ramp, which
65
66
  * colours the rider's *chosen calibration level* and would be a different quantity wearing the same
66
67
  * paint.
@@ -89,11 +90,16 @@
89
90
  * Every gauge is a CONTINUOUS fill; what changes is the paint and the tooltip's orientation.
90
91
  *
91
92
  * · `solid` (default) — flat opaque silhouette; a landscape tooltip with the figure beside the
92
- * title and the bar under both. Most legible; start here.
93
- * · `blueprint` — thin technical linework; a PORTRAIT tooltip whose bar runs vertically and which
94
- * carries NO figure at all, only the title and a tall bar marked at FTP. On that variant the bar
95
- * IS the reading: a rider glancing at a trainer screen reads a fill long before three digits, and
96
- * a number beside it competes with the thing it annotates.
93
+ * title and the bar under both. Most legible; start here. Its trainer's FLYWHEEL is the second
94
+ * reading — white, haloed in the gauge's own zone colour at a strength the effort sets, so the
95
+ * watts are legible from across a room without reading a number. The flywheel and not the front
96
+ * wheel: that disc is the one the legs actually drive, while the front wheel is parked on the floor
97
+ * driven by nothing.
98
+ * · `blueprint` — thin technical linework; a PORTRAIT tooltip whose bar runs vertically, with the
99
+ * caption above the figure and both stacked over a tall bar marked at FTP. On that variant the bar
100
+ * is still the reading — a rider glancing at a trainer screen reads a fill long before three
101
+ * digits — so the figure is set smaller than the landscape one: it answers "exactly how much",
102
+ * which the fill cannot, without taking the headline off the bar.
97
103
  * · `neon` — glowing outline; a landscape tooltip whose fill carries a bright leading edge.
98
104
  *
99
105
  * ## SVG for the drawing, CSS for the tooltip
@@ -112,11 +118,43 @@
112
118
  * is built once and a driver mutates cached nodes behind a dirty guard, exactly as `pad-svg.ts` does,
113
119
  * so an idle rig costs no DOM writes at 60 Hz.
114
120
  *
115
- * ── Sizing contract (same as the rest of the kit) ──────────────────────────────────────────────
116
- * Tuned for a trainer screen read from a few feet away while pedalling. Pick a `size` preset; don't
117
- * override the `.rrig-*` dimensions or wrap it in `transform: scale()`.
121
+ * ## In a game: a HUD piece
122
+ *
123
+ * The rig started as a panel for a page — an onboarding beat, a store page — and a game wants the same
124
+ * picture pinned into a corner of play while the rider rides. Two options cover that, and they are the
125
+ * only two a game needs:
126
+ *
127
+ * ```ts
128
+ * const rig = mountRiderRig(gameRoot, {
129
+ * ftp: session.identity.ftp,
130
+ * width: 300, // any size, not just the three presets
131
+ * place: { corner: "bottom-right", margin: 24 },// pinned, floating, click-through
132
+ * renderer: createThreeRiderRig, // optional: the 3D one
133
+ * });
134
+ * ```
135
+ *
136
+ * `place` is what turns it from a block in flow into a floating overlay; without it nothing about the
137
+ * component changes. Both are adjustable after mounting (`setWidth`, `setPlacement`) so a game can
138
+ * find the size and the corner by eye and paste the numbers back — `examples/ui/rig-hud.ts` is exactly
139
+ * that, a dev page over a fake game screen.
140
+ *
141
+ * **One 3D rig per screen.** `createThreeRiderRig` opens its own WebGL context beside the game's own
142
+ * scene. One HUD widget is a fair price for the picture; three is three contexts the game did not
143
+ * budget for. If the rig is decoration rather than the subject, the flat renderer is free — it is an
144
+ * `<svg>` — and it is the default for that reason.
145
+ *
146
+ * ── Sizing contract (same as the rest of the kit, with ONE exception) ──────────────────────────
147
+ * Tuned for a trainer screen read from a few feet away while pedalling. Don't override the `.rrig-*`
148
+ * dimensions or wrap it in `transform: scale()`.
149
+ *
150
+ * The exception is this component's own `width`, and it is narrow: the rig is a PICTURE, not a label,
151
+ * so it survives being re-sized in a way a keycap or a callout card does not — and a game pinning it
152
+ * into a corner genuinely cannot work from three fixed widths. `width` scales the tooltip's em base
153
+ * with the drawing rather than leaving the type behind, and it floors at 140px, below which the watts
154
+ * stop being readable. That is the whole exception: it licenses nothing elsewhere in the kit.
118
155
  * ────────────────────────────────────────────────────────────────────────────────────────────────
119
156
  */
157
+ import { applyHudPlacement, anchorHudHost } from "./hud-placement.js";
120
158
  const STYLE_ID = "rydr-rider-rig-styles";
121
159
  const NS = "http://www.w3.org/2000/svg";
122
160
  /**
@@ -132,6 +170,24 @@ const SIZES = {
132
170
  md: { w: 330, u: 10 },
133
171
  lg: { w: 440, u: 13 },
134
172
  };
173
+ /** Narrowest the rig may be drawn. Below this the watts stop being readable and it is a logo, not a gauge. */
174
+ const MIN_WIDTH = 140;
175
+ /**
176
+ * The em base for a FREE width.
177
+ *
178
+ * The three presets are not on a straight line through the origin, but they are close (240/7.5 = 32,
179
+ * 330/10 = 33, 440/13 = 33.8), so one divisor reproduces all three within a fraction of a pixel and
180
+ * keeps a hand-picked width in the same family as the presets. Sizing the bubble off the width is the
181
+ * point: a tooltip pinned at 10px sits comfortably at `md` and covers half the machine at 180px.
182
+ *
183
+ * The FLOOR is the part that matters, and it is the kit's own rule rather than a tweak: type has a
184
+ * minimum physical size on a trainer screen read from a few feet away, and proportion does not get to
185
+ * override it. Left unclamped, a 170px HUD rig printed its watts at about 8px — present, legible to
186
+ * nobody, and the exact failure the sizing contract exists to prevent. So below the `sm` preset's base
187
+ * the bubble stops shrinking and simply takes up more of the picture, which is the right trade: a
188
+ * small rig whose number can still be read beats a smaller one whose number cannot.
189
+ */
190
+ const emBase = (width) => Math.max(SIZES.sm.u, width / 33);
135
191
  /* ───────────────────────────── geometry ─────────────────────────────
136
192
  * One coordinate space, shared by all three designs, in a 200×200 viewBox.
137
193
  *
@@ -152,8 +208,20 @@ const WHEEL_R = 26;
152
208
  * flat rather than nose-up.
153
209
  */
154
210
  const AXLE = { x: 78, y: 152 };
155
- /** Front hub. Its tyre is on the ground (`y + WHEEL_R === GROUND`), and it never turns. */
156
- const FRONT = { x: 158, y: 152 };
211
+ /**
212
+ * Front hub. Its tyre is on the ground (`y + WHEEL_R === GROUND`), and it never turns.
213
+ *
214
+ * x is 163 and not 158, and the five units are load-bearing. At 158 the hub sat exactly `WHEEL_R` from
215
+ * the fork crown (`HEADTUBE_BOT`), which put the crown ON the tyre: the fork appeared to start inside
216
+ * the rubber and the tube read as spearing the wheel — worst in 3D, where a solid blade crosses a
217
+ * solid tyre with no line-art convention to excuse it. Moving the hub forward opens ~2.3 units of
218
+ * clearance, so the crown sits above the tyre the way a real fork's does and the blades run down
219
+ * beside the wheel to the hub.
220
+ *
221
+ * It lengthens the wheelbase to ~1.63 wheel diameters, which is still a road bike's number (the
222
+ * proportion note above allows 1.55; a lowrider starts nearer 2).
223
+ */
224
+ const FRONT = { x: 163, y: 152 };
157
225
  /** Bottom bracket — the crank's centre of rotation, and the anchor the whole rider hangs off. */
158
226
  const BB = { x: 112, y: 157 };
159
227
  const CRANK_R = 15;
@@ -224,18 +292,63 @@ const FLY_GEAR = 2.1;
224
292
  /** Cadence at which the motion streaks reach full strength. */
225
293
  const STREAK_FULL_RPM = 95;
226
294
  /**
227
- * %FTP zone colours — LOCAL to this component. See the module header on why this is not
295
+ * %FTP effort colours — LOCAL to this component. See the module header on why this is not
228
296
  * `@rydr/game-sdk/difficulty`'s ramp.
297
+ *
298
+ * A CONTINUOUS ramp, not a table of zones. It was six discrete bands (recovery, endurance, tempo,
299
+ * threshold, VO2, anaerobic) and every boundary was a jump: the bar changed colour in one frame while
300
+ * the watts behind it had barely moved, which reads as a glitch rather than as a rider getting closer
301
+ * to their limit. Effort is not quantised, so its colour should not be either — and now that the same
302
+ * colour also lights the flywheel, a jump was a lamp being switched, not a fire being stoked.
303
+ *
304
+ * The path is grey-blue → blue → violet → pink → red: cold and unsaturated when nothing is being
305
+ * asked, then rising in both saturation and heat. It stays out of green and amber deliberately —
306
+ * those are the two hues a HUD spends on "healthy" and "warning" elsewhere, and this is neither.
307
+ *
308
+ * Interpolation is straight RGB, which is only safe because consecutive stops are close in hue and
309
+ * all of them are saturated: a lerp from blue to pink through violet never passes near grey, so there
310
+ * is no muddy midpoint to correct for.
229
311
  */
230
- const ZONES = [
231
- { max: 0.55, color: "#6b7f9e", glow: "#9fb4d4" }, // recovery
232
- { max: 0.75, color: "#38bdf8", glow: "#7dd3fc" }, // endurance
233
- { max: 0.9, color: "#22c55e", glow: "#6ee7a2" }, // tempo
234
- { max: 1.05, color: "#eab308", glow: "#fde047" }, // threshold
235
- { max: 1.2, color: "#f97316", glow: "#fdba74" }, // vo2
236
- { max: Infinity, color: "#ef4444", glow: "#fca5a5" }, // anaerobic
312
+ const EFFORT_RAMP = [
313
+ { at: 0, rgb: [107, 127, 158] }, // #6b7f9e — soft pedalling: cold, barely there
314
+ { at: 0.45, rgb: [59, 130, 246] }, // #3b82f6 — blue: working
315
+ { at: 0.75, rgb: [139, 92, 246] }, // #8b5cf6 — violet
316
+ { at: 1, rgb: [236, 72, 153] }, // #ec4899 — pink: threshold, where the FTP mark sits
317
+ { at: 1.3, rgb: [239, 68, 68] }, // #ef4444 — red: over the top, and it should look like it
237
318
  ];
238
- const zoneFor = (ftpPct) => ZONES.find((z) => ftpPct < z.max);
319
+ const hex = (r, g, b) => `#${[r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join("")}`;
320
+ /**
321
+ * The effort's colour, and the lighter one that goes with it.
322
+ *
323
+ * The `glow` half is DERIVED — the base mixed 45% toward white — rather than authored per stop. A
324
+ * second hand-written table would be a second thing to keep in step for no gain: the light variant is
325
+ * only ever used as the hot core of a bloom or the outer edge of a shadow, and "the same colour, paler"
326
+ * is exactly what that wants.
327
+ *
328
+ * The input is quantised to 1/100 FTP before anything is computed. Without it the colour is a new
329
+ * string every frame, and every frame writes two custom properties on the root and invalidates style
330
+ * for the whole rig — for a difference no eye can see. 150 distinct colours across the scale is
331
+ * already far finer than the gradient it paints.
332
+ */
333
+ function zoneFor(ftpPct) {
334
+ const t = Math.round(clamp(ftpPct, 0, 1.5) * 100) / 100;
335
+ let lo = EFFORT_RAMP[0];
336
+ let hi = EFFORT_RAMP[EFFORT_RAMP.length - 1];
337
+ for (let i = 0; i < EFFORT_RAMP.length - 1; i++) {
338
+ if (t >= EFFORT_RAMP[i].at && t <= EFFORT_RAMP[i + 1].at) {
339
+ lo = EFFORT_RAMP[i];
340
+ hi = EFFORT_RAMP[i + 1];
341
+ break;
342
+ }
343
+ }
344
+ const span = hi.at - lo.at;
345
+ const k = span <= 0 ? 0 : clamp((t - lo.at) / span, 0, 1);
346
+ const c = [0, 1, 2].map((i) => lerp(lo.rgb[i], hi.rgb[i], k));
347
+ return {
348
+ color: hex(c[0], c[1], c[2]),
349
+ glow: hex(lerp(c[0], 255, 0.45), lerp(c[1], 255, 0.45), lerp(c[2], 255, 0.45)),
350
+ };
351
+ }
239
352
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
240
353
  const lerp = (a, b, t) => a + (b - a) * t;
241
354
  const rad = (deg) => (deg * Math.PI) / 180;
@@ -395,7 +508,10 @@ export const RIG_GEOMETRY = {
395
508
  export function mountRiderRig(host, opts = {}) {
396
509
  ensureStyles();
397
510
  const design = opts.design ?? "solid";
398
- const { shape, orient } = GAUGE_SPEC[design];
511
+ const { shape } = GAUGE_SPEC[design];
512
+ // The SHAPE of the fill stays the design's (a `neon` gauge keeps its leading edge whichever way it
513
+ // runs); only the direction is overridable.
514
+ const orient = opts.gauge == null ? GAUGE_SPEC[design].orient : opts.gauge === "portrait" ? "vertical" : "horizontal";
399
515
  const preset = SIZES[opts.size ?? "md"];
400
516
  const showReadout = opts.readout !== false;
401
517
  let ftp = Math.max(1, opts.ftp ?? 200);
@@ -409,8 +525,25 @@ export function mountRiderRig(host, opts = {}) {
409
525
  const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
410
526
  const root = document.createElement("div");
411
527
  root.className = `rrig rrig--${design}`;
412
- root.style.width = `${preset.w}px`;
413
- root.style.setProperty("--rrig-u", `${preset.u}px`);
528
+ /**
529
+ * Width and the em base that goes with it, in one place so the two cannot drift.
530
+ *
531
+ * A preset keeps its own hand-tuned em base; a free width derives one. Both write the same two
532
+ * properties, so `setWidth` is just this called again.
533
+ */
534
+ function applyWidth(px) {
535
+ const w = px == null ? preset.w : Math.max(MIN_WIDTH, px);
536
+ const u = px == null ? preset.u : emBase(w);
537
+ root.style.width = `${w}px`;
538
+ root.style.setProperty("--rrig-u", `${u}px`);
539
+ }
540
+ applyWidth(opts.width);
541
+ let placement = opts.place ? { ...opts.place } : undefined;
542
+ if (placement) {
543
+ root.classList.add("rrig--placed");
544
+ applyHudPlacement(root, placement);
545
+ anchorHudHost(host, placement);
546
+ }
414
547
  const renderer = (opts.renderer ?? createSvgRigRenderer)({ design });
415
548
  root.appendChild(renderer.node);
416
549
  const anchor = renderer.anchor();
@@ -419,10 +552,11 @@ export function mountRiderRig(host, opts = {}) {
419
552
  placeTip(n.tipRoot, orient, anchor, tipOver);
420
553
  host.appendChild(root);
421
554
  /** Dirty guards for the TOOLTIP. The renderer keeps its own for the drawing. */
422
- const last = { frac: -Infinity, zone: "", readout: -Infinity };
555
+ const last = { frac: -Infinity, zone: "", readout: -Infinity, effort: -Infinity };
423
556
  /** Repaint everything the CURRENT state implies. Cheap: every write is guarded. */
424
557
  function paint() {
425
558
  const effort = smoothPower / ftp;
559
+ const zone = zoneFor(effort);
426
560
  // ── the pose: solved HERE, once, for whatever is drawing ──────────────
427
561
  // Nothing that moves is the bike: it is bolted to a fixed machine. Only the crank and the two
428
562
  // things the chain turns rotate at all — see the module header.
@@ -458,14 +592,25 @@ export function mountRiderRig(host, opts = {}) {
458
592
  // Streaks stand in for the speed the spokes can't show without strobing.
459
593
  streak: clamp(smoothCadence / STREAK_FULL_RPM, 0, 1),
460
594
  effort,
595
+ zone,
461
596
  });
462
597
  // ── the gauge ─────────────────────────────────────────────────────────
463
598
  const frac = clamp(smoothPower / (ftp * GAUGE_MAX_FTP_PCT), 0, 1);
464
- const zone = zoneFor(smoothPower / ftp);
465
599
  if (zone.color !== last.zone) {
466
600
  last.zone = zone.color;
467
- n.tipRoot.style.setProperty("--rrig-zone", zone.color);
468
- n.tipRoot.style.setProperty("--rrig-zone-glow", zone.glow);
601
+ // On the ROOT, not the tooltip: the tooltip inherits them from here exactly as it did when they
602
+ // were its own, and the drawing — which is the tooltip's sibling — can now read the same pair.
603
+ // That is what lets the solid wheel glow in the gauge's colour with no second colour table.
604
+ root.style.setProperty("--rrig-zone", zone.color);
605
+ root.style.setProperty("--rrig-zone-glow", zone.glow);
606
+ }
607
+ // Effort as a bare number, for a stylesheet to multiply into a length. Quantised: it feeds a glow
608
+ // whose size nobody can read to the percent, and an unguarded write here would be a style
609
+ // invalidation every frame for nothing.
610
+ const effortStep = Math.round(clamp(effort, 0, 1.5) * 20) / 20;
611
+ if (effortStep !== last.effort) {
612
+ last.effort = effortStep;
613
+ root.style.setProperty("--rrig-effort", effortStep.toFixed(2));
469
614
  }
470
615
  if (Math.abs(frac - last.frac) > 0.002) {
471
616
  last.frac = frac;
@@ -500,6 +645,16 @@ export function mountRiderRig(host, opts = {}) {
500
645
  tipOver = { ...tipOver, ...next };
501
646
  placeTip(n.tipRoot, orient, anchor, tipOver);
502
647
  },
648
+ setWidth(px) {
649
+ if (Number.isFinite(px))
650
+ applyWidth(px);
651
+ },
652
+ setPlacement(next) {
653
+ placement = { ...placement, ...next };
654
+ root.classList.add("rrig--placed"); // a rig mounted in flow can be pinned later
655
+ applyHudPlacement(root, placement);
656
+ anchorHudHost(host, placement);
657
+ },
503
658
  setPower(watts) {
504
659
  rawPower = Number.isFinite(watts) ? Math.max(0, watts) : 0;
505
660
  },
@@ -535,8 +690,11 @@ export function mountRiderRig(host, opts = {}) {
535
690
  }
536
691
  /* ───────────────────────────── the drawing ───────────────────────────── */
537
692
  /** The flat drawing. Everything here is the rig itself; the tooltip is built separately. */
693
+ /** Per-instance suffix for the gradient ids. Two rigs on one page must not share a `<defs>` entry. */
694
+ let uid = 0;
538
695
  function buildSvg(design) {
539
696
  const svg = svgNode("svg", { viewBox: `0 0 ${VB_W} ${VB_H}`, class: "rrig-svg", role: "img" });
697
+ const glowId = `rrig-flyglow-${++uid}`;
540
698
  svg.setAttribute("aria-label", "A rider pedalling on a direct-drive indoor trainer, with a live watts gauge");
541
699
  svg.classList.add(`rrig-svg--${design}`);
542
700
  // Ground line first — everything else sits on it.
@@ -569,6 +727,38 @@ function buildSvg(design) {
569
727
  y2: FLY.y + (FLY.r - 2) * Math.sin(a),
570
728
  }));
571
729
  }
730
+ /**
731
+ * The neon bloom BEHIND the flywheel.
732
+ *
733
+ * A `drop-shadow` was the first attempt and it is the wrong tool: it hugs the shape's own edge, so it
734
+ * reads as a rim light on the disc rather than as light coming OFF the machine. This is a real
735
+ * radial fade — bright at the disc, gone by two and a half radii out — which is what a neon glow
736
+ * actually looks like, and it lets the disc itself stay plain white and crisp in front of it.
737
+ *
738
+ * The stops take their colour from the SAME custom properties the gauge is painted from (the root
739
+ * carries them, see paint()), and the disc's opacity rides on `--rrig-effort`. So the bloom is the
740
+ * watts, in the drawing, and it cannot disagree with the bar.
741
+ */
742
+ // The bloom peaks at the DISC'S EDGE — 38% out, which is where the flywheel's own rim falls in a
743
+ // circle 2.6 radii wide — and falls away from there. Peaking at the centre (the obvious reading of a
744
+ // radial gradient) puts the brightest part behind the disc, where it is hidden, and leaves the rim
745
+ // sitting on the dimmest ring of its own halo. Light comes OFF the wheel; it is brightest where it
746
+ // leaves it.
747
+ const glowGrad = svgNode("radialGradient", { id: glowId });
748
+ glowGrad.appendChild(svgNode("stop", { offset: "0%", class: "rrig-flyglow-mid" }));
749
+ glowGrad.appendChild(svgNode("stop", { offset: "38%", class: "rrig-flyglow-core" }));
750
+ glowGrad.appendChild(svgNode("stop", { offset: "62%", class: "rrig-flyglow-mid" }));
751
+ glowGrad.appendChild(svgNode("stop", { offset: "100%", class: "rrig-flyglow-edge" }));
752
+ const defs = svgNode("defs");
753
+ defs.appendChild(glowGrad);
754
+ svg.appendChild(defs);
755
+ trainer.appendChild(svgNode("circle", {
756
+ class: "rrig-fly-glow",
757
+ cx: FLY.x,
758
+ cy: FLY.y,
759
+ r: FLY.r * 2.6,
760
+ fill: `url(#${glowId})`,
761
+ }));
572
762
  trainer.appendChild(svgNode("circle", { class: "rrig-fly", cx: FLY.x, cy: FLY.y, r: FLY.r }));
573
763
  trainer.appendChild(flySpokes);
574
764
  trainer.appendChild(flyStreaks);
@@ -695,22 +885,24 @@ function buildTooltip(root, cfg) {
695
885
  const panel = document.createElement("div");
696
886
  panel.className = "rrig-tip-panel";
697
887
  tipRoot.appendChild(panel);
698
- // The PORTRAIT variant carries no figure at all — only the title and the bar.
888
+ // BOTH layouts print the figure; what differs is where it sits and how loud it is.
889
+ //
890
+ // Landscape puts it beside the title, on its own row. Portrait stacks it ABOVE the bar — the caption
891
+ // on top, the number under it — and sets it smaller, because on that variant the bar is still the
892
+ // reading: it is tall, continuous and marked at FTP, and it answers "how hard am I working" before
893
+ // any digit is read. The figure is the exact value for when the fill is not enough, not the headline.
699
894
  //
700
- // On that layout the bar is the reading: it is tall, continuous, and marked at FTP, which answers
701
- // "how hard am I working" without a number. Printing the watts beside it competes with the thing it
702
- // is annotating, and a rider glancing at a trainer screen reads the fill long before they read
703
- // three digits. The landscape variants keep the figure, where there is a row for it.
895
+ // The portrait variant shipped with no figure at all for exactly that reason, and it went too far:
896
+ // a rider who wants to know what they are pushing right now had nowhere to look, and the bar cannot
897
+ // answer that question to the watt. Restored deliberately, sized so it annotates rather than shouts.
704
898
  let readoutValue;
705
899
  if (cfg.showReadout) {
706
900
  const head = document.createElement("div");
707
901
  head.className = "rrig-tip-head";
708
- if (cfg.orient === "horizontal") {
709
- readoutValue = document.createElement("span");
710
- readoutValue.className = "rrig-tip-num";
711
- readoutValue.textContent = "0";
712
- head.appendChild(readoutValue);
713
- }
902
+ readoutValue = document.createElement("span");
903
+ readoutValue.className = "rrig-tip-num";
904
+ readoutValue.textContent = "0";
905
+ head.appendChild(readoutValue);
714
906
  const unit = document.createElement("span");
715
907
  unit.className = "rrig-tip-unit";
716
908
  unit.textContent = cfg.label;
@@ -720,15 +912,30 @@ function buildTooltip(root, cfg) {
720
912
  const track = document.createElement("div");
721
913
  track.className = "rrig-tip-track";
722
914
  panel.appendChild(track);
915
+ /**
916
+ * A clipping well inside the track, holding everything that FILLS it.
917
+ *
918
+ * The fill is a full-width rectangle pinned to one end, and the track is a rounded well — so at the
919
+ * rounded end the fill's square corners poked outside the track's silhouette. Worst when the fill is
920
+ * short, which is exactly when it is most looked at.
921
+ *
922
+ * `overflow: hidden` on the TRACK itself would have been the one-liner, and it is wrong here: the
923
+ * FTP marker is a child of the track and overhangs it on purpose — the tick sticks out 0.4em each
924
+ * side and the label sits entirely outside it. Clipping the track would clip the label away. So the
925
+ * clip gets its own box and the marker stays out of it.
926
+ */
927
+ const well = document.createElement("div");
928
+ well.className = "rrig-tip-well";
929
+ track.appendChild(well);
723
930
  const gaugeFill = document.createElement("div");
724
931
  gaugeFill.className = "rrig-tip-fill";
725
- track.appendChild(gaugeFill);
932
+ well.appendChild(gaugeFill);
726
933
  let tip;
727
934
  if (cfg.shape === "tube") {
728
935
  tip = document.createElement("i");
729
936
  tip.className = "rrig-tip-lead";
730
937
  tip.style.opacity = "0";
731
- track.appendChild(tip);
938
+ well.appendChild(tip);
732
939
  }
733
940
  // The FTP reference. Positioned off `--rrig-ftp` with a -50% translate, so the tick and its label
734
941
  // are centred on the same point by construction rather than by two hand-computed coordinates.
@@ -758,6 +965,12 @@ function ensureStyles() {
758
965
  const CSS = `
759
966
  .rrig { display: block; max-width: 100%; position: relative;
760
967
  --rrig-zone: #6b7f9e; --rrig-zone-glow: #9fb4d4; }
968
+ /* Pinned to the game's screen. Click-through in full: it is a picture the game draws over itself, and
969
+ a HUD piece that ate a click on what is underneath is a bug in every game that mounts one. The
970
+ position, the offsets and the transform are written inline by applyHudPlacement — only what is true of
971
+ EVERY placement lives here. max-width is dropped with it: a pinned rig is sized by its own width,
972
+ and inheriting the host's width would shrink it in a narrow column. */
973
+ .rrig--placed { pointer-events: none; max-width: none; }
761
974
  .rrig-svg { display: block; width: 100%; height: auto; overflow: visible; }
762
975
  .rrig-svg * { vector-effect: non-scaling-stroke; }
763
976
 
@@ -828,9 +1041,15 @@ const CSS = `
828
1041
  order puts the caption on top without reordering the markup.) Side by side the pair would also
829
1042
  set the bubble's width from the text rather than from the bar, and the point of the portrait box is
830
1043
  to be narrow. */
831
- .rrig-tip--vertical .rrig-tip-panel > .rrig-tip-head { flex-direction: column-reverse; align-items: center; gap: 0.15em; }
832
- .rrig-tip--vertical .rrig-tip-num { font-size: 1.55em; font-weight: 700; }
833
- .rrig-tip--vertical .rrig-tip-unit { font-size: 1.7em; letter-spacing: 0.14em; }
1044
+ /* Portrait stacks them in DOM order — the figure on top, its unit under it — which is how a reading is
1045
+ written down: the number first, the word that qualifies it second and smaller. (It ran the other way
1046
+ at first, caption over figure; that makes the box announce its own title before it says anything,
1047
+ and the title is the least interesting line in it.) */
1048
+ .rrig-tip--vertical .rrig-tip-panel > .rrig-tip-head { flex-direction: column; align-items: center; gap: 0.05em; }
1049
+ /* The figure leads and is the larger of the two, while staying clearly under the landscape figure's
1050
+ size — the BAR is still the reading on this variant, and the number annotates it. */
1051
+ .rrig-tip--vertical .rrig-tip-num { font-size: 2.8em; font-weight: 800; line-height: 1; }
1052
+ .rrig-tip--vertical .rrig-tip-unit { font-size: 1.75em; letter-spacing: 0.12em; }
834
1053
 
835
1054
  /* The well is a translucent WHITE, not black.
836
1055
  Black was the instinct — a recessed groove — but the panel behind it is already dark glass, so a
@@ -842,13 +1061,21 @@ const CSS = `
842
1061
  box-shadow: inset 0 1px 2.5px rgba(0,0,0,0.55),
843
1062
  inset 0 0 0 1px rgba(255,255,255,0.13); }
844
1063
  .rrig-tip--horizontal .rrig-tip-track { width: 8.4em; height: 0.9em; }
845
- /* Portrait: a deliberately BIG bar. With no figure beside it, it has to carry the reading on its own,
1064
+ /* Portrait: a deliberately BIG bar. It carries the reading, the figure above it only annotates,
846
1065
  and the extra height is what makes a change of a few percent visible at a glance. It is nudged off
847
1066
  centre so the bar and its FTP label sit optically centred as a pair, rather than the bar alone. */
848
1067
  .rrig-tip--vertical .rrig-tip-track { width: 1.75em; height: 13em;
849
1068
  align-self: flex-start; margin-left: 0.7em; }
850
1069
 
851
- /* continuous fill */
1070
+ /* The well: clips the fill to the track's rounded shape. See the note in build() on why the clip is
1071
+ not on the track itself. */
1072
+ .rrig-tip-well { position: absolute; inset: 0; overflow: hidden; border-radius: inherit; }
1073
+
1074
+ /* continuous fill
1075
+ Its own radius is now decoration rather than structure — the well's curve is what shapes the ends.
1076
+ That also settles the near-empty case: a browser clamps a radius to half the smaller side, so at a
1077
+ height of a pixel this collapsed to a flat sliver. Clipped, the same sliver reads as a thin lens
1078
+ hugging the bottom of the well, which is the truth at 3 W and looks deliberate rather than broken. */
852
1079
  .rrig-tip-fill { position: absolute; border-radius: 99px; background: var(--rrig-zone);
853
1080
  transition: width 90ms linear, height 90ms linear; }
854
1081
  .rrig-tip--horizontal .rrig-tip-fill { left: 0; top: 0; height: 100%; width: 0; }
@@ -881,7 +1108,11 @@ const CSS = `
881
1108
  .rrig-tip--horizontal .rrig-tip-ftp b { top: calc(100% + 0.85em); left: 50%; transform: translateX(-50%); }
882
1109
  .rrig-tip--vertical .rrig-tip-ftp { bottom: var(--rrig-ftp); left: 0; width: 100%; }
883
1110
  .rrig-tip--vertical .rrig-tip-ftp i { top: -1px; left: -0.4em; height: 0; width: calc(100% + 0.8em); }
884
- .rrig-tip--vertical .rrig-tip-ftp b { left: calc(100% + 1.1em); top: 50%; transform: translateY(-50%); }
1111
+ /* Portrait prints it LARGER, and only portrait. It hangs off the side of the bar, where there is room;
1112
+ the landscape label sits UNDER the track inside the panel's bottom padding, and growing it there
1113
+ pushed it through the bubble's own edge. Same mark, two layouts, one of which has the space. */
1114
+ .rrig-tip--vertical .rrig-tip-ftp b { left: calc(100% + 1.1em); top: 50%; transform: translateY(-50%);
1115
+ font-size: 1.5em; }
885
1116
  /* Portrait needs no bottom gutter: its label sits beside the bar, not under it. */
886
1117
  .rrig-tip--vertical .rrig-tip-panel { padding: 1em 1.2em 1.3em; min-width: 10em; }
887
1118
 
@@ -894,6 +1125,16 @@ const CSS = `
894
1125
 
895
1126
  /* ── shared skeleton defaults — the designs override the paint, never the shapes ─────────── */
896
1127
  .rrig-tyre, .rrig-fly, .rrig-cassette, .rrig-chainring { fill: none; }
1128
+
1129
+ /* The flywheel's neon bloom. Off everywhere by default: only the design that makes the disc a reading
1130
+ turns it on, and the others keep their own art direction. The three stops are the fade — a hot core
1131
+ in the light zone colour, the saturated one through the middle, nothing at the edge. */
1132
+ .rrig-fly-glow { opacity: 0; transition: opacity 140ms linear; }
1133
+ .rrig-flyglow-core { stop-color: var(--rrig-zone-glow, #9fb4d4); stop-opacity: 0.9; }
1134
+ .rrig-flyglow-mid { stop-color: var(--rrig-zone, #6b7f9e); stop-opacity: 0.45; }
1135
+ .rrig-flyglow-edge { stop-color: var(--rrig-zone, #6b7f9e); stop-opacity: 0; }
1136
+ /* Never fully off — a dark machine at rest still reads as switched on — and never fully saturating. */
1137
+ .rrig--solid .rrig-fly-glow { opacity: calc(0.12 + var(--rrig-effort, 0) * 0.62); }
897
1138
  .rrig-frame, .rrig-chain, .rrig-saddle, .rrig-bar, .rrig-crank-arm,
898
1139
  .rrig-limb, .rrig-torso, .rrig-neck, .rrig-spoke, .rrig-streak { fill: none; }
899
1140
  .rrig-limb, .rrig-torso, .rrig-neck { stroke-linecap: round; stroke-linejoin: round; }
@@ -916,8 +1157,18 @@ const CSS = `
916
1157
  .rrig--solid .rrig-pedal { fill: #e6edf9; }
917
1158
  .rrig--solid .rrig-trainer-body { fill: #2b374e; stroke: #4a5a79; stroke-width: 2; }
918
1159
  .rrig--solid .rrig-foot { fill: #4a5a79; }
919
- .rrig--solid .rrig-fly { stroke: #566b90; stroke-width: 4; fill: #1b2434; }
920
- .rrig--solid .rrig-fly-hub { fill: #7c8fb3; }
1160
+ /* The FLYWHEEL is the rig's effort reading, in the drawing itself.
1161
+ It is the right part to carry it and not the front wheel: this disc is the one the rider's legs
1162
+ actually drive, it is the machine's resistance made visible, and it is already spinning with the
1163
+ cadence — so lighting it says "this is what your watts are going into". The front wheel is parked on
1164
+ the floor doing nothing, and glowing it would have said the opposite.
1165
+ White rim and hub, haloed in the gauge's own zone colour, inherited from the root (see paint()) so
1166
+ there is no second colour table. The halo rides on --rrig-effort: barely there at rest, hot at
1167
+ threshold, burning in a sprint. Two shadows — a tight core that keeps the disc crisp, a wide bloom that
1168
+ carries the colour; one big shadow alone washes the spokes out. */
1169
+ .rrig--solid .rrig-fly { stroke: #ffffff; stroke-width: 4; fill: #1b2434; }
1170
+ .rrig--solid .rrig-fly-spokes .rrig-spoke { stroke: #ffffff; }
1171
+ .rrig--solid .rrig-fly-hub { fill: #ffffff; }
921
1172
  .rrig--solid .rrig-limb { stroke: #f0b429; stroke-width: 8; }
922
1173
  .rrig--solid .rrig-limb--far { stroke: #a8791b; stroke-width: 7; }
923
1174
  .rrig--solid .rrig-limb--arm { stroke: #ff7a45; stroke-width: 7; }