@rydr/game-sdk 7.0.1 → 8.0.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 (115) hide show
  1. package/README.md +47 -4
  2. package/dist/client/PlatformClient.d.ts +14 -7
  3. package/dist/client/PlatformClient.d.ts.map +1 -1
  4. package/dist/client/PlatformClient.js.map +1 -1
  5. package/dist/difficulty/index.d.ts +141 -0
  6. package/dist/difficulty/index.d.ts.map +1 -0
  7. package/dist/difficulty/index.js +300 -0
  8. package/dist/difficulty/index.js.map +1 -0
  9. package/dist/nav/README.md +1 -1
  10. package/dist/protocol/glyphs.d.ts +7 -0
  11. package/dist/protocol/glyphs.d.ts.map +1 -1
  12. package/dist/protocol/glyphs.js +26 -0
  13. package/dist/protocol/glyphs.js.map +1 -1
  14. package/dist/protocol/version.d.ts +1 -1
  15. package/dist/protocol/version.js +1 -1
  16. package/dist/ui/README.md +143 -11
  17. package/dist/ui/action-diamond.js +1 -1
  18. package/dist/ui/action-diamond.js.map +1 -1
  19. package/dist/ui/controller/combo-demo.d.ts +74 -0
  20. package/dist/ui/controller/combo-demo.d.ts.map +1 -0
  21. package/dist/ui/controller/combo-demo.js +295 -0
  22. package/dist/ui/controller/combo-demo.js.map +1 -0
  23. package/dist/ui/controller/combo-strip.d.ts +36 -0
  24. package/dist/ui/controller/combo-strip.d.ts.map +1 -0
  25. package/dist/ui/controller/combo-strip.js +66 -0
  26. package/dist/ui/controller/combo-strip.js.map +1 -0
  27. package/dist/ui/controller/control-trainer.d.ts +132 -0
  28. package/dist/ui/controller/control-trainer.d.ts.map +1 -0
  29. package/dist/ui/controller/control-trainer.js +570 -0
  30. package/dist/ui/controller/control-trainer.js.map +1 -0
  31. package/dist/ui/controller/controller-map.d.ts +82 -0
  32. package/dist/ui/controller/controller-map.d.ts.map +1 -0
  33. package/dist/ui/controller/controller-map.js +291 -0
  34. package/dist/ui/controller/controller-map.js.map +1 -0
  35. package/dist/ui/controller/controller-pad.d.ts +122 -0
  36. package/dist/ui/controller/controller-pad.d.ts.map +1 -0
  37. package/dist/ui/controller/controller-pad.js +200 -0
  38. package/dist/ui/controller/controller-pad.js.map +1 -0
  39. package/dist/ui/controller/index.d.ts +26 -0
  40. package/dist/ui/controller/index.d.ts.map +1 -0
  41. package/dist/ui/controller/index.js +26 -0
  42. package/dist/ui/controller/index.js.map +1 -0
  43. package/dist/ui/controller/input-chip.d.ts +101 -0
  44. package/dist/ui/controller/input-chip.d.ts.map +1 -0
  45. package/dist/ui/controller/input-chip.js +323 -0
  46. package/dist/ui/controller/input-chip.js.map +1 -0
  47. package/dist/ui/controller/layouts/dualsense.d.ts +12 -0
  48. package/dist/ui/controller/layouts/dualsense.d.ts.map +1 -0
  49. package/dist/ui/controller/layouts/dualsense.js +64 -0
  50. package/dist/ui/controller/layouts/dualsense.js.map +1 -0
  51. package/dist/ui/controller/layouts/index.d.ts +20 -0
  52. package/dist/ui/controller/layouts/index.d.ts.map +1 -0
  53. package/dist/ui/controller/layouts/index.js +34 -0
  54. package/dist/ui/controller/layouts/index.js.map +1 -0
  55. package/dist/ui/controller/layouts/joycon.d.ts +21 -0
  56. package/dist/ui/controller/layouts/joycon.d.ts.map +1 -0
  57. package/dist/ui/controller/layouts/joycon.js +76 -0
  58. package/dist/ui/controller/layouts/joycon.js.map +1 -0
  59. package/dist/ui/controller/layouts/switch-pro.d.ts +12 -0
  60. package/dist/ui/controller/layouts/switch-pro.d.ts.map +1 -0
  61. package/dist/ui/controller/layouts/switch-pro.js +60 -0
  62. package/dist/ui/controller/layouts/switch-pro.js.map +1 -0
  63. package/dist/ui/controller/layouts/xbox.d.ts +11 -0
  64. package/dist/ui/controller/layouts/xbox.d.ts.map +1 -0
  65. package/dist/ui/controller/layouts/xbox.js +59 -0
  66. package/dist/ui/controller/layouts/xbox.js.map +1 -0
  67. package/dist/ui/controller/layouts/zwift.d.ts +27 -0
  68. package/dist/ui/controller/layouts/zwift.d.ts.map +1 -0
  69. package/dist/ui/controller/layouts/zwift.js +62 -0
  70. package/dist/ui/controller/layouts/zwift.js.map +1 -0
  71. package/dist/ui/controller/pad-svg.d.ts +105 -0
  72. package/dist/ui/controller/pad-svg.d.ts.map +1 -0
  73. package/dist/ui/controller/pad-svg.js +289 -0
  74. package/dist/ui/controller/pad-svg.js.map +1 -0
  75. package/dist/ui/controller/parts-map.d.ts +96 -0
  76. package/dist/ui/controller/parts-map.d.ts.map +1 -0
  77. package/dist/ui/controller/parts-map.js +187 -0
  78. package/dist/ui/controller/parts-map.js.map +1 -0
  79. package/dist/ui/controller/styles.d.ts +19 -0
  80. package/dist/ui/controller/styles.d.ts.map +1 -0
  81. package/dist/ui/controller/styles.js +364 -0
  82. package/dist/ui/controller/styles.js.map +1 -0
  83. package/dist/ui/controller/tokens.d.ts +106 -0
  84. package/dist/ui/controller/tokens.d.ts.map +1 -0
  85. package/dist/ui/controller/tokens.js +50 -0
  86. package/dist/ui/controller/tokens.js.map +1 -0
  87. package/dist/ui/controller/types.d.ts +157 -0
  88. package/dist/ui/controller/types.d.ts.map +1 -0
  89. package/dist/ui/controller/types.js +61 -0
  90. package/dist/ui/controller/types.js.map +1 -0
  91. package/dist/ui/index.d.ts +9 -1
  92. package/dist/ui/index.d.ts.map +1 -1
  93. package/dist/ui/index.js +9 -1
  94. package/dist/ui/index.js.map +1 -1
  95. package/dist/ui/keycap.d.ts +65 -20
  96. package/dist/ui/keycap.d.ts.map +1 -1
  97. package/dist/ui/keycap.js +99 -28
  98. package/dist/ui/keycap.js.map +1 -1
  99. package/dist/ui/{pause-menu.d.ts → option-menu.d.ts} +34 -24
  100. package/dist/ui/option-menu.d.ts.map +1 -0
  101. package/dist/ui/{pause-menu.js → option-menu.js} +134 -90
  102. package/dist/ui/option-menu.js.map +1 -0
  103. package/dist/ui/showcase/gallery.d.ts +13 -1
  104. package/dist/ui/showcase/gallery.d.ts.map +1 -1
  105. package/dist/ui/showcase/gallery.js.map +1 -1
  106. package/dist/ui/showcase/index.d.ts.map +1 -1
  107. package/dist/ui/showcase/index.js +265 -9
  108. package/dist/ui/showcase/index.js.map +1 -1
  109. package/dist/ui/solo-labeled-diamond.js +1 -1
  110. package/dist/ui/solo-labeled-diamond.js.map +1 -1
  111. package/dist/ui/styles.js +14 -9
  112. package/dist/ui/styles.js.map +1 -1
  113. package/package.json +5 -1
  114. package/dist/ui/pause-menu.d.ts.map +0 -1
  115. package/dist/ui/pause-menu.js.map +0 -1
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The **FTP-difficulty ladder** — levels 1 → 50, and the watts each one means.
3
+ *
4
+ * This is the rider's chosen calibration: the number every game's `%FTP` demands are a percentage
5
+ * of. It is **not** a measurement of anyone's fitness, and it is not the rank of a score.
6
+ *
7
+ * ## Why this lives in the SDK
8
+ *
9
+ * A leaderboard row carries watts — `BoardEntry.ftpDifficulty` in `src/protocol/boards.ts` — and
10
+ * nothing else. Any game that draws a rider's badge next to their score therefore has to turn those
11
+ * watts into a level and a colour, and a game cannot import the shell's copy across the iframe
12
+ * boundary. Before this module existed the only way through that was to hand-port the ladder into
13
+ * each game, which is exactly what happened: guitar hero shipped a private copy that kept rendering
14
+ * a retired 36-rung metal ladder long after the shell had moved on, so one rider's `ftpDifficulty`
15
+ * produced a bronze "3" in-game and a ramp-coloured "26" in the chrome. **The interpretation of a
16
+ * protocol field belongs to the protocol.** The shell imports this module too — there is one ladder,
17
+ * published once, and both sides read it.
18
+ *
19
+ * Pure by design: no `lit`, no `three`, no DOM. {@link visualForLevel} returns CSS strings, which any
20
+ * renderer can use — a Lit component's `style`, an `innerHTML` template, a canvas fill.
21
+ *
22
+ * ## The scale
23
+ *
24
+ * **Watts are what get stored; the level is derived here.** Every persisted copy of the dial (the
25
+ * shell's `profile.ftpDifficulty`, a leaderboard entry's `ftp_difficulty`) holds watts, and every
26
+ * client computes the level from it through {@link levelForWatts}. That's what lets this scale be
27
+ * redrawn — floor, band widths, tier count — without migrating or reinterpreting a single stored row.
28
+ * The cost, accepted: redrawing it re-labels history, so a shift in the levels reported by analytics
29
+ * can mean the ladder moved rather than the riders.
30
+ *
31
+ * ```
32
+ * watts = level ≤ 9 ? 50 + 5 × (level − 1) // 50, 55, 60 … 90
33
+ * : level × 10 // 100, 110 … 500
34
+ * ```
35
+ *
36
+ * `level × 10 = watts` holds exactly from 100 W up, where essentially every rider sits — that
37
+ * relationship is the point of the number, since it makes "22" readable as "~220 W" with no lookup.
38
+ * Below 100 W the steps are finer because a flat 10 W step there is a **20%** jump where at 400 W
39
+ * it's 2.5%. The floor of 50 W is the researched floor for a deconditioned adult; the ceiling of
40
+ * 500 W is beyond any realistic rider on purpose, so nobody is ever pinned at either end.
41
+ *
42
+ * **There is deliberately no watts-per-press constant** (see {@link stepWatts}): the watts a − / +
43
+ * press moves are whatever the neighbouring rung is — 5 W inside levels 1 → 9, 10 W from level 10
44
+ * up, and 10 W across the 9 → 10 boundary. Any single constant is wrong in one of those bands by
45
+ * construction, which is the bug that convention replaced.
46
+ *
47
+ * The rationale for the whole design, including the colour ramp below, is written up in the platform
48
+ * repo: `rydr-platform/docs/run-level-and-leaderboards.md`.
49
+ */
50
+ /** Weakest level. The ladder is **1-based** — there is no level 0. */
51
+ export const LEVEL_MIN = 1;
52
+ /** Strongest level. */
53
+ export const LEVEL_MAX = 50;
54
+ /** Last level of the fine 5 W band. */
55
+ const LOW_BAND_MAX = 9;
56
+ /** Watts of the fine band's first rung, i.e. of {@link LEVEL_MIN}. */
57
+ export const FTP_MIN = 50;
58
+ /** Watts per rung inside the fine band (levels 1 → 9 only — never use this as *the* step). */
59
+ const LOW_BAND_STEP = 5;
60
+ /** Watts at {@link LEVEL_MAX}. A real ceiling, not a bucket: nobody is meant to reach it. */
61
+ export const FTP_MAX = 500;
62
+ /** Levels per tier badge. Ten tiers × five levels covers 1 → 50 exactly. */
63
+ export const LEVELS_PER_TIER = 5;
64
+ /** Number of tier badges. */
65
+ export const TIER_COUNT = LEVEL_MAX / LEVELS_PER_TIER; // 10
66
+ // ── the scale ─────────────────────────────────────────────────────────────────────
67
+ /** Clamp any number to a whole level inside the ladder. */
68
+ export function clampLevel(level) {
69
+ return Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, Math.round(level)));
70
+ }
71
+ /** The watts a level means. Clamps, so it's safe to call with arithmetic results. */
72
+ export function wattsForLevel(level) {
73
+ const lvl = clampLevel(level);
74
+ return lvl <= LOW_BAND_MAX ? FTP_MIN + LOW_BAND_STEP * (lvl - LEVEL_MIN) : lvl * 10;
75
+ }
76
+ /**
77
+ * The level a watts value sits at — the **nearest** rung, so any number (a legacy profile, the
78
+ * weight-relative result of an onboarding questionnaire, one day a measured estimate) has a level.
79
+ * Ties round up, which matters only for values landing exactly between two rungs.
80
+ *
81
+ * Each band's formula is inverted, then whichever candidate rung is genuinely closer wins. Trusting
82
+ * one formula by a `watts < 100` branch is wrong **at the seam**, because the gap from level 9 (90 W)
83
+ * to level 10 (100 W) is 10 W while the low-band formula divides by 5: it read 98 W as level 11 and
84
+ * snapped it up to 110 W — a 12% jump — and 93 W as level 10. Only 93, 94, 98 and 99 W were affected,
85
+ * which is exactly why it took a brute-force sweep against {@link FTP_LEVELS} to notice.
86
+ */
87
+ export function levelForWatts(watts) {
88
+ if (!Number.isFinite(watts))
89
+ return LEVEL_MIN;
90
+ if (watts <= FTP_MIN)
91
+ return LEVEL_MIN;
92
+ if (watts >= FTP_MAX)
93
+ return LEVEL_MAX;
94
+ const low = clampLevel(LEVEL_MIN + (watts - FTP_MIN) / LOW_BAND_STEP);
95
+ const high = clampLevel(watts / 10);
96
+ // `<` not `<=`, so a value exactly between two rungs keeps the documented round-up.
97
+ return Math.abs(wattsForLevel(high) - watts) < Math.abs(wattsForLevel(low) - watts) ? high : low;
98
+ }
99
+ /** Snap arbitrary watts onto the ladder — `wattsForLevel(levelForWatts(w))`. Idempotent. */
100
+ export function snapWatts(watts) {
101
+ return wattsForLevel(levelForWatts(watts));
102
+ }
103
+ /**
104
+ * Move one level from wherever `watts` is, and return the new **watts**.
105
+ *
106
+ * This is the only correct way to step the dial: it derives the level, moves it by one, and reads
107
+ * that level's watts back. Off-ladder input snaps to its nearest rung on the first press, then
108
+ * steps from there. At either end of the ladder it returns the end.
109
+ */
110
+ export function stepWatts(watts, direction) {
111
+ return wattsForLevel(levelForWatts(watts) + direction);
112
+ }
113
+ // ── colour ────────────────────────────────────────────────────────────────────────
114
+ const hex2rgb = (h) => {
115
+ const s = h.replace("#", "");
116
+ return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
117
+ };
118
+ const to2 = (x) => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0");
119
+ const rgb2hex = (r, g, b) => `#${to2(r)}${to2(g)}${to2(b)}`;
120
+ const mix = (h1, h2, t) => {
121
+ const a = hex2rgb(h1);
122
+ const b = hex2rgb(h2);
123
+ return rgb2hex(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
124
+ };
125
+ const rgba = (hex, alpha) => `rgba(${hex2rgb(hex).join(",")},${alpha})`;
126
+ const hsl = (h, s, l) => {
127
+ const sat = Math.min(100, s) / 100;
128
+ const lig = l / 100;
129
+ const k = (n) => (n + h / 30) % 12;
130
+ const a = sat * Math.min(lig, 1 - lig);
131
+ const f = (n) => lig - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
132
+ return rgb2hex(255 * f(0), 255 * f(8), 255 * f(4));
133
+ };
134
+ const hslA = (h, s, l, alpha) => `hsl(${h.toFixed(1)} ${Math.min(100, s).toFixed(1)}% ${l.toFixed(1)}% / ${alpha})`;
135
+ const lerp = (a, b, t) => a + (b - a) * t;
136
+ /**
137
+ * Relative luminance, the sRGB formula WCAG contrast is built on. Needed because the answer swings
138
+ * hard with hue: a gold and a blue at the same nominal lightness are nowhere near each other behind
139
+ * white text, so every threshold below is measured rather than guessed.
140
+ */
141
+ const relLum = (hex) => {
142
+ const lin = (u) => u <= 0.03928 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
143
+ const [r, g, b] = hex2rgb(hex);
144
+ return 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255);
145
+ };
146
+ const contrast = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
147
+ /**
148
+ * Positions are explicit because even spacing is what made level 26 ugly. Interpolating green
149
+ * (142°) → gold (45°) passes through ~90°, and 90° deepened enough for white text is army-green
150
+ * olive. Gold itself was never the problem and can't be avoided anyway: every green-to-red path
151
+ * crosses it, and going the other way round the wheel revisits blue. What's fixable is how long
152
+ * the ramp lingers in the crossing, so green is warmed to 152° (further from chartreuse), gold to
153
+ * 42° at 96% saturation (deepens to a rich gold, not mustard), and the green→gold segment is
154
+ * squeezed to 0.027 of the path — positioned so the whole traverse falls in the GAP between
155
+ * level 26 (p 0.456) and level 27 (p 0.500). Nothing lands in the lime zone.
156
+ *
157
+ * The cost, stated honestly: that pair is a visible hue jump, the one hard edge in an otherwise
158
+ * continuous ramp — and a hard edge is the "club boundary" the metals were dropped to avoid. It
159
+ * buys the ugliest colour out of the most populated band. Widen this segment if the edge ever
160
+ * reads worse than the olive did.
161
+ */
162
+ const HUE_STOPS = [
163
+ { p: 0.0, h: 217, s: 91 }, // blue
164
+ { p: 0.465, h: 152, s: 74 }, // green
165
+ { p: 0.492, h: 42, s: 96 }, // gold
166
+ { p: 0.74, h: 22, s: 95 }, // orange
167
+ { p: 1.0, h: 0, s: 84 }, // red
168
+ ];
169
+ /** First and last level the hue path moves across; outside this it's clamped. */
170
+ const HUE_LO = 10;
171
+ const HUE_HI = 44;
172
+ /** Lightness floor and ceiling. The floor keeps level 1 separable from a dark container. */
173
+ const L_LO = 34;
174
+ const L_HI = 64;
175
+ const S_SCALE_LO = 0.5;
176
+ const S_SCALE_HI = 1.0;
177
+ /** Fill alpha. The pill is glass, so it reads as chrome rather than a sticker on top of it. */
178
+ const FILL_ALPHA = 0.4;
179
+ /** Max luminance a fill may have. Above this, white-on-fill stops being comfortable. */
180
+ const LUM_MAX = 0.25;
181
+ /** Contrast the coloured numeral must clear against its own composited fill. */
182
+ const INK_MIN_CONTRAST = 4.2;
183
+ /** Nominal dark container behind a pill — what a translucent fill composites over. */
184
+ const CONTAINER = "#111c2e";
185
+ const smoothstep = (t) => t * t * (3 - 2 * t);
186
+ /** Hue-path position for a level: eased inside the window, clamped outside it. */
187
+ function huePos(level) {
188
+ const t = (clampLevel(level) - HUE_LO) / (HUE_HI - HUE_LO);
189
+ return smoothstep(Math.max(0, Math.min(1, t)));
190
+ }
191
+ /**
192
+ * Hue + saturation at a position. Hue decreases monotonically 217° → 0° with no wraparound, so
193
+ * plain interpolation between adjacent stops walks the ramp in order and never takes the long way
194
+ * round the wheel.
195
+ */
196
+ function hueAt(p) {
197
+ const x = Math.max(0, Math.min(1, p));
198
+ let i = 0;
199
+ while (i < HUE_STOPS.length - 2 && x > HUE_STOPS[i + 1].p)
200
+ i++;
201
+ const a = HUE_STOPS[i];
202
+ const b = HUE_STOPS[i + 1];
203
+ const t = (x - a.p) / (b.p - a.p);
204
+ return { h: lerp(a.h, b.h, t), s: lerp(a.s, b.s, t) };
205
+ }
206
+ /** The level's own colour, before any legibility adjustment. */
207
+ function rampColour(level) {
208
+ const lvl = clampLevel(level);
209
+ const { h, s } = hueAt(huePos(lvl));
210
+ const t = (lvl - LEVEL_MIN) / (LEVEL_MAX - LEVEL_MIN);
211
+ const q = 2 * t - smoothstep(t); // complement of the hue curve
212
+ return { h, s: Math.min(100, s * lerp(S_SCALE_LO, S_SCALE_HI, q)), l: lerp(L_LO, L_HI, q) };
213
+ }
214
+ /**
215
+ * Fill lightness, solved PER HUE rather than by a flat cap — the cap that makes gold safe would
216
+ * needlessly darken blue into slate. Walks down from the level's own lightness until the fill is
217
+ * dark enough to sit under white/coloured text.
218
+ */
219
+ function fillLightness(c) {
220
+ let l = c.l;
221
+ while (l > 18 && relLum(hsl(c.h, c.s, l)) > LUM_MAX)
222
+ l -= 1;
223
+ return l;
224
+ }
225
+ /**
226
+ * The numeral's colour: the level's own hue, brightened until it clears
227
+ * {@link INK_MIN_CONTRAST} against the **composited** fill — the fill blended over the container
228
+ * at {@link FILL_ALPHA}, not the fill's own colour. That distinction is the whole thing: at 40%
229
+ * glass most of what sits behind the glyph is container, so measuring against the raw fill would
230
+ * let the ink come out too dark. Exact mirror of {@link fillLightness}: that walks a fill down
231
+ * until text is safe, this walks the text up until the hue is safe.
232
+ *
233
+ * Saturation is capped at 88% so bright ink doesn't vibrate against its own fill.
234
+ */
235
+ function inkColour(c) {
236
+ const bg = relLum(mix(hsl(c.h, c.s, fillLightness(c)), CONTAINER, FILL_ALPHA));
237
+ const s = Math.min(c.s, 88);
238
+ let l = Math.min(92, c.l + 14);
239
+ while (l < 92 && contrast(relLum(hsl(c.h, s, l)), bg) < INK_MIN_CONTRAST)
240
+ l += 1;
241
+ return hsl(c.h, s, l);
242
+ }
243
+ /** The tier badge index (0 → 9) a level belongs to. */
244
+ export function tierForLevel(level) {
245
+ return Math.floor((clampLevel(level) - LEVEL_MIN) / LEVELS_PER_TIER);
246
+ }
247
+ /**
248
+ * A level's ramp colour as one flat hex.
249
+ *
250
+ * {@link visualForLevel} returns a translucent CSS gradient, which is right for a pill and useless
251
+ * to anything that needs a single value — a gradient stop, a chart series, a canvas fill. Callers
252
+ * wanting to draw the ramp itself (e.g. a results-screen level track) sample this per level.
253
+ */
254
+ export function colourForLevel(level) {
255
+ const c = rampColour(level);
256
+ return hsl(c.h, c.s, c.l);
257
+ }
258
+ /** The pill styling for a level — its position on the ramp. */
259
+ export function visualForLevel(level) {
260
+ const c = rampColour(level);
261
+ const lf = fillLightness(c);
262
+ return {
263
+ fill: `linear-gradient(140deg,${hslA(c.h, c.s, lf, FILL_ALPHA)},${hslA(c.h, c.s, Math.max(12, lf - 11), FILL_ALPHA)})`,
264
+ accent: hslA(c.h, c.s, Math.min(72, c.l + 6), Math.min(1, FILL_ALPHA + 0.18)),
265
+ glow: rgba(hsl(c.h, c.s, c.l), 0.4 * FILL_ALPHA),
266
+ ink: inkColour(c),
267
+ };
268
+ }
269
+ // ── the ladder, materialised ──────────────────────────────────────────────────────
270
+ /** Every rung, weakest → strongest (level 1 → 50). */
271
+ export const FTP_LEVELS = Array.from({ length: LEVEL_MAX }, (_, i) => {
272
+ const level = i + LEVEL_MIN;
273
+ return { level, watts: wattsForLevel(level), tier: tierForLevel(level) };
274
+ });
275
+ /** The rung a watts value sits on — the whole {@link FtpLevel}, for UI that needs its parts. */
276
+ export function levelAt(watts) {
277
+ return FTP_LEVELS[levelForWatts(watts) - LEVEL_MIN];
278
+ }
279
+ /** Watts label for a level — "255 W". */
280
+ export function wattsLabel(level) {
281
+ return `${wattsForLevel(level)} W`;
282
+ }
283
+ /**
284
+ * Level-range label for a band — "26-30". This is what a band gets called now that the metal names
285
+ * are gone: a band is a group of five rungs on a dial, so its honest label is which rungs it spans.
286
+ */
287
+ export function levelRangeLabelForTier(tier) {
288
+ const first = tier * LEVELS_PER_TIER + LEVEL_MIN;
289
+ return `${first}-${first + LEVELS_PER_TIER - 1}`;
290
+ }
291
+ /** Watts-range label for a whole tier badge — "270-310 W". */
292
+ export function rangeLabelForTier(tier) {
293
+ const first = tier * LEVELS_PER_TIER + LEVEL_MIN;
294
+ return `${wattsForLevel(first)}-${wattsForLevel(first + LEVELS_PER_TIER - 1)} W`;
295
+ }
296
+ /** The levels belonging to a tier badge, weakest → strongest. */
297
+ export function levelsInTier(tier) {
298
+ return FTP_LEVELS.filter((l) => l.tier === tier);
299
+ }
300
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/difficulty/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,sEAAsE;AACtE,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC;AAC3B,uBAAuB;AACvB,MAAM,CAAC,MAAM,SAAS,GAAG,EAAE,CAAC;AAC5B,uCAAuC;AACvC,MAAM,YAAY,GAAG,CAAC,CAAC;AACvB,sEAAsE;AACtE,MAAM,CAAC,MAAM,OAAO,GAAG,EAAE,CAAC;AAC1B,8FAA8F;AAC9F,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,6FAA6F;AAC7F,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAC;AAE3B,4EAA4E;AAC5E,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC;AACjC,6BAA6B;AAC7B,MAAM,CAAC,MAAM,UAAU,GAAG,SAAS,GAAG,eAAe,CAAC,CAAC,KAAK;AA8B5D,qFAAqF;AAErF,2DAA2D;AAC3D,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACrE,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC9B,OAAO,GAAG,IAAI,YAAY,CAAC,CAAC,CAAC,OAAO,GAAG,aAAa,GAAG,CAAC,GAAG,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,EAAE,CAAC;AACtF,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IAC9C,IAAI,KAAK,IAAI,OAAO;QAAE,OAAO,SAAS,CAAC;IACvC,IAAI,KAAK,IAAI,OAAO;QAAE,OAAO,SAAS,CAAC;IACvC,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,GAAG,CAAC,KAAK,GAAG,OAAO,CAAC,GAAG,aAAa,CAAC,CAAC;IACtE,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,GAAG,EAAE,CAAC,CAAC;IACpC,oFAAoF;IACpF,OAAO,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC;AACnG,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,aAAa,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAC,KAAa,EAAE,SAAiB;IACxD,OAAO,aAAa,CAAC,aAAa,CAAC,KAAK,CAAC,GAAG,SAAS,CAAC,CAAC;AACzD,CAAC;AAED,qFAAqF;AAErF,MAAM,OAAO,GAAG,CAAC,CAAS,EAA4B,EAAE;IACtD,MAAM,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IAC7B,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAA6B,CAAC;AAC3F,CAAC,CAAC;AACF,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAChC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;AAC1E,MAAM,OAAO,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS,EAAU,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;AAC5F,MAAM,GAAG,GAAG,CAAC,EAAU,EAAE,EAAU,EAAE,CAAS,EAAU,EAAE;IACxD,MAAM,CAAC,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC;IACtB,MAAM,CAAC,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC;IACtB,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;AAC/F,CAAC,CAAC;AACF,MAAM,IAAI,GAAG,CAAC,GAAW,EAAE,KAAa,EAAU,EAAE,CAAC,QAAQ,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,KAAK,GAAG,CAAC;AAEhG,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS,EAAU,EAAE;IACtD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,CAAC;IACnC,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC;IACpB,MAAM,CAAC,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC;IACnD,MAAM,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,GAAG,CAAC,CAAC;IACvC,MAAM,CAAC,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACzF,OAAO,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACrD,CAAC,CAAC;AACF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS,EAAE,KAAa,EAAU,EAAE,CACtE,OAAO,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,KAAK,GAAG,CAAC;AACrF,MAAM,IAAI,GAAG,CAAC,CAAS,EAAE,CAAS,EAAE,CAAS,EAAU,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;AAE1E;;;;GAIG;AACH,MAAM,MAAM,GAAG,CAAC,GAAW,EAAU,EAAE;IACrC,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAChC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,GAAG,KAAK,EAAE,GAAG,CAAC,CAAC;IAChE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/B,OAAO,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC;AAC/E,CAAC,CAAC;AACF,MAAM,QAAQ,GAAG,CAAC,CAAS,EAAE,CAAS,EAAU,EAAE,CAChD,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAuCpD;;;;;;;;;;;;;;GAcG;AACH,MAAM,SAAS,GAAuB;IACpC,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,OAAO;IAClC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,QAAQ;IACrC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,OAAO;IACnC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,SAAS;IACpC,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,MAAM;CAChC,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,4FAA4F;AAC5F,MAAM,IAAI,GAAG,EAAE,CAAC;AAChB,MAAM,IAAI,GAAG,EAAE,CAAC;AAChB,MAAM,UAAU,GAAG,GAAG,CAAC;AACvB,MAAM,UAAU,GAAG,GAAG,CAAC;AACvB,+FAA+F;AAC/F,MAAM,UAAU,GAAG,GAAG,CAAC;AACvB,wFAAwF;AACxF,MAAM,OAAO,GAAG,IAAI,CAAC;AACrB,gFAAgF;AAChF,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAC7B,sFAAsF;AACtF,MAAM,SAAS,GAAG,SAAS,CAAC;AAE5B,MAAM,UAAU,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AAE9D,kFAAkF;AAClF,SAAS,MAAM,CAAC,KAAa;IAC3B,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IAC3D,OAAO,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AACjD,CAAC;AAED;;;;GAIG;AACH,SAAS,KAAK,CAAC,CAAS;IACtB,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACtC,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,SAAS,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC,CAAC;QAAE,CAAC,EAAE,CAAC;IAChE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAE,CAAC;IACxB,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC;IAC5B,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAClC,OAAO,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;AACxD,CAAC;AAED,gEAAgE;AAChE,SAAS,UAAU,CAAC,KAAa;IAC/B,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC9B,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACpC,MAAM,CAAC,GAAG,CAAC,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IACtD,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,8BAA8B;IAC/D,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC;AAC9F,CAAC;AAED;;;;GAIG;AACH,SAAS,aAAa,CAAC,CAAsC;IAC3D,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACZ,OAAO,CAAC,GAAG,EAAE,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,OAAO;QAAE,CAAC,IAAI,CAAC,CAAC;IAC5D,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,SAAS,CAAC,CAAsC;IACvD,MAAM,EAAE,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;IAC/E,MAAM,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC5B,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/B,OAAO,CAAC,GAAG,EAAE,IAAI,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,gBAAgB;QAAE,CAAC,IAAI,CAAC,CAAC;IACjF,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AACxB,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,SAAS,CAAC,GAAG,eAAe,CAAC,CAAC;AACvE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,MAAM,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC5B,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AAC5B,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,MAAM,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC5B,MAAM,EAAE,GAAG,aAAa,CAAC,CAAC,CAAC,CAAC;IAC5B,OAAO;QACL,IAAI,EAAE,0BAA0B,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,UAAU,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,UAAU,CAAC,GAAG;QACtH,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC,CAAC;QAC7E,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,UAAU,CAAC;QAChD,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC;KAClB,CAAC;AACJ,CAAC;AAED,qFAAqF;AAErF,sDAAsD;AACtD,MAAM,CAAC,MAAM,UAAU,GAAwB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;IACxF,MAAM,KAAK,GAAG,CAAC,GAAG,SAAS,CAAC;IAC5B,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;AAC3E,CAAC,CAAC,CAAC;AAEH,gGAAgG;AAChG,MAAM,UAAU,OAAO,CAAC,KAAa;IACnC,OAAO,UAAU,CAAC,aAAa,CAAC,KAAK,CAAC,GAAG,SAAS,CAAE,CAAC;AACvD,CAAC;AAED,yCAAyC;AACzC,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC;AACrC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAAY;IACjD,MAAM,KAAK,GAAG,IAAI,GAAG,eAAe,GAAG,SAAS,CAAC;IACjD,OAAO,GAAG,KAAK,IAAI,KAAK,GAAG,eAAe,GAAG,CAAC,EAAE,CAAC;AACnD,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,MAAM,KAAK,GAAG,IAAI,GAAG,eAAe,GAAG,SAAS,CAAC;IACjD,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,IAAI,aAAa,CAAC,KAAK,GAAG,eAAe,GAAG,CAAC,CAAC,IAAI,CAAC;AACnF,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC;AACnD,CAAC"}
@@ -28,7 +28,7 @@ import { createSpatialNav } from "@rydr/game-sdk/nav";
28
28
  const nav = createSpatialNav({
29
29
  session, // auto-subscribes to onButton (down edges → the engine)
30
30
  root: document.body, // where to scan for [data-nav]
31
- onBack: () => openPauseMenu(), // where "back" goes when no sub-screen is open (app-owned)
31
+ onBack: () => openOptionMenu(), // where "back" goes when no sub-screen is open (app-owned)
32
32
  });
