@rydr/game-sdk 8.2.0 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,726 @@
1
+ /**
2
+ * ACTION MARKER — "there is something to do THERE": a big screen-space card pinned to a point in the
3
+ * world, the A/B/Y/Z keycap joining it once the rider can act, and an arrow at the screen's edge while
4
+ * the point is off screen (PLAT-1283).
5
+ *
6
+ * Grown in the platform's tutorial, where it could be tuned against a real arena, and moved here
7
+ * unchanged (PLAT-1367) — it was written for this from the start, which is why nothing in it reaches for
8
+ * a host: the only dependency is the keycap, and even that comes in through
9
+ * {@link ActionMarkerOptions.keycap} so a host with its own lettering rules (the shell's `shellKeycap`)
10
+ * can supply one. Keep it that way.
11
+ *
12
+ * ## Screen space, not world space
13
+ *
14
+ * The prompt this replaces was painted into the arena's own canvas, in WORLD units — so the 3D arena's
15
+ * floor squash (`GROUND_TILT`) squashed the lettering with it, and the camera zoom decided how big the
16
+ * type was. A prompt is chrome: it is drawn at a fixed size, upright, in CSS pixels, and only its
17
+ * ANCHOR comes from the world (`game.worldToScreen`). Hence DOM rather than canvas — which also buys
18
+ * the real SDK keycap, real text rendering and CSS animation instead of a hand-rolled blink.
19
+ *
20
+ * ## Three states, one component (and the consumer only feeds it a point)
21
+ *
22
+ * 1. **Far** — the card alone. "There's a thing over there, and this is what it is."
23
+ * 2. **Near** — the same card plus the keycap, and the card SETTLES: it eases out of its pulse and
24
+ * comes to rest half way up its own swell, lit and ringed ({@link ActionMarker.setNear}). The whole
25
+ * arrival crossfades on one interpolated number (`--rydr-marker-near`), never a class snap.
26
+ * 3. **Off screen** — the card CLAMPS to the border with a caret pointing at the anchor, so the rider
27
+ * is never told to do something they can't see the location of. When the anchor comes back into
28
+ * view the card doesn't jump: it glides to its exact point (the follow smoothing below).
29
+ *
30
+ * The consumer calls {@link ActionMarker.place} every frame with the anchor projected to screen pixels
31
+ * and the marker decides which of the three it is. It never reads the camera, the world or the player.
32
+ *
33
+ * ## The card never moves to get out of the way
34
+ *
35
+ * A card sits where its anchor is, full stop: a prompt that slides about to dodge the character is a
36
+ * prompt whose position stops meaning anything, and it moves at the worst possible moment — as the
37
+ * rider walks up to press the button. That was tried and it is wrong.
38
+ *
39
+ * The overlap it exists in — the rider walks INTO the thing they're told to take, so the two share the
40
+ * same few hundred pixels — is a LAYERING question, and it belongs to the game, not to this component.
41
+ * Mount the layer over the drawing surface (the tutorial does) and markers are chrome that nothing can
42
+ * hide. Mount it under a surface that clears TRANSPARENT and the world covers them instead, so the
43
+ * character walks in front of a card. The marker behaves identically either way; a game picks the one
44
+ * its own scene wants. The cheap half-measure for whichever is chosen: anchor in WORLD space above the
45
+ * object (a metre over a chest, not a fixed pixel lift), and most of the overlap goes away by itself.
46
+ *
47
+ * ## Two axes: how LOUD it is, and what it's ABOUT
48
+ *
49
+ * {@link ActionMarkerTone} is the volume — a standing possibility (`hint`, still), the thing the game
50
+ * is asking for (`action`, breathing), something demanded right now (`urgent`, hammering). It is
51
+ * MOTION, nothing else.
52
+ *
53
+ * {@link ActionMarkerColor} is the subject — go/take (`green`), mind this (`orange`), danger
54
+ * (`red`), objective (`cyan`), special (`purple`), or the chrome default (`steel`). It is PALETTE,
55
+ * nothing else.
56
+ *
57
+ * The two are free of each other on purpose: any colour pairs with any tone, because a game routinely
58
+ * needs to say "danger, calmly" and "the good thing, NOW", and a component that bundles the two makes
59
+ * it pick one. A marker that never names a colour keeps the tone's own historic paint, so the default
60
+ * look is unchanged.
61
+ *
62
+ * Both axes are only CSS custom properties (`--rydr-marker-*`) underneath — the palettes above are a
63
+ * vocabulary for the common cases, not a wall: a game with its own colours sets those properties from
64
+ * its own stylesheet through {@link ActionMarkerOptions.className} and never touches this file.
65
+ */
66
+ import { createKeycap } from "./keycap.js";
67
+ /** Every role → the colour it paints. The one place the two vocabularies are tied together. */
68
+ export const MARKER_ROLE_COLORS = {
69
+ default: "white",
70
+ success: "green",
71
+ warningLight: "yellow",
72
+ warning: "orange",
73
+ danger: "red",
74
+ accent: "cyan",
75
+ accent2: "purple",
76
+ accentLight: "steel",
77
+ };
78
+ /**
79
+ * ...and the way back, plus the one-line brief for each — what a palette page prints under the swatch,
80
+ * and what a reviewer argues with. In severity order (neutral → success → the warning ladder) and then
81
+ * the three accents, because that order IS part of the vocabulary: two colours next to each other in
82
+ * this list are two things a rider is meant to be able to tell apart.
83
+ */
84
+ export const MARKER_COLORS = [
85
+ { color: "white", role: "default", use: "A plain prompt: there is something here, and nothing more is claimed." },
86
+ { color: "green", role: "success", use: "A gain, a safe direction, the thing that went right." },
87
+ { color: "yellow", role: "warningLight", use: "A soft caution — notice this; nothing has gone wrong yet." },
88
+ { color: "orange", role: "warning", use: "A real warning: a cost, a timer running down, act or lose it." },
89
+ { color: "red", role: "danger", use: "Damage, a hazard, a refusal." },
90
+ { color: "cyan", role: "accent", use: "The game's own highlight: objectives, waypoints, what the beat is asking for." },
91
+ { color: "purple", role: "accent2", use: "A second highlight for a different kind of special: rare, secret, boss." },
92
+ { color: "steel", role: "accentLight", use: "The RYDR chrome blue — quiet emphasis, the shell's own colour." },
93
+ ];
94
+ /**
95
+ * A colour or a role → the colour to paint. Roles first, so a name in both vocabularies (there is none
96
+ * today, and this keeps it true if one is ever added) can't resolve two ways depending on the caller.
97
+ */
98
+ function resolveColor(color) {
99
+ return MARKER_ROLE_COLORS[color] ?? color;
100
+ }
101
+ /** Default distance kept between the clamped card and the edge of the view, in CSS pixels. */
102
+ const EDGE_INSET = 28;
103
+ /**
104
+ * How fast the card catches up with its anchor, per second (as an exponential decay rate).
105
+ *
106
+ * High enough that following a moving anchor reads as pinned rather than dragged, low enough that the
107
+ * unclamp — the anchor coming back on screen, which can be half a screen away — is a glide the eye can
108
+ * follow instead of a teleport. Both behaviours want the same smoothing, so there is only one.
109
+ */
110
+ const FOLLOW_RATE = 14;
111
+ /** Below this, the card is simply put on its anchor: smoothing a sub-pixel gap only smears the text. */
112
+ const SNAP_PX = 0.4;
113
+ /**
114
+ * The caret's geometry, in CSS pixels — the numbers the placement maths and the drawing must agree on,
115
+ * so they live HERE and reach the stylesheet as custom properties rather than being written twice.
116
+ *
117
+ * `GAP` is measured to the arrow's BASE, which is what sits against the card: at rest the arrow all
118
+ * but touches it, and the attract animation is what carries it out and back. That reads as one object
119
+ * leaning towards the thing it points at; a caret parked in open space beside the card reads as two.
120
+ */
121
+ const CARET_GAP = 4;
122
+ const CARET_LEN = 32;
123
+ const CARET_HALF = 19;
124
+ const CARET_NUDGE = 20;
125
+ /** How much the arrow swells at the top of the beat — the card's own pulse, read at arrow scale. */
126
+ const CARET_SCALE = 1.2;
127
+ /**
128
+ * Extra room kept between the clamped card and the edge while the caret is out, in CSS pixels.
129
+ *
130
+ * The caret rides OUTSIDE the card, in the direction of the anchor — which, when the card is clamped,
131
+ * is the direction of the nearest edge. Clamp to the same inset either way and the arrow is the one
132
+ * part that lands past it and gets cut off by the layer's own overflow. So the card sits further in
133
+ * exactly while it's carrying one. Covers the gap, the triangle at full swell, and its nudge.
134
+ */
135
+ const CARET_ROOM = CARET_GAP + CARET_LEN * CARET_SCALE + CARET_NUDGE;
136
+ /** Mount an action marker into `host` (which must be a positioned element covering the play area). */
137
+ export function createActionMarker(host, opts) {
138
+ const el = document.createElement("div");
139
+ el.className = `rydr-marker${opts.className ? ` ${opts.className}` : ""}`;
140
+ el.dataset.tone = opts.tone ?? "action";
141
+ el.dataset.color = opts.color ? resolveColor(opts.color) : "white";
142
+ const caret = document.createElement("div");
143
+ caret.className = "rydr-marker-caret";
144
+ // The triangle is a CHILD of the rotated wrapper, so its attract nudge (a plain translateX) runs
145
+ // along whatever direction the caret is pointing rather than always sideways on screen.
146
+ const caretTip = document.createElement("div");
147
+ caretTip.className = "rydr-marker-caret-tip";
148
+ caret.appendChild(caretTip);
149
+ el.style.setProperty("--caret-len", `${CARET_LEN}px`);
150
+ el.style.setProperty("--caret-half", `${CARET_HALF}px`);
151
+ el.style.setProperty("--caret-nudge", `${CARET_NUDGE}px`);
152
+ el.style.setProperty("--caret-scale", `${CARET_SCALE}`);
153
+ const row = document.createElement("div");
154
+ row.className = "rydr-marker-row";
155
+ const card = document.createElement("div");
156
+ card.className = "rydr-marker-card";
157
+ if (opts.content)
158
+ opts.content(card);
159
+ else
160
+ card.textContent = opts.label;
161
+ const capSlot = document.createElement("div");
162
+ capSlot.className = "rydr-marker-cap";
163
+ const build = opts.keycap ?? ((b) => createKeycap(b, { variant: "full", animate: true }));
164
+ const cap = opts.button ? build(opts.button) : null;
165
+ if (cap)
166
+ capSlot.appendChild(cap.el);
167
+ row.append(card, capSlot);
168
+ el.append(caret, row);
169
+ host.appendChild(el);
170
+ ensureStyles(el);
171
+ let visible = !opts.hidden;
172
+ el.classList.toggle("is-hidden", !visible);
173
+ let offScreen = false;
174
+ /** Where the card actually is, in host pixels — the smoothed position, not the anchor. */
175
+ let current = null;
176
+ let lastFrame = 0;
177
+ // The card's own box and the host's, both read from observers rather than per frame: `place` runs
178
+ // every frame and an `offsetWidth` in there is a forced layout on each one of them.
179
+ let boxW = 0;
180
+ let boxH = 0;
181
+ let hostW = host.clientWidth;
182
+ let hostH = host.clientHeight;
183
+ const boxObserver = new ResizeObserver(() => {
184
+ boxW = row.offsetWidth;
185
+ boxH = row.offsetHeight;
186
+ });
187
+ boxObserver.observe(row);
188
+ const hostObserver = new ResizeObserver(() => {
189
+ hostW = host.clientWidth;
190
+ hostH = host.clientHeight;
191
+ });
192
+ hostObserver.observe(host);
193
+ const marker = {
194
+ el,
195
+ card,
196
+ get offScreen() {
197
+ return offScreen;
198
+ },
199
+ place(x, y) {
200
+ // The caret means one thing only: "the place this is about is not on your screen". So it keys off
201
+ // the ANCHOR leaving the view, never off the card having been clamped — a marker resting quietly
202
+ // near an edge would otherwise sprout an arrow pointing at an item three centimetres away.
203
+ const anchorVisible = x >= 0 && x <= hostW && y >= 0 && y <= hostH;
204
+ if (anchorVisible === offScreen) {
205
+ offScreen = !anchorVisible;
206
+ el.classList.toggle("is-offscreen", offScreen);
207
+ }
208
+ // The card must stay WHOLLY inside the view — plus the caret's own room while it's out, since the
209
+ // arrow is outside the card and on the side of the very edge being clamped to. A card wider than
210
+ // the view (a long label on a phone) would invert the range, hence the `Math.max`, which centres
211
+ // it instead of flipping it to the far edge.
212
+ const inset = (opts.edgeInset ?? EDGE_INSET) + (offScreen ? CARET_ROOM : 0);
213
+ const halfW = Math.min(boxW / 2, Math.max(0, hostW / 2 - inset));
214
+ const halfH = Math.min(boxH / 2, Math.max(0, hostH / 2 - inset));
215
+ const minY = inset + halfH;
216
+ const maxY = hostH - inset - halfH;
217
+ const cx = clamp(x, inset + halfW, hostW - inset - halfW);
218
+ let cy = clamp(y, minY, maxY);
219
+ const now = performance.now();
220
+ // The caret points from the card at the anchor it stands in for, and rides just outside the
221
+ // card's box in that direction — an ellipse, so it hugs a wide card's corner the way the
222
+ // mock-up does rather than floating off the end of it.
223
+ if (offScreen) {
224
+ const angle = Math.atan2(y - cy, x - cx);
225
+ // The wrapper's origin IS the arrow's base (the triangle is drawn outward from it), so these
226
+ // radii put the base against the card and the arrow points away from it.
227
+ const rx = boxW / 2 + CARET_GAP;
228
+ const ry = boxH / 2 + CARET_GAP;
229
+ caret.style.transform =
230
+ `translate(-50%, -50%) translate(${Math.cos(angle) * rx}px, ${Math.sin(angle) * ry}px) rotate(${angle}rad)`;
231
+ }
232
+ // Follow smoothing, frame-rate independent. The first placement snaps: a marker appearing has no
233
+ // previous position to glide from, and sliding in from the corner would read as a mistake.
234
+ const dt = current ? Math.min(0.1, Math.max(0, (now - lastFrame) / 1000)) : 0;
235
+ lastFrame = now;
236
+ if (!current)
237
+ current = { x: cx, y: cy };
238
+ else {
239
+ const k = 1 - Math.exp(-FOLLOW_RATE * dt);
240
+ current.x += (cx - current.x) * k;
241
+ current.y += (cy - current.y) * k;
242
+ if (Math.abs(cx - current.x) < SNAP_PX)
243
+ current.x = cx;
244
+ if (Math.abs(cy - current.y) < SNAP_PX)
245
+ current.y = cy;
246
+ }
247
+ el.style.transform = `translate(${Math.round(current.x)}px, ${Math.round(current.y)}px) translate(-50%, -50%)`;
248
+ },
249
+ setLabel(text) {
250
+ card.textContent = text;
251
+ },
252
+ setTone(tone) {
253
+ el.dataset.tone = tone;
254
+ },
255
+ setColor(color) {
256
+ el.dataset.color = color ? resolveColor(color) : "white";
257
+ },
258
+ setNear(on) {
259
+ el.classList.toggle("is-near", on);
260
+ cap?.setVisible(on);
261
+ },
262
+ setPressed(on) {
263
+ cap?.setPressed(on);
264
+ },
265
+ setVisible(on) {
266
+ if (visible === on)
267
+ return;
268
+ visible = on;
269
+ el.classList.toggle("is-hidden", !on);
270
+ },
271
+ dispose() {
272
+ boxObserver.disconnect();
273
+ hostObserver.disconnect();
274
+ cap?.dispose();
275
+ el.remove();
276
+ },
277
+ };
278
+ marker.setNear(false);
279
+ return marker;
280
+ }
281
+ function clamp(v, lo, hi) {
282
+ return Math.min(hi, Math.max(lo, v));
283
+ }
284
+ // ── Styles ───────────────────────────────────────────────────────────────────────────────────────
285
+ /**
286
+ * One stylesheet, adopted by every root a marker is mounted in — a shadow root gets it through
287
+ * `adoptedStyleSheets`, a light-DOM host through a `<style>` in `<head>`. The SDK's own components
288
+ * inject into `<head>` only and leave shadow hosts to mirror the blob themselves; a marker is mounted
289
+ * INSIDE a view's shadow root (the arena's HUD layer is part of the view), so it does its own.
290
+ */
291
+ const STYLE_ID = "rydr-action-marker-css";
292
+ let sheet = null;
293
+ const adopted = new WeakSet();
294
+ function ensureStyles(el) {
295
+ const root = el.getRootNode();
296
+ if (root instanceof ShadowRoot) {
297
+ if (adopted.has(root))
298
+ return;
299
+ if (!sheet) {
300
+ sheet = new CSSStyleSheet();
301
+ sheet.replaceSync(CSS);
302
+ }
303
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
304
+ adopted.add(root);
305
+ return;
306
+ }
307
+ if (document.getElementById(STYLE_ID))
308
+ return;
309
+ const tag = document.createElement("style");
310
+ tag.id = STYLE_ID;
311
+ tag.textContent = CSS;
312
+ document.head.appendChild(tag);
313
+ }
314
+ /**
315
+ * Everything paintable is a custom property on `.rydr-marker`, so a consumer restyles a marker (or all
316
+ * of them) from its own CSS without touching this file — that is the contract. The tones below only
317
+ * re-point those properties and add motion.
318
+ */
319
+ const CSS = `
320
+ /* HOW FAR INTO REACH the marker is: 0 far, 1 in reach — and every part of the near state (the card's
321
+ lift, its lit fill, the ready ring, the keycap) is a calc off this ONE number. Registered with
322
+ @property, which is the whole trick: an unregistered custom property is a string and jumps between
323
+ values, a <number> INTERPOLATES, so a plain transition on it eases the entire state across at once
324
+ instead of each piece snapping on its own clock. It is also the only knob a consumer needs to cancel
325
+ the near look wholesale (a disabled card pins it to 0 — see the showcase's refused state). */
326
+ @property --rydr-marker-near {
327
+ syntax: "<number>";
328
+ inherits: true;
329
+ initial-value: 0;
330
+ }
331
+
332
+ .rydr-marker {
333
+ --rydr-marker-bg: rgba(9, 13, 20, 0.82);
334
+ --rydr-marker-fg: #f2f6ff;
335
+ --rydr-marker-accent: #7aa2e6;
336
+ --rydr-marker-border: rgba(122, 162, 230, 0.5);
337
+ --rydr-marker-radius: 14px;
338
+ --rydr-marker-font-size: clamp(1rem, 2.1vw, 1.45rem);
339
+ --rydr-marker-pulse-ms: 1500ms;
340
+ --rydr-marker-near: 0;
341
+ /* How long arriving takes, and on what curve. Slightly springy on purpose: the card is answering the
342
+ rider, and a touch of overshoot reads as the prompt leaning towards them. */
343
+ --rydr-marker-near-ms: 260ms;
344
+ --rydr-marker-near-ease: cubic-bezier(0.3, 1.35, 0.6, 1);
345
+ /* The settled size: HALF the pulse's own swell (0.14), so a marker in reach sits between its resting
346
+ and its fully-expanded size — bigger than the far card, never as big as the peak it just left. */
347
+ --rydr-marker-near-lift: 0.07;
348
+ /* ...and the settled FILL: the pulse's peak warmth (26%) held, rather than flashed. */
349
+ --rydr-marker-near-fill: 20%;
350
+ /* Whether this tone pulses at all (hint: no). The live amplitude below is that, minus however far
351
+ into reach we are — which is what makes a card EASE out of its pulse instead of dropping it. */
352
+ --rydr-marker-pulse-strength: 1;
353
+ --rydr-marker-pulse-amp: calc(var(--rydr-marker-pulse-strength) * (1 - var(--rydr-marker-near)));
354
+ /* HOW LIT THE CARD'S HALO IS, at the two ends of the beat — the drop shadow's reach, the accent ring
355
+ and the outer glow are all one factor times these, so "the glow" is a single number in two places.
356
+ Far, it swings 0 → 1 → 0: the pulse's whole point. In reach it is pinned at 1 at BOTH ends, so the
357
+ card simply keeps the lit halo of the peak it was playing rather than breathing at rest — which is
358
+ what makes arriving read as the beat landing instead of the beat stopping. And since the far peak
359
+ is 'amp' and amp is '1 - near', "peak" is just amp + near: the two ends meet at 1 with nothing to
360
+ clamp, whichever way the rider is walking. */
361
+ --rydr-marker-glow-rest: var(--rydr-marker-near);
362
+ --rydr-marker-glow-peak: calc(var(--rydr-marker-pulse-amp) + var(--rydr-marker-near));
363
+ /* How far the cap tucks over the card's right edge, in pixels — deliberately less than the card's
364
+ own 0.9em side padding, so the cap sits against the corner and never over the lettering. A FIXED
365
+ length, not a fraction of the cap: a host with a bigger keycap (the shell's shellKeycap) would
366
+ otherwise eat further into the text the bigger its cap got. */
367
+ --cap-overlap: 10px;
368
+
369
+ position: absolute;
370
+ left: 0;
371
+ top: 0;
372
+ z-index: 2;
373
+ pointer-events: none;
374
+ transition: opacity 180ms ease,
375
+ --rydr-marker-near var(--rydr-marker-near-ms) var(--rydr-marker-near-ease);
376
+ /* The whole marker is chrome over a moving arena: it must never take part in its layout. */
377
+ will-change: transform;
378
+ }
379
+
380
+ .rydr-marker.is-hidden {
381
+ opacity: 0;
382
+ }
383
+
384
+ /* The row is only an anchor for the keycap. It has exactly the card's box, because the keycap is taken
385
+ OUT of the flow (below) — that's what makes the card the thing centred on the world point, and what
386
+ stops the prompt twitching sideways the moment the rider walks into range. */
387
+ .rydr-marker-row {
388
+ position: relative;
389
+ display: inline-block;
390
+ /* The settle lift lives HERE, not on the card, for two reasons. It has to scale the card and the
391
+ keycap together — in reach they are one control, and a card growing out from under a cap that
392
+ stayed put is the thing that reads as two objects. And it has to be a plain transform driven by
393
+ --rydr-marker-near rather than a keyframe: the card's own transform is owned by the pulse
394
+ animation, and CSS gives you no way to ease OUT of an animation you removed. Two elements, two
395
+ mechanisms, one number moving them both. */
396
+ transform: scale(calc(1 + var(--rydr-marker-near-lift) * var(--rydr-marker-near)));
397
+ }
398
+
399
+ .rydr-marker-card {
400
+ padding: 0.55em 0.9em;
401
+ border-radius: var(--rydr-marker-radius);
402
+ position: relative;
403
+ /* The lit fill is part of the RESTING style of a card in reach, not a frame of an animation: the
404
+ pulse's peak flashes this colour, arriving HOLDS it. Reduced motion, which drops every animation,
405
+ still gets it for the same reason. */
406
+ background: color-mix(in srgb,
407
+ var(--rydr-marker-accent) calc(var(--rydr-marker-near-fill) * var(--rydr-marker-near)),
408
+ var(--rydr-marker-bg));
409
+ border: 2px solid var(--rydr-marker-border);
410
+ color: var(--rydr-marker-fg);
411
+ font: 800 var(--rydr-marker-font-size) / 1.15 system-ui, -apple-system, "Segoe UI", sans-serif;
412
+ letter-spacing: 0.01em;
413
+ white-space: nowrap;
414
+ /* Whatever is in the card is CENTRED on it — the card is centred on its world point, so anything
415
+ ranged left inside it would read as leaning off the thing it names. Costs nothing on the one-line
416
+ default (the card hugs its text), and is what makes a title with a subtitle under it look pinned. */
417
+ text-align: center;
418
+ text-shadow: 0 2px 10px rgba(4, 8, 14, 0.9);
419
+ /* The RESTING halo — dark-only far, the peak's full accent glow in reach. Written out here as well
420
+ as in the keyframes so the near state survives reduced motion, which drops the animation entirely. */
421
+ box-shadow:
422
+ 0 calc(10px + 6px * var(--rydr-marker-glow-rest))
423
+ calc(30px + 14px * var(--rydr-marker-glow-rest))
424
+ rgba(4, 8, 14, calc(0.55 + 0.1 * var(--rydr-marker-glow-rest))),
425
+ 0 0 0 calc(12px * var(--rydr-marker-glow-rest))
426
+ color-mix(in srgb, var(--rydr-marker-accent) calc(34% * var(--rydr-marker-glow-rest)), transparent),
427
+ 0 0 calc(26px * var(--rydr-marker-glow-rest))
428
+ color-mix(in srgb, var(--rydr-marker-accent) calc(45% * var(--rydr-marker-glow-rest)), transparent);
429
+ backdrop-filter: blur(6px);
430
+ /* The border warms to the accent on arrival — on the near clock, like everything else. */
431
+ transition: border-color var(--rydr-marker-near-ms) var(--rydr-marker-near-ease);
432
+ }
433
+
434
+ /* The shapes a consumer's own card content usually wants, all sized off the marker's one font size so
435
+ a subtitle stays a subtitle at every viewport. Nothing in the component emits these — they exist for
436
+ whatever {@link ActionMarkerOptions.content} builds, which is the point: the inside of the card is
437
+ the game's, the box around it is ours. */
438
+ .rydr-marker-title {
439
+ display: block;
440
+ }
441
+
442
+ .rydr-marker-sub {
443
+ display: block;
444
+ margin-top: 0.15em;
445
+ /* Only ~10% off the title: the second line names what the thing IS or COSTS, which is half the
446
+ reason to read the card at all — shrunk to caption size it stopped carrying its weight. */
447
+ font-size: 0.9em;
448
+ font-weight: 800;
449
+ letter-spacing: 0.04em;
450
+ text-transform: uppercase;
451
+ color: var(--rydr-marker-accent);
452
+ opacity: 0.95;
453
+ }
454
+
455
+ .rydr-marker-meta {
456
+ display: flex;
457
+ align-items: center;
458
+ justify-content: center;
459
+ gap: 0.5em;
460
+ margin-top: 0.3em;
461
+ font-size: 0.55em;
462
+ font-weight: 700;
463
+ letter-spacing: 0.04em;
464
+ opacity: 0.72;
465
+ }
466
+
467
+ /* ABSOLUTE, straddling the card's right edge: the keycap appearing must not move a single pixel of the
468
+ card. In flow it would — the marker is centred on its anchor, so a cap joining the row pushes the
469
+ text left exactly when the rider is reading it and reaching for the button. Out of flow it simply
470
+ fades in over the corner it always occupied.
471
+
472
+ Centred on the DIAMOND's own box, not on its lit pip. Lining the big pip up with the text was the
473
+ first try and it reads wrong: it lifts the three small siblings above the card's top edge, so the
474
+ cluster looks like it slipped upwards rather than like a button sitting beside a label. Centring the
475
+ whole cluster is what looks centred. */
476
+ .rydr-marker-cap {
477
+ position: absolute;
478
+ left: 100%;
479
+ top: 50%;
480
+ transform: translate(calc(-1 * var(--cap-overlap)), -50%)
481
+ scale(calc(0.86 + 0.14 * var(--rydr-marker-near)));
482
+ /* Fades and grows in on the near clock — one arrival, not a cap appearing on a timer of its own. */
483
+ opacity: var(--rydr-marker-near);
484
+ /* Never a hit target, and never in the way of the text it sits beside. */
485
+ pointer-events: none;
486
+ }
487
+
488
+
489
+ .rydr-marker-caret {
490
+ position: absolute;
491
+ left: 50%;
492
+ top: 50%;
493
+ width: 0;
494
+ height: 0;
495
+ opacity: 0;
496
+ transition: opacity 140ms ease;
497
+ }
498
+
499
+ /* A triangle pointing along +x — the axis the wrapper is rotated to — drawn OUTWARD from the wrapper's
500
+ origin, which is why the placement maths can treat that origin as the arrow's base. Sized from the
501
+ custom properties the component sets, so the geometry has one home.
502
+
503
+ ONE CLOCK for the whole marker. The arrow's travel and the card's pulse are the same heartbeat, so
504
+ they run off the same --rydr-marker-pulse-ms with the same easing — and, crucially, the arrow's
505
+ animation runs from the moment the marker is built rather than starting when it goes off screen.
506
+ Two animations of equal duration are only in phase if they STARTED together; a caret whose animation
507
+ began the moment the anchor left the view would beat against a card that had been breathing since
508
+ the beat opened, and the pair reads as two separate things twitching. Hidden, it costs a compositor
509
+ transform on a zero-size element and nothing else. (Pausing it while hidden would desync it again on
510
+ resume, which is the whole thing this avoids.) */
511
+ .rydr-marker-caret-tip {
512
+ animation: rydr-marker-caret-nudge var(--rydr-marker-pulse-ms) ease-in-out infinite;
513
+ position: absolute;
514
+ left: 0;
515
+ top: calc(-1 * var(--caret-half));
516
+ width: 0;
517
+ height: 0;
518
+ border-left: var(--caret-len) solid var(--rydr-marker-accent);
519
+ border-top: var(--caret-half) solid transparent;
520
+ border-bottom: var(--caret-half) solid transparent;
521
+ filter: drop-shadow(0 3px 8px rgba(4, 8, 14, 0.85));
522
+ /* Swell from the BASE, not the middle: the base is the end resting against the card, so growing
523
+ about it reaches the arrow out towards what it points at. About the centre it would grow backwards
524
+ into the card by half of whatever it gained. */
525
+ transform-origin: 0 50%;
526
+ }
527
+
528
+ .rydr-marker.is-offscreen .rydr-marker-caret {
529
+ opacity: 1;
530
+ }
531
+
532
+ /* "You can do this here": the standing possibility. The CARD is still — the arrow keeps its travel,
533
+ since a marker pointing off screen has to be findable whatever its tone, and it beats on the same
534
+ clock as everything else. */
535
+ .rydr-marker[data-tone="hint"] {
536
+ /* Nothing. A hint is the STILLEST tone, not a grey one — the rule is kept as a marker of that: what
537
+ used to live here (a grey palette) is the white/default colour's job now, and a hint wearing
538
+ danger red has to come out red. */
539
+ }
540
+
541
+ /* "This is the thing on offer" (action) and "do it NOW" (urgent) are the SAME MOVEMENT — one keyframe,
542
+ the same scale and the same blink. What separates them is the CLOCK and the colour, nothing else:
543
+ urgent re-points --rydr-marker-pulse-ms and the accent below. Two different pulse shapes was the
544
+ first try and it reads as two unrelated widgets; one shape at two speeds reads as one vocabulary
545
+ with a volume knob, which is what a tone IS. */
546
+ /* The animation is on EVERY card, including hint's — hint just runs it at zero amplitude, where its
547
+ keyframes collapse to the resting style and nothing moves. One always-running animation per card is
548
+ what lets the near state fade the pulse out (amplitude → 0) rather than tear it off: swapping or
549
+ removing an animation is instant by definition, and instant is what this used to look like. */
550
+ .rydr-marker-card {
551
+ animation: rydr-marker-pulse var(--rydr-marker-pulse-ms) ease-in-out infinite;
552
+ }
553
+
554
+ .rydr-marker[data-tone="hint"] {
555
+ --rydr-marker-pulse-strength: 0;
556
+ }
557
+
558
+ /* "Do it NOW": the same pulse, much faster. The CLOCK belongs to the tone and holds whatever colour the
559
+ marker is wearing — a green urgent marker is still a marker hammering at 620 ms. */
560
+ .rydr-marker[data-tone="urgent"] {
561
+ --rydr-marker-pulse-ms: 620ms;
562
+ }
563
+
564
+ /* The amber it used to come in is GONE, deliberately: it made every urgent prompt look like a warning
565
+ whether or not the thing at the other end was one, which is the confusion the colour axis exists to
566
+ end. An urgent warning now says so — tone: "urgent" + color: "warning". */
567
+
568
+ /* ── The colour axis ─────────────────────────────────────────────────────────────────────────────
569
+ Six palettes, each one nothing but the same four custom properties re-pointed — exactly what a
570
+ consumer's own className skin does, offered as a vocabulary so the common cases don't need a
571
+ stylesheet. Which means they carry no motion and no layout: whatever the card was doing, it keeps
572
+ doing in the new colour.
573
+
574
+ Every one is built the same way, which is what makes the six read as ONE set rather than six
575
+ decisions: a bright accent that has to survive being blown up to a 12 px glow ring in the pulse, a
576
+ near-black background carrying just enough of the hue that the card looks lit from inside rather
577
+ than tinted, an off-white text colour pulled the same way (pure white beside a saturated accent
578
+ reads colder than the accent), and a border at the accent's own 0.6 alpha. Alpha on the background,
579
+ not a solid: the marker sits over a moving arena and blurs it (backdrop-filter), and that blur is
580
+ half of why a card reads as glass on top of the world instead of a hole cut in it. */
581
+ /* THE DEFAULT. A true neutral — no hue anywhere, not even in the glass — because it has to be able to
582
+ sit beside any of the other seven without joining in: the moment the default carries a tint, a card
583
+ that means nothing in particular reads as meaning whatever that tint means. Which is why steel, the
584
+ chrome blue, can't be the default even though it is the RYDR colour: beside cyan it reads as a third
585
+ accent. The accent here is a dimmed white rather than pure #fff, so the pulse's 12 px glow ring
586
+ doesn't blow out. */
587
+ .rydr-marker[data-color="white"] {
588
+ --rydr-marker-bg: rgba(10, 12, 16, 0.84);
589
+ --rydr-marker-fg: #f7f9fd;
590
+ --rydr-marker-accent: #e8edf7;
591
+ --rydr-marker-border: rgba(232, 237, 247, 0.55);
592
+ }
593
+
594
+ /* Yellow and orange are the same message at two strengths, so they are the two nearest palettes in the
595
+ set — and that is a RISK, not a nicety: a rider glancing at one has to be able to tell it is not the
596
+ other. Yellow keeps a green bias (#ffd93d) against orange's red one (#ffa23a), which is the widest
597
+ the two can be pulled apart while both still read as "caution". */
598
+ .rydr-marker[data-color="yellow"] {
599
+ --rydr-marker-bg: rgba(24, 20, 4, 0.84);
600
+ --rydr-marker-fg: #fff9e0;
601
+ --rydr-marker-accent: #ffd93d;
602
+ --rydr-marker-border: rgba(255, 217, 61, 0.62);
603
+ }
604
+
605
+ .rydr-marker[data-color="steel"] {
606
+ --rydr-marker-bg: rgba(9, 13, 20, 0.82);
607
+ --rydr-marker-fg: #f2f6ff;
608
+ --rydr-marker-accent: #7aa2e6;
609
+ --rydr-marker-border: rgba(122, 162, 230, 0.6);
610
+ }
611
+
612
+ .rydr-marker[data-color="green"] {
613
+ --rydr-marker-bg: rgba(6, 20, 13, 0.84);
614
+ --rydr-marker-fg: #ecfff4;
615
+ --rydr-marker-accent: #4ade80;
616
+ --rydr-marker-border: rgba(74, 222, 128, 0.6);
617
+ }
618
+
619
+ .rydr-marker[data-color="orange"] {
620
+ --rydr-marker-bg: rgba(24, 14, 4, 0.84);
621
+ --rydr-marker-fg: #fff4e6;
622
+ --rydr-marker-accent: #ffa23a;
623
+ --rydr-marker-border: rgba(255, 162, 58, 0.62);
624
+ }
625
+
626
+ .rydr-marker[data-color="red"] {
627
+ --rydr-marker-bg: rgba(26, 8, 8, 0.84);
628
+ --rydr-marker-fg: #ffecec;
629
+ --rydr-marker-accent: #ff5a52;
630
+ --rydr-marker-border: rgba(255, 90, 82, 0.64);
631
+ }
632
+
633
+ .rydr-marker[data-color="cyan"] {
634
+ --rydr-marker-bg: rgba(4, 20, 25, 0.84);
635
+ --rydr-marker-fg: #e6fbff;
636
+ --rydr-marker-accent: #38e1f0;
637
+ --rydr-marker-border: rgba(56, 225, 240, 0.6);
638
+ }
639
+
640
+ .rydr-marker[data-color="purple"] {
641
+ --rydr-marker-bg: rgba(16, 8, 30, 0.84);
642
+ --rydr-marker-fg: #f4ecff;
643
+ --rydr-marker-accent: #b07cff;
644
+ --rydr-marker-border: rgba(176, 124, 255, 0.62);
645
+ }
646
+
647
+ /* IN REACH — the marker stops calling and starts answering.
648
+ Every tone's attention-getting motion is a TRANSFORM on the card (the pulse's scale), and the
649
+ keycap is not part of that transform: it's parked out of flow against the card's edge. So a card
650
+ still scaling at 1.07 slides its own lettering under a cap that isn't moving, and the pair reads as
651
+ two objects that happen to be near each other rather than one control. Which is exactly wrong at
652
+ the moment it matters — the rider is in range, the button is the point, and the prompt's job has
653
+ changed from "look over here" to "press this".
654
+ So arriving SETTLES it: the pulse eases out and the card comes to rest HALF WAY UP its own swell,
655
+ holding the peak's fill and its whole accent HALO — lit and ringed, fixed, at the size between
656
+ resting and fully expanded. Nothing at rest breathes: a second, slower beat in reach is still a card
657
+ asking to be looked at, when the rider is already there and the only thing left to say is "press
658
+ this". All of it is one number, --rydr-marker-near, so the class only has to flip it and the border
659
+ it can't express as a calc; the transition on the root eases the rest. This comes after every tone
660
+ rule on purpose. */
661
+ .rydr-marker.is-near {
662
+ --rydr-marker-near: 1;
663
+ }
664
+
665
+ .rydr-marker.is-near .rydr-marker-card {
666
+ border-color: var(--rydr-marker-accent);
667
+ }
668
+
669
+ /* The ONE pulse both calling tones use — action and urgent differ only in how fast it is played.
670
+ Everything that swells does so on the same beat: the card's SIZE, the ring around it, and the card's
671
+ own FILL, which warms towards the tone's accent at the peak and falls back to the flat background.
672
+ The fill is why the pulse reads across a busy arena at all — scale alone is easy to miss against a
673
+ moving scene, a card that lights up isn't. All three are painted from --rydr-marker-accent / -bg, so
674
+ a reskin gets its own pulse for free. */
675
+ @keyframes rydr-marker-pulse {
676
+ 0%, 100% {
677
+ transform: scale(1);
678
+ opacity: calc(1 - 0.28 * var(--rydr-marker-pulse-amp));
679
+ background: color-mix(in srgb,
680
+ var(--rydr-marker-accent) calc(var(--rydr-marker-near-fill) * var(--rydr-marker-near)),
681
+ var(--rydr-marker-bg));
682
+ box-shadow:
683
+ 0 calc(10px + 6px * var(--rydr-marker-glow-rest))
684
+ calc(30px + 14px * var(--rydr-marker-glow-rest))
685
+ rgba(4, 8, 14, calc(0.55 + 0.1 * var(--rydr-marker-glow-rest))),
686
+ 0 0 0 calc(12px * var(--rydr-marker-glow-rest))
687
+ color-mix(in srgb, var(--rydr-marker-accent) calc(34% * var(--rydr-marker-glow-rest)), transparent),
688
+ 0 0 calc(26px * var(--rydr-marker-glow-rest))
689
+ color-mix(in srgb, var(--rydr-marker-accent) calc(45% * var(--rydr-marker-glow-rest)), transparent);
690
+ }
691
+ 50% {
692
+ transform: scale(calc(1 + 0.14 * var(--rydr-marker-pulse-amp)));
693
+ opacity: 1;
694
+ background: color-mix(in srgb,
695
+ var(--rydr-marker-accent)
696
+ calc(var(--rydr-marker-near-fill) * var(--rydr-marker-near) + 26% * var(--rydr-marker-pulse-amp)),
697
+ var(--rydr-marker-bg));
698
+ box-shadow:
699
+ 0 calc(10px + 6px * var(--rydr-marker-glow-peak))
700
+ calc(30px + 14px * var(--rydr-marker-glow-peak))
701
+ rgba(4, 8, 14, calc(0.55 + 0.1 * var(--rydr-marker-glow-peak))),
702
+ 0 0 0 calc(12px * var(--rydr-marker-glow-peak))
703
+ color-mix(in srgb, var(--rydr-marker-accent) calc(34% * var(--rydr-marker-glow-peak)), transparent),
704
+ 0 0 calc(26px * var(--rydr-marker-glow-peak))
705
+ color-mix(in srgb, var(--rydr-marker-accent) calc(45% * var(--rydr-marker-glow-peak)), transparent);
706
+ }
707
+ }
708
+
709
+ /* Out and back along its own axis, starting from against the card: the arrow travels most of its own
710
+ length and swells as it goes — the card's pulse, on the card's clock, in the arrow's own vocabulary.
711
+ Both extremes land at 50%, so the two peak on the same beat. */
712
+ @keyframes rydr-marker-caret-nudge {
713
+ 0%, 100% { transform: translateX(0) scale(1); }
714
+ 50% { transform: translateX(var(--caret-nudge)) scale(var(--caret-scale)); }
715
+ }
716
+
717
+ /* A rider who asked the OS for less motion gets the colours and none of the movement — the tone is
718
+ still legible, it just stops moving. */
719
+ @media (prefers-reduced-motion: reduce) {
720
+ .rydr-marker .rydr-marker-card,
721
+ .rydr-marker .rydr-marker-caret-tip {
722
+ animation: none !important;
723
+ }
724
+ }
725
+ `;
726
+ //# sourceMappingURL=action-marker.js.map