33
33
  ```
34
34
 
@@ -44,4 +44,11 @@ export declare function buttonLabel(set: GlyphSet, name: ButtonName): string;
44
44
  * positionally (keycaps, prompts) and treats everything else as a plain label.
45
45
  */
46
46
  export declare function isDiamondButton(name: ButtonName): boolean;
47
+ /**
48
+ * The label printed on an upper shoulder button for `set` — `("playstation", "left")` → `"L1"`.
49
+ *
50
+ * Use it when naming the rider's resistance controls. Like {@link buttonLabel}, this is the only place
51
+ * allowed to know these strings; never hard-code `"L1"` in a prompt.
52
+ */
53
+ export declare function bumperLabel(set: GlyphSet, side: "left" | "right"): string;
47
54
  //# sourceMappingURL=glyphs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"glyphs.d.ts","sourceRoot":"","sources":["../../src/protocol/glyphs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;;GAOG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,GAAG,OAAO,CAAC;AAErE,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,EAAE,QAAiB,CAAC;AA4DlD;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAGnE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAEzD"}
1
+ {"version":3,"file":"glyphs.d.ts","sourceRoot":"","sources":["../../src/protocol/glyphs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;;GAOG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,aAAa,GAAG,UAAU,GAAG,OAAO,CAAC;AAErE,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,EAAE,QAAiB,CAAC;AA4DlD;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAGnE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAEzD;AAoBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAEzE"}
@@ -93,4 +93,30 @@ export function buttonLabel(set, name) {
93
93
  export function isDiamondButton(name) {
94
94
  return name in FACE[DEFAULT_GLYPH_SET];
95
95
  }
96
+ /**
97
+ * The UPPER shoulder buttons — the ones that are NOT game input.
98
+ *
99
+ * They deliberately have no {@link ButtonName}: on this platform the upper shoulders are the rider's
100
+ * trainer resistance − / + and are handled by the shell, never delivered to a game (see the shell-owned
101
+ * keys in `protocol/buttons.ts`). They still need lettering, though, because the shell has to *name*
102
+ * them when teaching a rider where they are — and because the controller drawing prints them.
103
+ *
104
+ * Separate from {@link SHOULDERS}, which covers the lower triggers a game DOES receive (`LT`/`RT`).
105
+ */
106
+ const BUMPERS = {
107
+ xbox: { left: "LB", right: "RB" },
108
+ playstation: { left: "L1", right: "R1" },
109
+ nintendo: { left: "L", right: "R" },
110
+ // Zwift Play has no lettering on its paddles at all, so say what they DO.
111
+ zwift: { left: "−", right: "+" },
112
+ };
113
+ /**
114
+ * The label printed on an upper shoulder button for `set` — `("playstation", "left")` → `"L1"`.
115
+ *
116
+ * Use it when naming the rider's resistance controls. Like {@link buttonLabel}, this is the only place
117
+ * allowed to know these strings; never hard-code `"L1"` in a prompt.
118
+ */
119
+ export function bumperLabel(set, side) {
120
+ return (BUMPERS[set] ?? BUMPERS[DEFAULT_GLYPH_SET])[side];
121
+ }
96
122
  //# sourceMappingURL=glyphs.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"glyphs.js","sourceRoot":"","sources":["../../src/protocol/glyphs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAcH,0DAA0D;AAC1D,MAAM,CAAC,MAAM,iBAAiB,GAAa,MAAM,CAAC;AAElD;;;;;;;GAOG;AACH,MAAM,IAAI,GAAiE;IACzE,2DAA2D;IAC3D,IAAI,EAAE,YAAY,CAAC,EAAE,YAAY,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE;IAChG,WAAW,EAAE,KAAK,CAAC,EAAE,YAAY,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE;IAChG,QAAQ,EAAE,QAAQ,CAAC,EAAE,YAAY,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE;IAChG,KAAK,EAAE,WAAW,CAAC,EAAE,YAAY,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE;CACjG,CAAC;AAEF,gGAAgG;AAChG,MAAM,SAAS,GAAiE;IAC9E,IAAI,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE;IACpE,WAAW,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE;IAC3E,QAAQ,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE;IACtE,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE;CACtE,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,GAAiE;IAC3E,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE;IACzB,WAAW,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE;IACnC,QAAQ,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE;IAC1B,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE;CAC3B,CAAC;AAEF,+EAA+E;AAC/E,MAAM,UAAU,GAAqC;IACnD,EAAE,EAAE,GAAG;IACP,IAAI,EAAE,GAAG;IACT,IAAI,EAAE,GAAG;IACT,KAAK,EAAE,GAAG;IACV,GAAG,EAAE,GAAG;IACR,KAAK,EAAE,GAAG;IACV,KAAK,EAAE,GAAG;IACV,MAAM,EAAE,GAAG;CACZ,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,GAAa,EAAE,IAAgB;IACzD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,iBAAiB,CAAC;IACnD,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAC3G,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,IAAgB;IAC9C,OAAO,IAAI,IAAI,IAAI,CAAC,iBAAiB,CAAC,CAAC;AACzC,CAAC"}
1
+ {"version":3,"file":"glyphs.js","sourceRoot":"","sources":["../../src/protocol/glyphs.ts"],"names":[],"mappings":"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"}
@@ -9,5 +9,5 @@
9
9
  export declare const RYDR_PROTOCOL_VERSION: 30;
10
10
  /** Semver of this SDK build. Sent in the handshake for telemetry/debugging.
11
11
  * (Bumped to 2.0.0 by `npm version major` on release — see CHANGELOG [Unreleased].) */
12
- export declare const RYDR_SDK_VERSION = "7.0.1";
12
+ export declare const RYDR_SDK_VERSION = "8.0.0";
13
13
  //# sourceMappingURL=version.d.ts.map
@@ -176,5 +176,5 @@
176
176
  export const RYDR_PROTOCOL_VERSION = 30;
177
177
  /** Semver of this SDK build. Sent in the handshake for telemetry/debugging.
178
178
  * (Bumped to 2.0.0 by `npm version major` on release — see CHANGELOG [Unreleased].) */
179
- export const RYDR_SDK_VERSION = "7.0.1";
179
+ export const RYDR_SDK_VERSION = "8.0.0";
180
180
  //# sourceMappingURL=version.js.map
package/dist/ui/README.md CHANGED
@@ -51,26 +51,35 @@ feed its `{x, y, onScreen}` into `setScreenPos`/`setVisible` — don't compute p
51
51
  | `mountHeatVignette(host, opts)` | fn | **Screen-edge heat vignette alone** — the peripheral amber→orange→red glow that escalates with heat. Take this when your game draws its own heat bar. `setHeat(heat01, locked)` per frame. |
52
52
  | `mountOverheatGlitch(host, opts)` | fn | **Lockout glitch overlay alone** — one-shot RGB chromatic burst on entry, then scanlines + red noise for the lockout. `setLocked(bool)` per frame. |
53
53
  | `mountOverheatHudBar(host, opts)` | fn | **The whole overheat package**: centred inferno bar + label + COOLING DOWN countdown, composing the vignette and glitch above. One `update(heat01, locked, lockoutRemaining)` per frame. |
54
- | `createLabelKeycap(button, opts)` | fn | A keycap for a button printed with a WORD, not a letter `OPTIONS` ("Menu"/"Options"/"+"), `LT`/`RT`, the stick clicks. A pill, not a pip. |
55
- | `mountPauseMenu(host, opts)` | fn | **The in-game menu**, opened by the game's own `OPTIONS` button: game name big in YOUR font, a free `Resume` row + your own rows, each able to advertise its in-game `shortcut`. Takes the controller over while up and eases the trainer. See below. |
54
+ | `createButtonKeycap(button, opts)` | fn | A keycap for the buttons that are neither a face letter nor a direction, drawn as the pad draws them: `OPTIONS` → a round pip with the pad's ICON (`☰` on Xbox/DualSense, `+` on Switch); `LT`/`RT` and the stick clicks a text pill ("LT"/"L2"/"LS"), since those are printed with a word. |
55
+ | `mountOptionMenu(host, opts)` | fn | **The in-game menu**, opened by the game's own `OPTIONS` button: game name big in YOUR font, a free `Resume` row + your own rows, each able to advertise its in-game `shortcut`. Takes the controller over while up; leaves the trainer alone. See below. |
56
+ | `mountControllerPad(host, opts)` | fn | **The rider's controller, drawn live** — buttons light while held, stick caps follow the real axes. Pass `press: session`. |
57
+ | `mountControllerMap(host, opts)` | fn | **The controls screen** — that pad flanked by action callouts, each joined by a line to its button. Takes a declarative `ControlAction[]`. |
58
+ | `mountComboDemo(host, opts)` | fn | **"Do this" animation** — loops the combination one action requires: buttons sink in, the stick swings, release, repeat. |
59
+ | `mountControlTrainer(host, opts)` | fn | **Guided lesson** — a title, one panel per step side by side, one shared controller below. Watches the rider: white "do this" → blue "doing it" → green ✓. Fires `onComplete` when every step is done. |
60
+ | `createComboStrip(action, opts)` | fn | Just the chip row for one action (`[L2] + [✕]`), for embedding in your own layout. |
61
+ | `createInputChip(token, opts)` | fn | One control's pictogram, for embedding in your own layout. Delegates to `createKeycap` / `createDpadKeycap` / `createButtonKeycap` for the buttons, and draws its own stick pictograms. The atom the two views are built from. |
62
+ | `DEFAULT_STICK_STYLE` | const | The joystick drawing convention the kit uses (`"profile"`). Override per-call with `stickStyle`. |
56
63
  | `injectCss()` | fn | Force the `.rydr-ui-*` CSS into `<head>` early (components call it for you). |
57
64
 
58
65
  Types: `DiamondButton` (`"DIAMOND_UP"\|"DIAMOND_DOWN"\|"DIAMOND_LEFT"\|"DIAMOND_RIGHT"`), `DirButton`, `KeycapVariant` (`"full"\|"solo"`),
59
66
  `ButtonSource`, `Keycap`, `Card`, `ActionCard`, `ButtonPrompt`, `DialogueCard`, `ChoiceCard`,
60
- `LabeledDiamond`, `ActionDiamond`, `Rarity`, `RarityCard`, `PauseMenu`, `PauseMenuItem`, `PauseMenuOptions`.
67
+ `LabeledDiamond`, `ActionDiamond`, `Rarity`, `RarityCard`, `OptionMenu`, `OptionMenuItem`, `OptionMenuOptions`,
68
+ `ControlAction`, `ComboToken`, `StickDir`, `StickPictogramStyle`, `PadInputSource`, `PadBrandKey`, `PadPart`, `PadLayout`,
69
+ `ControllerPad`, `ControllerMap`, `ComboDemo`, `InputChip`, `ComboStrip`.
61
70
 
62
71
  ---
63
72
 
64
- ## The in-game menu (`mountPauseMenu`)
73
+ ## The in-game menu (`mountOptionMenu`)
65
74
 
66
75
  The GAME's menu, deliberately distinct from the PLATFORM's overlay (which the shell opens on `MENU` /
67
76
  `M` and which never reaches a game). The platform overlay carries the rider's concerns — activity,
68
77
  hardware, volume, exit; this one carries the game's.
69
78
 
70
79
  ```ts
71
- import { mountPauseMenu } from "@rydr/game-sdk/ui";
80
+ import { mountOptionMenu } from "@rydr/game-sdk/ui";
72
81
 
73
- const pause = mountPauseMenu(document.body, {
82
+ const menu = mountOptionMenu(document.body, {
74
83
  session,
75
84
  title: "Split Racing", // printed big, in YOUR font
76
85
  canOpen: () => phase === "playing", // so it never stacks on your own menus
@@ -88,18 +97,137 @@ const pause = mountPauseMenu(document.body, {
88
97
  `open()`/`close()`/`toggle()`/`setItems()`, or `handleButton(name)` if your host routes input itself.
89
98
 
90
99
  **It renders in the game's font.** The title and every row inherit the host's `font-family` — set the
91
- `font` option or the `--rydr-pause-font` custom property to force one. The keycaps keep their own RYDR
100
+ `font` option or the `--rydr-option-font` custom property to force one. The keycaps keep their own RYDR
92
101
  type on purpose: they are pictures of the rider's physical controller, not your copy.
93
102
 
103
+ > **So your page must actually declare a font.** Inheritance is the whole mechanism: if nothing up the
104
+ > tree sets `font-family`, the menu inherits the browser default and renders in **Times**. Every real
105
+ > game sets a body font, so this is normally free — but if the menu looks like a legal document, that's
106
+ > the cause, and the fix is a `font-family` on your `body` (or the `font` option).
107
+
94
108
  **`shortcut` is a teaching aid.** It renders as `Shortcut [Y]` — the button that does the same thing
95
109
  *during play*. Binding it in gameplay stays your job; the menu advertises it and honours it while open.
96
110
 
97
111
  **What it does NOT do: stop your game.** No library can — your loop, timers and physics are yours, so
98
112
  freeze them in `onOpen`/`onClose`. What it *guarantees* is that a game which keeps running cannot be
99
113
  **driven**: it takes the controller over for the duration (`session.grabInput`), so your `onButton`
100
- handlers go quiet, `isDown`/`stick` read resting, and anything held is released first. It also declares
101
- `setActivity("menu")` while up and `"playing"` on close, so trainer resistance eases like on any other
102
- non-racing screen.
114
+ handlers go quiet, `isDown`/`stick` read resting, and anything held is released first.
115
+
116
+ **It also does NOT touch the trainer.** The activity state is left exactly as your game set it, so a
117
+ rider who opens this mid-run keeps the resistance they were riding. This menu is an overlay over LIVE
118
+ gameplay, not a screen they navigated to — they're still on the bike with their legs turning, and
119
+ easing under them (then snapping back on Resume) would change the difficulty of the run they're
120
+ deciding whether to restart. If your game does want easing while it's up, call
121
+ `session.setActivity("menu")` in `onOpen` and `"playing"` in `onClose` — each game decides.
122
+
123
+ ---
124
+
125
+ ## Showing your controls
126
+
127
+ **Don't hand-build a control legend.** Describe your scheme once and pass it in; the SDK owns which
128
+ physical button that is on the rider's pad, the letter printed on it, the layout, the callout lines and
129
+ the animation.
130
+
131
+ ```ts
132
+ import { mountControllerMap, mountComboDemo, type ControlAction } from "@rydr/game-sdk/ui";
133
+
134
+ const CONTROLS: ControlAction[] = [
135
+ { label: "Se déplacer", combo: ["LSTICK"], hint: "Stick gauche" },
136
+ { label: "Freiner", combo: ["LT"] },
137
+ { label: "Sauter", combo: ["DIAMOND_DOWN"] },
138
+ { label: "Tir chargé", combo: ["RT", "DIAMOND_DOWN"], hint: "Maintenir" },
139
+ { label: "Dash", combo: ["LSTICK_PRESS", "LT"] },
140
+ { label: "Ultime", combo: ["DIAMOND_UP", "DIAMOND_LEFT"], disabled: true },
141
+ ];
142
+
143
+ // STATIC — the whole scheme at once. An inline widget: you own the host.
144
+ mountControllerMap(pauseOverlay, { press: session, actions: CONTROLS, title: "Commandes" });
145
+
146
+ // DYNAMIC — "here's how you do this one", looping.
147
+ const demo = mountComboDemo(tutorialHost, { press: session, action: CONTROLS[4] });
148
+ demo.setAction(CONTROLS[3]); // swaps cleanly mid-loop
149
+ ```
150
+
151
+ A **combo token** is a `ButtonName` (`"LT"`, `"DIAMOND_DOWN"`, `"UP"`), a whole stick (`"LSTICK"` —
152
+ free movement), or a stick pushed one way (`"LSTICK:left"`). One token is a simple control; several
153
+ render as `[L2] + [✕]`, or `[L2] → [✕]` with `sequence: true`.
154
+
155
+ **The callout line points at the first *non-generic* token** — sticks and the d-pad are "generic"
156
+ because many actions share them, so `["LSTICK_PRESS", "LT"]` points at the trigger, not at the stick
157
+ every other action also mentions. Set `anchor` on the action to override. Sides and column order are
158
+ derived from where each button physically sits on the layout, so they stay right across Xbox,
159
+ PlayStation, Switch Pro, Joy-Con and Zwift shapes; set `side` to override.
160
+
161
+ **It's an inline widget, not a screen** — no backdrop, no close button, no fixed positioning. You decide
162
+ whether the host is a pause overlay, a pre-race panel or a settings tab:
163
+
164
+ ```ts
165
+ const overlay = document.createElement("div");
166
+ overlay.style.cssText = "position:fixed;inset:0;display:grid;place-items:center;background:rgba(4,8,14,.72)";
167
+ document.body.appendChild(overlay);
168
+ mountControllerMap(overlay, { press: session, actions: CONTROLS });
169
+ ```
170
+
171
+ Notes worth knowing:
172
+
173
+ - **Never name a letter.** You say `DIAMOND_DOWN`; the rider sees `A`, `✕` or `B` on the right physical
174
+ button. On a Switch Pro the highlight stays on the *bottom* button while the letters shift — that's
175
+ the whole point of the positional vocabulary. The views also **re-letter themselves** if the rider
176
+ swaps pads mid-screen (pass `press: session`), so you never rebuild them.
177
+ - **`LT`/`RT` are your shoulder buttons**, and they letter as `LT` / `L2` / `ZL`. The upper `L1`/`R1`
178
+ bumpers are the shell's resistance − / +, and `View`/`Guide` are the shell's too — all of them are
179
+ drawn faint and can never light, so a rider can tell "this game doesn't use that" from "that isn't
180
+ the game's to use".
181
+ - **Devices differ.** A Zwift Play has no joysticks and no triggers, so they aren't drawn at all. A
182
+ phone exposes only the confirm/back pair — the protocol can't tell us which buttons a device has, so
183
+ pass `available: [...]` if your game knows, and don't gate a required flow behind a rare control.
184
+ - **The joysticks are pictures, not letters.** `LSTICK` draws a well ringed by four arrows (move any
185
+ way), `LSTICK:left` slides the cap that way with one arrow, and `LSTICK_PRESS` draws a **side view** of
186
+ the stick being pushed down — because a click is motion straight at the viewer, which a top-down
187
+ drawing cannot show, and because "L3" tells a rider nothing about what to do with their thumb (the
188
+ brand letter is still in the chip's tooltip). Three conventions exist via `stickStyle`: `"profile"`
189
+ (the default — use it), `"topdown"`, `"glyph"`. Every *button* shape comes from the keycap factories, so
190
+ a trigger or OPTIONS reads identically in a legend and in an in-game prompt.
191
+ - Tuned for up to **5 callouts per side**; past that it warns rather than dropping one.
192
+ - Live catalog: `npm run showcase` in the SDK repo, or the platform's `/game-ui` route.
193
+
194
+ ### Teaching a control — `mountControlTrainer`
195
+
196
+ For a lesson the rider has to *perform*, not just watch. Built for paired controls: one concept, two
197
+ linked halves, **one shared controller drawing** below both panels.
198
+
199
+ ```ts
200
+ mountControlTrainer(modalBody, {
201
+ title: "Changer la résistance",
202
+ press: shellInput,
203
+ steps: [
204
+ { label: "Réduire la résistance", combo: ["LSTICK_PRESS", "LB"] },
205
+ { label: "Augmenter la résistance", combo: ["LSTICK_PRESS", "RB"] },
206
+ ],
207
+ onComplete: () => closeModal(), // the component never closes itself
208
+ });
209
+ ```
210
+
211
+ Each step walks three states, on both its chips and the shared pad: **whitish + pulsing** ("do this"),
212
+ **accent, pulse stops** ("you're doing it"), **green + ✓** ("done"). The prompt shows **one step at a
213
+ time, alternating**, and builds each combination **cumulatively** — the stick click lights, stays lit, and
214
+ the shoulder joins it a beat later; then the other half takes its turn, and it loops. **The two surfaces speak different languages.** The
215
+ **pad** is physical — white = *press this*, blue = *you're pressing it*, and **never green**; white and blue
216
+ have already taught the rider that colour there means *now*, so progress as a third colour reads as "and this
217
+ one too". The **panels** are logical — grey = to do, green + ✓ = done, and that stays for the rest of the
218
+ lesson. The confirmation still lands on the controls the rider just pressed, because a panel's chips picture
219
+ the same combination one line up. Nothing special happens to the pad at completion: blue persists while the
220
+ controls are held and clears when they let go, which is simply the truth of the hardware. A step goes green **the moment its whole
221
+ combination is held** — holding all of it *is* doing it, so there is nothing to wait for, and it stays green
222
+ whether or not the rider keeps the controls down. Blue is the state in between: on a two-control step, after
223
+ the first control and before the second. Focus also lingers on a step that has just gone green before the
224
+ rotation moves on, so "you did it" lands before "now this one".
225
+
226
+ > **⚠️ `LB` / `RB` (the upper shoulders) are shell-owned and a game cannot observe them.** The SDK client turns those
227
+ > keys into `trainer.adjustResistance` intents; `session.onButton` never fires for them. A game can train
228
+ > its *own* buttons, but a resistance lesson has to be driven by the shell — which is also the only place
229
+ > that lesson belongs. `mountControllerMap` may still *point at* them; only the interactive trainer needs
230
+ > a source that sees them.
103
231
 
104
232
  ---
105
233
 
@@ -115,7 +243,11 @@ createKeycap("DIAMOND_DOWN", { press: session });
115
243
  ```
116
244
 
117
245
  The keycap is a small state machine — pressed sink, bouncing attract pulse (`setAnimated`), a
118
- depleting cooldown ring + countdown (`setCooldown(frac, label)`), colored/mono (`setColored`),
246
+ depleting cooldown ring + countdown (`setCooldown(frac, label)`), mono/colored (`setColored` — **mono
247
+ is the default**: a white face with a dark letter, as everywhere else in RYDR; the per-button role hues
248
+ are opt-in via `colored: true`), and `scale` (a size multiplier — the **only** sanctioned way to shrink a
249
+ cap, since it resizes the real geometry; never `transform: scale()`, which keeps a full-size box and
250
+ blurs the text. Secondary hints only: a "press this now" prompt stays at `1`),
119
251
  disabled (`setDisabled`), and show/hide (`setVisible`).
120
252
 
121
253
  ## Common recipes
@@ -16,7 +16,7 @@ const POS_CLASS = {
16
16
  * the move state.
17
17
  */
18
18
  export function mountActionDiamond(host, opts = {}) {
19
- const colored = opts.colored ?? true;
19
+ const colored = opts.colored ?? false; // mono by default, like every keycap — see KeycapOptions.colored
20
20
  const root = document.createElement("div");
21
21
  root.className = "rydr-ui-dia";
22
22
  if (opts.rowGap != null)
@@ -1 +1 @@
1
- {"version":3,"file":"action-diamond.js","sourceRoot":"","sources":["../../src/ui/action-diamond.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAyC,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,eAAe,EAAmB,MAAM,kBAAkB,CAAC;AAEpE,MAAM,SAAS,GAAkC;IAC/C,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS;CAClG,CAAC;AA2BF;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAiB,EAAE,OAA6B,EAAE;IACnF,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC;IACrC,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,CAAC,SAAS,GAAG,aAAa,CAAC;IAC/B,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI;QAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,IAAI,CAAC;IAChE,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;QAAE,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,GAAG,IAAI,CAAC,SAAS,IAAI,CAAC;IAEzE,MAAM,KAAK,GAAG,EAAwC,CAAC;IACvD,KAAK,MAAM,MAAM,IAAI,CAAC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,cAAc,CAAoB,EAAE,CAAC;QACxG,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,MAAM,CAAC,CAAC;QAClC,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAgB,CAAC;QACpK,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,kBAAkB,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,SAAS,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClE,KAAK,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACvB,CAAC;IAED,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AACvD,CAAC"}
1
+ {"version":3,"file":"action-diamond.js","sourceRoot":"","sources":["../../src/ui/action-diamond.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAyC,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,eAAe,EAAmB,MAAM,kBAAkB,CAAC;AAEpE,MAAM,SAAS,GAAkC;IAC/C,UAAU,EAAE,SAAS,EAAE,aAAa,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS;CAClG,CAAC;AA2BF;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAiB,EAAE,OAA6B,EAAE;IACnF,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,KAAK,CAAC,CAAC,iEAAiE;IACxG,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,CAAC,SAAS,GAAG,aAAa,CAAC;IAC/B,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI;QAAE,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,IAAI,CAAC;IAChE,IAAI,IAAI,CAAC,SAAS,IAAI,IAAI;QAAE,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,GAAG,IAAI,CAAC,SAAS,IAAI,CAAC;IAEzE,MAAM,KAAK,GAAG,EAAwC,CAAC;IACvD,KAAK,MAAM,MAAM,IAAI,CAAC,YAAY,EAAE,eAAe,EAAE,cAAc,EAAE,cAAc,CAAoB,EAAE,CAAC;QACxG,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,MAAM,CAAC,CAAC;QAClC,MAAM,IAAI,GAAG,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAgB,CAAC;QACpK,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,kBAAkB,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/D,IAAI,CAAC,SAAS,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClE,KAAK,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IACvB,CAAC;IAED,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AACvD,CAAC"}