@tastic/hud 0.14.2 → 0.16.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 (53) hide show
  1. package/README.md +117 -80
  2. package/dist/chrome/index.d.mts +240 -0
  3. package/dist/chrome/index.d.ts +240 -0
  4. package/dist/chrome/index.js +772 -0
  5. package/dist/chrome/index.mjs +44 -0
  6. package/dist/chunk-JXRBXQLQ.mjs +645 -0
  7. package/dist/guide/index.js +3 -3
  8. package/dist/guide/index.mjs +3 -3
  9. package/dist/index.d.mts +21 -252
  10. package/dist/index.d.ts +21 -252
  11. package/dist/index.js +23 -23
  12. package/dist/index.mjs +186 -791
  13. package/package.json +8 -1
  14. package/src/AchievementCatalogSection.tsx +1 -1
  15. package/src/AchievementRow.tsx +9 -9
  16. package/src/BaseSettingsDialog.tsx +31 -31
  17. package/src/BaseStatsScreen.tsx +13 -13
  18. package/src/ConfirmDialog.tsx +2 -2
  19. package/src/ControlSchemePicker.tsx +9 -9
  20. package/src/CornerActionButtons.tsx +18 -10
  21. package/src/CornerStatusBadges.tsx +8 -8
  22. package/src/InlineColorPicker.tsx +30 -30
  23. package/src/LabeledDropdown.tsx +8 -8
  24. package/src/PlayerSetupPanel.tsx +25 -25
  25. package/src/PopoverBody.tsx +16 -16
  26. package/src/PressAwayOverlay.tsx +2 -2
  27. package/src/ReadyButton.tsx +3 -3
  28. package/src/SectionedDropdown.tsx +31 -31
  29. package/src/SharedActionBand.tsx +6 -6
  30. package/src/SkiaGate.tsx +5 -5
  31. package/src/SkiaGate.web.tsx +2 -2
  32. package/src/StaggeredWord.tsx +9 -9
  33. package/src/StatRow.tsx +3 -3
  34. package/src/StatSection.tsx +3 -3
  35. package/src/TriggerGauge.tsx +54 -54
  36. package/src/TriggerGaugeHost.tsx +2 -2
  37. package/src/chrome/index.ts +22 -0
  38. package/src/fonts.ts +1 -1
  39. package/src/guide/DirectionKeysHint.tsx +3 -3
  40. package/src/guide/GuideProvider.tsx +7 -7
  41. package/src/guide/HowToPlayDialog.tsx +7 -7
  42. package/src/guide/SeatDiagram.tsx +3 -3
  43. package/src/guide/SwipeHint.tsx +4 -4
  44. package/src/guide/guideSlice.ts +7 -7
  45. package/src/guide/index.ts +4 -3
  46. package/src/guide/types.ts +1 -1
  47. package/src/index.ts +1 -1
  48. package/src/loadSkiaWeb.ts +2 -2
  49. package/src/loadSkiaWeb.web.ts +2 -2
  50. package/src/overlayCard.ts +3 -3
  51. package/src/useAutoAlign.ts +23 -23
  52. package/src/usePopoverHost.ts +1 -1
  53. package/src/useZoneClampedAlign.ts +23 -23
@@ -5,79 +5,79 @@ import { StyleSheet, View } from 'react-native'
5
5
  import { Easing, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
6
6
 
7
7
  export interface TriggerGaugeProps {
8
- // One dash per option — a single-select section's arc (grid size, speed, trail speed, CPU
8
+ // One dash per option - a single-select section's arc (grid size, speed, trail speed, CPU
9
9
  // difficulty) lights exactly one dash at the selected index; a multi-select section's (powerups)
10
- // lights one dash per enabled option, independently. Fewer than 2 renders nothing — a one-option
10
+ // lights one dash per enabled option, independently. Fewer than 2 renders nothing - a one-option
11
11
  // "choice" has nothing for an arc to distinguish.
12
12
  segments: number
13
13
  litIndices: number[]
14
14
  size: number
15
15
  accentColor: string
16
16
  mutedColor: string
17
- // Each dash's own arc length in px (at this gauge's radius) — how much of the arc it visually
18
- // covers. Omit to auto-size (see DASH_GAP_PX below) so it reads as a true dashed line — each dash
17
+ // Each dash's own arc length in px (at this gauge's radius) - how much of the arc it visually
18
+ // covers. Omit to auto-size (see DASH_GAP_PX below) so it reads as a true dashed line - each dash
19
19
  // fills its own slot along the arc minus a small constant gap, regardless of segment count. Pass
20
20
  // an explicit px value to override that with a fixed size instead (won't auto-adjust for segment
21
21
  // count, so a large fixed value can visually merge adjacent dashes on a high-segment gauge like
22
- // powerups — unlike the old straight-bar version, an oversized arc here just gets long, not
22
+ // powerups - unlike the old straight-bar version, an oversized arc here just gets long, not
23
23
  // distorted, since it's a real curve rather than an approximation of one).
24
24
  dashWidth?: number
25
25
  // Where the arc begins, in degrees clockwise from 12 o'clock (matching Skia's own angle
26
- // convention once shifted — see the -90° conversion below). Traversal from startDeg to endDeg is
26
+ // convention once shifted - see the -90° conversion below). Traversal from startDeg to endDeg is
27
27
  // always clockwise, so which of the two possible arcs (the short way or the long way around) you
28
- // get depends on how far clockwise endDeg sits from startDeg — swap the two values to flip
29
- // direction. Defaults to 6 o'clock — see endDeg for why that default is a full circle rather than
28
+ // get depends on how far clockwise endDeg sits from startDeg - swap the two values to flip
29
+ // direction. Defaults to 6 o'clock - see endDeg for why that default is a full circle rather than
30
30
  // any particular arc.
31
31
  startDeg?: number
32
- // Where the arc ends — see startDeg. Defaults to whatever startDeg resolved to (its own explicit
33
- // value if you passed one, otherwise 6 o'clock), not some other fixed clock position — so passing
32
+ // Where the arc ends - see startDeg. Defaults to whatever startDeg resolved to (its own explicit
33
+ // value if you passed one, otherwise 6 o'clock), not some other fixed clock position - so passing
34
34
  // only startDeg still gets you a full circle, just seamed wherever you started it, rather than an
35
35
  // arbitrary partial arc between your startDeg and an unrelated default endDeg. Pass a distinct
36
- // endDeg to actually clamp the sweep and carve out a gap — e.g. 210 (7 o'clock) with endDeg 150
36
+ // endDeg to actually clamp the sweep and carve out a gap - e.g. 210 (7 o'clock) with endDeg 150
37
37
  // (5 o'clock) is a 300° arc, the long way over the top through 12, leaving a 60° gap under the
38
38
  // bottom through 6. Whenever the span is carved down from a full circle like that, it's divided
39
- // into exactly `segments` equal shares (see the render below) — a 2-way split gets two equal
39
+ // into exactly `segments` equal shares (see the render below) - a 2-way split gets two equal
40
40
  // shares meeting at the sweep's midpoint, a 3-way split gets three, and so on for any segment
41
- // count — every option gets a true, equal fraction of the visible arc. Segment order follows the
41
+ // count - every option gets a true, equal fraction of the visible arc. Segment order follows the
42
42
  // same clockwise sweep, so index 0 sits nearest startDeg and the last index nearest endDeg.
43
43
  endDeg?: number
44
44
  }
45
45
 
46
- // Stroke width for an unlit dash — thin, so it reads as a faint outline rather than competing with
46
+ // Stroke width for an unlit dash - thin, so it reads as a faint outline rather than competing with
47
47
  // the lit one for attention.
48
48
  const UNLIT_THICKNESS = 3
49
- // Stroke width for a lit dash — noticeably thicker than UNLIT_THICKNESS, so the active option looks
49
+ // Stroke width for a lit dash - noticeably thicker than UNLIT_THICKNESS, so the active option looks
50
50
  // like it's actually "filled in" rather than just recolored.
51
51
  const LIT_THICKNESS = 7
52
- // Gap between consecutive dashes when auto-sizing (dashWidth omitted) — constant regardless of
52
+ // Gap between consecutive dashes when auto-sizing (dashWidth omitted) - constant regardless of
53
53
  // segment count, which is what keeps the arc reading as one dashed line instead of a solid merged
54
- // ring (many segments) — sized generously so unlit and lit dashes read as clearly separate marks
54
+ // ring (many segments) - sized generously so unlit and lit dashes read as clearly separate marks
55
55
  // rather than blending into one ring at a glance.
56
56
  const DASH_GAP_PX = 9
57
- // Gap between the trigger's own icon circle and the arc — small, so the dashes sit close in around
57
+ // Gap between the trigger's own icon circle and the arc - small, so the dashes sit close in around
58
58
  // the icon rather than floating well outside its silhouette.
59
59
  const ARC_GAP = 2
60
- // Baseline for startDeg when the caller passes neither prop at all — arbitrary in principle (any
60
+ // Baseline for startDeg when the caller passes neither prop at all - arbitrary in principle (any
61
61
  // value gives a full circle once endDeg defaults to match it, see the component signature below),
62
62
  // picked as 6 o'clock so the seam sits tucked underneath the trigger rather than across its top.
63
63
  const DEFAULT_START_DEG = 180 // 6 o'clock
64
64
  // Floor so a many-segment gauge (or a very small dashWidth override) never collapses a dash's own
65
65
  // sweep down to something too thin to register as a stroke.
66
66
  const MIN_DASH_ANGLE_DEG = 4
67
- // One-shot "gauge waking up" reveal on mount — each dash sweeps in from nothing rather than popping
67
+ // One-shot "gauge waking up" reveal on mount - each dash sweeps in from nothing rather than popping
68
68
  // straight into existence, staggered by index so the reveal visibly travels along the arc instead of
69
69
  // every dash appearing at once. Purely cosmetic, and runs once per mount only (see the effect below).
70
70
  const MOUNT_DURATION_MS = 450
71
71
  const MOUNT_STAGGER_MS = 40
72
- // Selection-change transition — separate from the one-shot mount reveal above, and re-triggered
72
+ // Selection-change transition - separate from the one-shot mount reveal above, and re-triggered
73
73
  // every time litIndices actually changes after that. Quick and snappy rather than a slow drift, so
74
74
  // it reads as "the gauge just updated" without making the picker feel sluggish to use.
75
75
  const SELECTION_TRANSITION_DURATION_MS = 260
76
76
 
77
- // Arc of dashes drawn around a SectionedDropdown trigger, purely decorative-informational — the
77
+ // Arc of dashes drawn around a SectionedDropdown trigger, purely decorative-informational - the
78
78
  // trigger's own icon stays exactly as it is (see SectionedDropdown's triggerIcon), this just frames
79
79
  // it with an at-a-glance read of *which* option is active without opening the popover. Each dash is
80
- // a real Skia arc segment (Path.addArc), not a straight bar approximating one — a straight bar wide
80
+ // a real Skia arc segment (Path.addArc), not a straight bar approximating one - a straight bar wide
81
81
  // enough to look bold started visibly crossing its neighbors on a sparse (e.g. 3-option) gauge,
82
82
  // since a chord that long diverges a lot from the circle it's meant to trace; an actual arc stays on
83
83
  // the circle at any width. Skia measures angles clockwise from 3 o'clock, so a center angle here
@@ -85,66 +85,66 @@ const SELECTION_TRANSITION_DURATION_MS = 260
85
85
  // addArc.
86
86
  export function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, dashWidth, startDeg = DEFAULT_START_DEG, endDeg = startDeg }: TriggerGaugeProps) {
87
87
  // Every hook below runs unconditionally, ahead of the segments<2 bail-out further down (rules of
88
- // hooks) — harmless when it fires, since the resulting paths just never get read.
88
+ // hooks) - harmless when it fires, since the resulting paths just never get read.
89
89
  const totalDurationMs = MOUNT_DURATION_MS + Math.max(0, segments - 1) * MOUNT_STAGGER_MS
90
90
  const mountProgress = useSharedValue(0)
91
91
  useLayoutEffect(() => {
92
92
  mountProgress.value = withTiming(1, { duration: totalDurationMs, easing: Easing.out(Easing.cubic) })
93
- // Runs once per mount only — a "the gauge just appeared" cue, not tied to which option is
93
+ // Runs once per mount only - a "the gauge just appeared" cue, not tied to which option is
94
94
  // selected, so this deliberately ignores changes to props that follow.
95
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
96
96
  }, [])
97
97
 
98
- // Clockwise distance from startDeg to endDeg — e.g. 210 (7 o'clock) to 150 (5 o'clock) is 300°,
98
+ // Clockwise distance from startDeg to endDeg - e.g. 210 (7 o'clock) to 150 (5 o'clock) is 300°,
99
99
  // the long way over the top, not the 60° short way under the bottom that a naive `endDeg -
100
100
  // startDeg` would give as a negative number whenever endDeg is numerically smaller. Divided evenly
101
- // across every segment (not every *gap* between segments — see step below) so each option gets a
101
+ // across every segment (not every *gap* between segments - see step below) so each option gets a
102
102
  // true equal fraction of the visible arc regardless of segment count, which is what actually keeps
103
103
  // a 2-segment gauge from reading differently proportioned than a 3- or 4-segment one. The %360
104
104
  // double-mod can't distinguish "0° apart" from "360° apart" (both reduce to 0), and a 0° sweep is
105
- // never a useful gauge — every dash would collapse onto the same point — so a 0 result is treated
105
+ // never a useful gauge - every dash would collapse onto the same point - so a 0 result is treated
106
106
  // as the far more likely intent, a full unbroken circle, rather than a degenerate one.
107
107
  const rawSweepDeg = (((endDeg - startDeg) % 360) + 360) % 360
108
108
  const totalSweepDeg = rawSweepDeg === 0 ? 360 : rawSweepDeg
109
- // Each segment's own angular share of totalSweepDeg — doubles as both "this dash's width before
109
+ // Each segment's own angular share of totalSweepDeg - doubles as both "this dash's width before
110
110
  // the gap is subtracted" (see autoDashAngleDeg below) and "the spacing between consecutive
111
111
  // centers" (see centerDeg), since a set of equal, contiguous shares naturally has both properties
112
112
  // at once.
113
113
  const step = segments > 0 ? totalSweepDeg / segments : 0
114
114
  // Segment i's own center sits half a share clockwise from startDeg, then a further whole share per
115
- // preceding index — i.e. the middle of the i-th equal slice, not at either of its edges. Segment
115
+ // preceding index - i.e. the middle of the i-th equal slice, not at either of its edges. Segment
116
116
  // 0 is the first slice *after* startDeg (never sits exactly on startDeg itself), and the last
117
- // segment's slice ends exactly on endDeg. Index increases clockwise — left to right across the
118
- // top — matching normal reading order.
117
+ // segment's slice ends exactly on endDeg. Index increases clockwise - left to right across the
118
+ // top - matching normal reading order.
119
119
  const centerDeg = useCallback((i: number) => startDeg + (i + 0.5) * step, [startDeg, step])
120
120
  // A single-select trigger (grid size, speed, trail speed, CPU difficulty) always has exactly one
121
- // lit index — a multi-select one (powerups) can have any count, including exactly 1, so this is a
121
+ // lit index - a multi-select one (powerups) can have any count, including exactly 1, so this is a
122
122
  // structural property of which *kind* of section this gauge belongs to, not something that flips
123
123
  // render to render in practice.
124
124
  const singleSelect = litIndices.length === 1
125
125
 
126
- // The lit arc's own center angle, for the singleSelect case — tracked continuously rather than
126
+ // The lit arc's own center angle, for the singleSelect case - tracked continuously rather than
127
127
  // computed fresh from a "from index" + 0..1 progress pair, specifically so a rapid follow-up
128
128
  // selection can *retarget* an already-in-flight rotation instead of snapping to a fixed start
129
129
  // angle first. withTiming does this automatically when you assign a new target to a shared value
130
130
  // that's already mid-animation: it interpolates from wherever the value actually is right now, not
131
131
  // from the animation's original start point. Resetting the value before restarting the animation
132
- // (this file's own previous approach) throws that away — every follow-up transition would snap the
132
+ // (this file's own previous approach) throws that away - every follow-up transition would snap the
133
133
  // arc to the *previous* transition's fixed departure angle before rotating on, which read as a
134
134
  // glitch/teleport on anything but the very first change. Initial value is set directly, before any
135
- // animation exists, so the very first mount doesn't rotate in from nowhere — see the mount reveal
135
+ // animation exists, so the very first mount doesn't rotate in from nowhere - see the mount reveal
136
136
  // (sweep width, not angle) above for that.
137
137
  const angleDeg = useSharedValue(centerDeg(litIndices[0] ?? 0))
138
138
 
139
- // Selection-change transition state, used only for the multi-select (non-singleSelect) case now —
139
+ // Selection-change transition state, used only for the multi-select (non-singleSelect) case now -
140
140
  // singleSelect handles its own transition via angleDeg above. `transitionFrom` is whatever was lit
141
- // just before the most recent change — detected by comparing litIndicesKey against lastKeyRef,
141
+ // just before the most recent change - detected by comparing litIndicesKey against lastKeyRef,
142
142
  // which trails one change behind (see lastIndicesRef.current below). This lives in
143
- // useLayoutEffect rather than a plain useEffect specifically so it fires — and, since
144
- // setTransitionFrom during it triggers a synchronous re-render, *resolves* — before the browser
143
+ // useLayoutEffect rather than a plain useEffect specifically so it fires - and, since
144
+ // setTransitionFrom during it triggers a synchronous re-render, *resolves* - before the browser
145
145
  // ever paints the frame it was scheduled from: a plain useEffect can run after paint, letting React
146
146
  // commit one real frame with the new litIndices against the still-stale transitionFrom/
147
- // selectionProgress first, which reads as a flash. Refs, not state, for the tracking itself —
147
+ // selectionProgress first, which reads as a flash. Refs, not state, for the tracking itself -
148
148
  // react-hooks/refs correctly flags reading or writing a ref during the render body (a discarded/
149
149
  // replayed render can leave a ref mutated without ever committing, corrupting this tracking in a
150
150
  // way that reproduces intermittently rather than every time), so this all needs to live inside an
@@ -173,7 +173,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
173
173
 
174
174
  const radius = size / 2 + ARC_GAP
175
175
  // step is always >= 0 now (totalSweepDeg's double-mod guarantees it, unlike the old signed
176
- // sweepDeg this replaced), so this no longer needs Math.abs — kept as its own name rather than
176
+ // sweepDeg this replaced), so this no longer needs Math.abs - kept as its own name rather than
177
177
  // reusing `step` directly since this one specifically means "before the gap is subtracted."
178
178
  const slotDeg = step
179
179
  // A px gap converted to degrees at this radius (arc length = radius * angleInRadians), so the gap
@@ -182,7 +182,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
182
182
  // No upper clamp: each dash fills its own equal slot minus the constant gap, whatever that share
183
183
  // works out to be, so a 2-segment gauge tiles as fully as a 4-segment one instead of stopping at
184
184
  // some fixed width and leaving the rest of a large share visibly empty (that mismatch is exactly
185
- // what a fixed ceiling used to cause — same segment count, same full circle, but only some of
185
+ // what a fixed ceiling used to cause - same segment count, same full circle, but only some of
186
186
  // them read as "divided all the way around" and others didn't).
187
187
  const autoDashAngleDeg = Math.max(MIN_DASH_ANGLE_DEG, slotDeg - gapDeg)
188
188
  const dashAngleDeg = dashWidth != null ? Math.max(MIN_DASH_ANGLE_DEG, (dashWidth / radius) * (180 / Math.PI)) : autoDashAngleDeg
@@ -193,11 +193,11 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
193
193
  const canvasSize = diameter + LIT_THICKNESS
194
194
  const oval = Skia.XYWHRect(LIT_THICKNESS / 2, LIT_THICKNESS / 2, diameter, diameter)
195
195
 
196
- // One combined path per style group (lit vs unlit) rather than one path per dash — a Path strokes
196
+ // One combined path per style group (lit vs unlit) rather than one path per dash - a Path strokes
197
197
  // as a single color/width, so dashes sharing a style share a path; this also keeps the number of
198
198
  // useDerivedValue calls fixed at two regardless of segment count, which a per-dash hook would
199
199
  // violate (hook counts can't vary across renders). Always reflects the FINAL/target litIndices,
200
- // dim — including whatever's mid-"leaving" in litPath below, so the dim base is already sitting
200
+ // dim - including whatever's mid-"leaving" in litPath below, so the dim base is already sitting
201
201
  // there the instant a lit dash starts shrinking off of it, rather than popping in only once the
202
202
  // shrink finishes.
203
203
  const unlitPath = useDerivedValue(() => {
@@ -212,13 +212,13 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
212
212
  }
213
213
  const sweep = dashAngleDeg * local
214
214
  if (sweep <= 0) continue
215
- // Recomputed inline (not calling the outer centerDeg helper) — this runs inside a
215
+ // Recomputed inline (not calling the outer centerDeg helper) - this runs inside a
216
216
  // useDerivedValue worklet, and the helper above is a plain JS closure, not itself workletized.
217
217
  const dashCenterDeg = startDeg + (i + 0.5) * step
218
218
  path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
219
219
  }
220
220
  return path
221
- // litIndicesKey, not litIndices — the array itself is a brand-new reference on every render
221
+ // litIndicesKey, not litIndices - the array itself is a brand-new reference on every render
222
222
  // (including ones triggered by a totally unrelated sibling gauge or settings field), which would
223
223
  // otherwise force this worklet to rebuild on every such render. The stable string key only
224
224
  // actually changes when the *content* does, so an unrelated re-render leaves the already-running
@@ -227,11 +227,11 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
227
227
 
228
228
  const litPath = useDerivedValue(() => {
229
229
  const path = Skia.Path.Make()
230
- // The common case — a single-select trigger swapping from one tier to another — is just one
230
+ // The common case - a single-select trigger swapping from one tier to another - is just one
231
231
  // full-width arc sitting at angleDeg's current value, which useLayoutEffect above retargets
232
232
  // (never resets) on every change; this alone gives a real rotating handoff, including smoothly
233
233
  // redirecting mid-rotation if another change interrupts it. Only reached once the mount reveal
234
- // is done — before that, the sweep-width stagger below owns the reveal instead.
234
+ // is done - before that, the sweep-width stagger below owns the reveal instead.
235
235
  if (mountProgress.value >= 1 && singleSelect) {
236
236
  path.addArc(oval, angleDeg.value - 90 - dashAngleDeg / 2, dashAngleDeg)
237
237
  return path
@@ -248,21 +248,21 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
248
248
  const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
249
249
  local = clamp((mountProgress.value - startFrac) / (endFrac - startFrac), 0, 1)
250
250
  } else if (isLitNow === wasLit) {
251
- local = 1 // steady — lit before and after (or the mount-only branch above already handled unlit)
251
+ local = 1 // steady - lit before and after (or the mount-only branch above already handled unlit)
252
252
  } else if (isLitNow) {
253
- local = selectionProgress.value // entering — grows in
253
+ local = selectionProgress.value // entering - grows in
254
254
  } else {
255
- local = 1 - selectionProgress.value // leaving — shrinks away
255
+ local = 1 - selectionProgress.value // leaving - shrinks away
256
256
  }
257
257
  const sweep = dashAngleDeg * local
258
258
  if (sweep <= 0) continue
259
- // Recomputed inline (not calling the outer centerDeg helper) — this runs inside a
259
+ // Recomputed inline (not calling the outer centerDeg helper) - this runs inside a
260
260
  // useDerivedValue worklet, and the helper above is a plain JS closure, not itself workletized.
261
261
  const dashCenterDeg = startDeg + (i + 0.5) * step
262
262
  path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
263
263
  }
264
264
  return path
265
- // litIndicesKey/transitionFromKey, not the arrays themselves — see unlitPath's identical comment.
265
+ // litIndicesKey/transitionFromKey, not the arrays themselves - see unlitPath's identical comment.
266
266
  }, [segments, litIndicesKey, transitionFromKey, singleSelect, step, startDeg, dashAngleDeg, totalDurationMs, oval.x, oval.y, oval.width, oval.height])
267
267
 
268
268
  if (segments < 2) return null
@@ -2,12 +2,12 @@ import { SkiaGate } from './SkiaGate'
2
2
  import { TriggerGaugeProps } from './TriggerGauge'
3
3
 
4
4
  // TriggerGauge.tsx's `Skia` import binds to global.CanvasKit at module-evaluation time, which on web
5
- // only exists once CanvasKit's WASM fetch resolves — a plain `import { TriggerGauge } from
5
+ // only exists once CanvasKit's WASM fetch resolves - a plain `import { TriggerGauge } from
6
6
  // './TriggerGauge'` at the top of this file would already have evaluated that binding, against a
7
7
  // not-yet-loaded, undefined CanvasKit, before anything in a consuming app renders at all, since
8
8
  // bundlers evaluate the whole static import graph up front. This file therefore never imports
9
9
  // TriggerGauge itself except as a type (erased at compile time, carrying no runtime module
10
- // reference) — getTriggerGauge below, hoisted to module scope so its identity stays stable across
10
+ // reference) - getTriggerGauge below, hoisted to module scope so its identity stays stable across
11
11
  // renders (see SkiaGate's own comment on why that matters), is what actually defers the real import
12
12
  // to runtime. No explicit loadSkiaWeb() call here on web: SkiaGate.web.tsx (WithSkiaWeb) already
13
13
  // gates its own mount behind CanvasKit being ready, and on native there's nothing to wait on at all.
@@ -0,0 +1,22 @@
1
+ // The fleet-chrome entry point (`@tastic/hud/chrome`): the shared dialogs, stats screen pieces, corner
2
+ // buttons and style tokens, reachable without the main barrel, for an app that has none of its heavy
3
+ // peers. Hangman is the reason it exists: it has @tastic/core, @rific/updater and the Paper stack, but
4
+ // no Skia, no @tastic/profile and no @tastic/split-screen. Everything reachable from this file must stay
5
+ // free of those three and of the main barrel; @tastic/achievements may appear only as `import type`.
6
+ // AchievementUnlockList is left out on purpose because it value-imports @tastic/profile's ProfileChip.
7
+ // src/__tests__/chromeImports.test.ts walks the real import graph from here and enforces all of it.
8
+ export { AchievementCatalogSection } from '../AchievementCatalogSection'
9
+ export { AchievementRow, LOCKED_BADGE_COLOR } from '../AchievementRow'
10
+ export { ActivityStatSection } from '../ActivityStatSection'
11
+ export { BaseSettingsDialog, type BaseSettingsDialogProps } from '../BaseSettingsDialog'
12
+ export { BaseStatsScreen } from '../BaseStatsScreen'
13
+ export { ConfirmDialog, type ConfirmDialogProps } from '../ConfirmDialog'
14
+ export { ContentGutter } from '../ContentGutter'
15
+ export { CornerActionButtons } from '../CornerActionButtons'
16
+ export { MONO_FONT } from '../fonts'
17
+ export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from '../overlayCard'
18
+ export { RotatedFrame, type RotatedFrameProps } from '../RotatedFrame'
19
+ export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from '../StaggeredWord'
20
+ export { StatRow } from '../StatRow'
21
+ export { StatSection } from '../StatSection'
22
+ export { UpdateDialog, type UpdateDialogProps } from '../UpdateDialog'
package/src/fonts.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Platform } from 'react-native'
2
2
 
3
- // System monospace — every component here defaults its labels to this unless a consumer passes
3
+ // System monospace - every component here defaults its labels to this unless a consumer passes
4
4
  // its own `labelFontFamily`, so a game with its own branded/registered font can override it
5
5
  // without this package needing to know that font exists.
6
6
  export const MONO_FONT = Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
@@ -12,7 +12,7 @@ export interface DirectionKeyLabels {
12
12
  export type DirectionKeysAxis = 'both' | 'vertical'
13
13
 
14
14
  export interface DirectionKeysHintProps {
15
- // What's printed on each cap — e.g. { up: 'W', left: 'A', down: 'S', right: 'D' }. Omit for the
15
+ // What's printed on each cap - e.g. { up: 'W', left: 'A', down: 'S', right: 'D' }. Omit for the
16
16
  // arrow keys themselves, drawn as arrow icons (so does any single cap left out). Pass whatever the
17
17
  // seat's own key scheme actually is, not a guess: the fleet games let each seat pick WASD, arrows,
18
18
  // IJKL and so on.
@@ -20,7 +20,7 @@ export interface DirectionKeysHintProps {
20
20
  // 'both' (default) draws the four-key inverted T. 'vertical' draws only up above down, for a game
21
21
  // whose controls only ever read those two (Pong's paddles).
22
22
  axis?: DirectionKeysAxis
23
- // Defaults to the theme's own primary — i.e. player 1's seat color in the fleet games.
23
+ // Defaults to the theme's own primary - i.e. player 1's seat color in the fleet games.
24
24
  color?: string
25
25
  }
26
26
 
@@ -42,7 +42,7 @@ function KeyCap({ label, icon, color }: { label?: string; icon: string; color: s
42
42
  )
43
43
  }
44
44
 
45
- // Key caps in the inverted-T every keyboard player already knows (or just up over down, see `axis`) —
45
+ // Key caps in the inverted-T every keyboard player already knows (or just up over down, see `axis`) -
46
46
  // the desktop counterpart to SwipeHint for the "how do I steer" card, since a desktop browser steers by
47
47
  // key, not by swipe. Plain Views, no animation, so there's nothing for reduced motion to switch off.
48
48
  export function DirectionKeysHint({ labels, axis = 'both', color }: DirectionKeysHintProps) {
@@ -5,19 +5,19 @@ import type { GuideRotation, GuideStep } from './types'
5
5
 
6
6
  interface GuideContextValue {
7
7
  isOpen: boolean
8
- // Opens the guide unconditionally — the Settings "How to play" row's whole job. Never persists
8
+ // Opens the guide unconditionally - the Settings "How to play" row's whole job. Never persists
9
9
  // anything on its own: only finishing or skipping does, and only while the player hasn't already
10
10
  // seen this version.
11
11
  open: () => void
12
12
  close: () => void
13
- // Opens it once per session, and only if this player hasn't seen `currentVersion` yet — see
13
+ // Opens it once per session, and only if this player hasn't seen `currentVersion` yet - see
14
14
  // useAutoShowGuide.
15
15
  requestAutoShow: () => void
16
16
  }
17
17
 
18
18
  const noop = () => {}
19
19
 
20
- // A default that does nothing rather than throwing when there's no provider above — an app's own
20
+ // A default that does nothing rather than throwing when there's no provider above - an app's own
21
21
  // existing tests render its Settings dialog or Home screen without one, and a "How to play" row that
22
22
  // silently does nothing in that harness is a far better failure than every such test breaking.
23
23
  const GuideContext = createContext<GuideContextValue>({ isOpen: false, open: noop, close: noop, requestAutoShow: noop })
@@ -35,13 +35,13 @@ export interface GuideProviderProps {
35
35
  children?: ReactNode
36
36
  }
37
37
 
38
- // Mounted once at the app root, next to <UpdateDialog /> — the fleet's existing "self-presenting
39
- // app-level dialog" seat — so any screen can open it through useGuide() without prop-drilling, and
38
+ // Mounted once at the app root, next to <UpdateDialog /> - the fleet's existing "self-presenting
39
+ // app-level dialog" seat - so any screen can open it through useGuide() without prop-drilling, and
40
40
  // the Portal it renders sits above every route. Inside PersistGate, so `seenVersion` is already the
41
41
  // rehydrated value by the time this first renders.
42
42
  export function GuideProvider({ steps, currentVersion, seenVersion, onSeen, rotation, children }: GuideProviderProps) {
43
43
  const [isOpen, setIsOpen] = useState(false)
44
- // Once per session, not once per Home mount — Home remounts every time the player backs out of a
44
+ // Once per session, not once per Home mount - Home remounts every time the player backs out of a
45
45
  // match, and skipping shouldn't be something they have to do again a minute later just because the
46
46
  // persisted flag hadn't been written back through the store yet.
47
47
  const autoShownRef = useRef(false)
@@ -74,7 +74,7 @@ export function useGuide(): GuideContextValue {
74
74
  return useContext(GuideContext)
75
75
  }
76
76
 
77
- // Call this from the Home screen and nowhere else — that placement, not any route check in here, is
77
+ // Call this from the Home screen and nowhere else - that placement, not any route check in here, is
78
78
  // what keeps it from ever firing over a live match or a two-seat loadout. Home is the one screen
79
79
  // with a single reader, before any seating, so a face-to-face two-player phone doesn't need per-seat
80
80
  // rotated copies of the card. `enabled` lets a caller hold it back (e.g. while a shared-link import
@@ -46,7 +46,7 @@ export interface HowToPlayDialogProps {
46
46
  steps: GuideStep[]
47
47
  // Last card's primary button.
48
48
  onFinish: () => void
49
- // Skip (first card only — see the action row), and Android's hardware back button — both mean "I don't need this", so a caller persisting
49
+ // Skip (first card only - see the action row), and Android's hardware back button - both mean "I don't need this", so a caller persisting
50
50
  // "seen" should treat them exactly like onFinish.
51
51
  onSkip: () => void
52
52
  // The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated
@@ -147,8 +147,8 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
147
147
  )}
148
148
 
149
149
  {/* ConfirmDialog's exact row (see overlayActionStyles): outlined secondary on the left,
150
- contained primary on the right. Skip and Back share the one secondary slot — three
151
- full-size buttons don't fit side by side at the row's 128 minimum — so Skip is only
150
+ contained primary on the right. Skip and Back share the one secondary slot - three
151
+ full-size buttons don't fit side by side at the row's 128 minimum - so Skip is only
152
152
  offered on the first card; after that the way out is Next to the end (a short flow) or
153
153
  Android's hardware back, which skips from anywhere. */}
154
154
  <View style={overlayActionStyles.actions}>
@@ -167,15 +167,15 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
167
167
  )
168
168
  }
169
169
 
170
- // A short, skippable, paged "how to play" card — the one shared shape every fleet game's intro takes,
170
+ // A short, skippable, paged "how to play" card - the one shared shape every fleet game's intro takes,
171
171
  // built on the same retro overlay-card look as ConfirmDialog (see overlayCard.ts) so it reads as
172
172
  // part of the same family. Controlled and presentational: whether it's visible, and what happens on
173
- // finish/skip (persisting "seen", mostly), is the caller's — see GuideProvider for the wiring nearly
173
+ // finish/skip (persisting "seen", mostly), is the caller's - see GuideProvider for the wiring nearly
174
174
  // every app wants. Buttons are the primary way through; the paging ScrollView underneath is what
175
175
  // makes a swipe work too, with no gesture-handler dependency.
176
176
  //
177
177
  // Split into an outer visibility gate and an inner card so the card's own page index is created
178
- // fresh every time it opens — a replay from Settings always starts on the first card, with no
178
+ // fresh every time it opens - a replay from Settings always starts on the first card, with no
179
179
  // effect needed to reset it.
180
180
  export function HowToPlayDialog({ visible, steps, ...rest }: HowToPlayDialogProps) {
181
181
  if (!visible || steps.length === 0) return null
@@ -193,7 +193,7 @@ const styles = StyleSheet.create({
193
193
  body: {
194
194
  textAlign: 'center'
195
195
  },
196
- // Additive on top of overlayCard's own shared `card` shape — its default 32px padding is tighter
196
+ // Additive on top of overlayCard's own shared `card` shape - its default 32px padding is tighter
197
197
  // here (a paged card wants more width for its art) and the shared 16px gap is right.
198
198
  card: {
199
199
  padding: CARD_PADDING
@@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'
3
3
  import { Text } from 'react-native-paper'
4
4
 
5
5
  export interface SeatDiagramProps {
6
- // Default to the theme's own secondary/primary — in the fleet games those ARE the two players'
6
+ // Default to the theme's own secondary/primary - in the fleet games those ARE the two players'
7
7
  // live seat colors, so the picture always matches whatever colors they've picked.
8
8
  topColor?: string
9
9
  bottomColor?: string
@@ -17,7 +17,7 @@ const DEVICE_HEIGHT = 120
17
17
  // "Two players, one phone": a portrait phone split into a top half and a bottom half, each tinted in
18
18
  // its own seat color, with the top label upside down the way that player actually reads it. The
19
19
  // seating card every face-to-face game (LightCycles, Snake, AirHockey, BoxHockey, Pong) needs, drawn
20
- // once. Plain Views, no animation — nothing here needs reduced-motion handling.
20
+ // once. Plain Views, no animation - nothing here needs reduced-motion handling.
21
21
  export function SeatDiagram({ topColor, bottomColor, topLabel = 'P2', bottomLabel = 'P1' }: SeatDiagramProps) {
22
22
  const { colors } = useAutoPaperTheme()
23
23
  const top = topColor ?? colors.secondary
@@ -64,7 +64,7 @@ const styles = StyleSheet.create({
64
64
  label: {
65
65
  fontWeight: 'bold'
66
66
  },
67
- // A wash of the seat color behind its own label rather than an rgba() fill — the theme's colors
67
+ // A wash of the seat color behind its own label rather than an rgba() fill - the theme's colors
68
68
  // aren't guaranteed to be 6-digit hex, so an opacity layer is the only alpha that always works.
69
69
  tint: {
70
70
  opacity: 0.2
@@ -13,7 +13,7 @@ export interface SwipeHintProps {
13
13
  // plays by dragging). The mouse variant also relabels itself "Drag" for screen readers, since a
14
14
  // desktop player should never be told to swipe.
15
15
  pointer?: SwipePointer
16
- // Defaults to the theme's own primary — i.e. the player's own seat color in the fleet games.
16
+ // Defaults to the theme's own primary - i.e. the player's own seat color in the fleet games.
17
17
  color?: string
18
18
  // Length of the dotted path in points, arrowhead excluded.
19
19
  length?: number
@@ -28,7 +28,7 @@ const PAUSE_MS = 500
28
28
  const ARROW_ICON: Record<SwipeDirection, string> = { up: 'arrow-up', down: 'arrow-down', left: 'arrow-left', right: 'arrow-right' }
29
29
 
30
30
  // A finger-sized ring (or, with pointer='mouse', a mouse) sweeping along a dotted path toward an
31
- // arrowhead, on a loop — the "swipe this way" illustration the arcade games (LightCycles, Snake, Pong,
31
+ // arrowhead, on a loop - the "swipe this way" illustration the arcade games (LightCycles, Snake, Pong,
32
32
  // AirHockey, BoxHockey) each need for their first card, so it lives here once rather than being
33
33
  // redrawn per app. The mouse variant is the same card on desktop web for a seat that plays by
34
34
  // dragging; Snake and AirHockey each drew their own before it existed. Plain Views plus Reanimated:
@@ -40,7 +40,7 @@ export function SwipeHint({ direction = 'right', pointer = 'finger', color, leng
40
40
  const reducedMotion = useReducedMotion()
41
41
  const progress = useSharedValue(0.5)
42
42
  const horizontal = direction === 'left' || direction === 'right'
43
- // Which way the finger travels along its own axis — left/up run against the positive x/y axes.
43
+ // Which way the finger travels along its own axis - left/up run against the positive x/y axes.
44
44
  const sign = direction === 'right' || direction === 'down' ? 1 : -1
45
45
 
46
46
  useEffect(() => {
@@ -94,7 +94,7 @@ const styles = StyleSheet.create({
94
94
  justifyContent: 'space-between'
95
95
  },
96
96
  // Centered on the whole path (not on the dots row alone) by the container's own alignItems/
97
- // justifyContent, then pushed along the axis by its own animated translate — absolute so it never
97
+ // justifyContent, then pushed along the axis by its own animated translate - absolute so it never
98
98
  // takes space from the dots and arrowhead it's drawn over.
99
99
  finger: {
100
100
  borderRadius: FINGER_SIZE / 2,
@@ -1,18 +1,18 @@
1
1
  // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
2
- // dependency on the package itself — same reasoning as @tastic/profile's createSeatColorsSlice, whose
2
+ // dependency on the package itself - same reasoning as @tastic/profile's createSeatColorsSlice, whose
3
3
  // factory shape this follows: this file has no opinion on whether the consuming app uses
4
4
  // redux-persist at all, and the string is a long-stable part of its public contract.
5
5
  const REHYDRATE = 'persist/REHYDRATE'
6
6
 
7
7
  export interface GuideState {
8
- // The highest guide version this player has finished or skipped — 0 means "never". Compared
8
+ // The highest guide version this player has finished or skipped - 0 means "never". Compared
9
9
  // against the app's own `currentVersion`, so bumping that later re-prompts everyone (only worth
10
10
  // doing for a genuine controls/rules change, not a copy tweak).
11
11
  versionSeen: number
12
12
  }
13
13
 
14
14
  // A `type` alias, not an `interface`, on purpose: redux's UnknownAction has a string index signature,
15
- // and only an object-literal type alias (never an interface) is implicitly assignable to one — with an
15
+ // and only an object-literal type alias (never an interface) is implicitly assignable to one - with an
16
16
  // interface here `configureStore` rejects the reducer at the consuming app's own typecheck.
17
17
  export type GuideAction = {
18
18
  type: string
@@ -24,7 +24,7 @@ export type GuideAction = {
24
24
  export interface GuideSliceOptions {
25
25
  // What a player who has already seen (or been grandfathered past) the guide is stamped with.
26
26
  currentVersion: number
27
- // The key the reducer is mounted under in the app's own combineReducers({...}) — redux-persist's
27
+ // The key the reducer is mounted under in the app's own combineReducers({...}) - redux-persist's
28
28
  // REHYDRATE payload is keyed by it, and a reducer has no way to see its own mount key.
29
29
  mountKey?: string
30
30
  // The persistConfig `key` of the store this reducer lives in. A REHYDRATE for any OTHER persisted
@@ -40,7 +40,7 @@ export interface GuideSlice {
40
40
  }
41
41
 
42
42
  // The persisted "has this player seen the how-to-play flow" flag, as its own slice rather than a
43
- // field on any app's settings blob — every fleet app already has a redux-persist root store behind a
43
+ // field on any app's settings blob - every fleet app already has a redux-persist root store behind a
44
44
  // PersistGate, and a dedicated key survives 'Reset stats'/'Reset match settings' (neither touches
45
45
  // Redux) without needing any of the four strict per-app settings validators to learn a new field.
46
46
  //
@@ -49,7 +49,7 @@ export interface GuideSlice {
49
49
  // fresh install redux-persist's getStoredState finds nothing under the persistConfig key and
50
50
  // REHYDRATE arrives with `payload === undefined`; an existing player has a payload object that
51
51
  // simply predates this slice and so has no `[mountKey]` entry. The first shows the guide; the
52
- // second is grandfathered — stamped as already having seen `currentVersion` — so nobody who has been
52
+ // second is grandfathered - stamped as already having seen `currentVersion` - so nobody who has been
53
53
  // playing gets a "how to play" screen after an over-the-air update. Both can still replay it from
54
54
  // Settings.
55
55
  //
@@ -68,7 +68,7 @@ export function createGuideSlice({ currentVersion, mountKey = 'guide', persistKe
68
68
  if (action.type === REHYDRATE) {
69
69
  if (action.key !== undefined && action.key !== persistKey) return state
70
70
  const payload = action.payload as Record<string, Partial<GuideState> | undefined> | undefined
71
- // Nothing persisted at all (fresh install) — or the read failed, which shows the guide once
71
+ // Nothing persisted at all (fresh install) - or the read failed, which shows the guide once
72
72
  // more rather than silently skipping it.
73
73
  if (!payload) return state
74
74
  const persisted = payload[mountKey]
@@ -1,9 +1,10 @@
1
- // The how-to-play entry point (`@tastic/hud/guide`) — a standalone build reachable without ever
1
+ // The how-to-play entry point (`@tastic/hud/guide`) - a standalone build reachable without ever
2
2
  // touching the main barrel, the same reason `@tastic/hud/skia-gate` is. This file and everything it
3
3
  // imports must stay free of @tastic/core, @tastic/profile, @tastic/split-screen, @tastic/achievements,
4
4
  // @rific/updater and Skia: those are optional peers of this package (see package.json's
5
- // peerDependenciesMeta) precisely so an app like Hangman, which has none of them, can install
6
- // @tastic/hud and use only this. src/__tests__/guideImports.test.ts enforces it.
5
+ // peerDependenciesMeta) precisely so an app with none of them can install @tastic/hud and use only
6
+ // this. src/__tests__/guideImports.test.ts enforces it. (src/chrome/index.ts is the other light entry,
7
+ // for an app that has @tastic/core and @rific/updater but not the rest; Hangman is that app.)
7
8
  export { type DirectionKeyLabels, type DirectionKeysAxis, DirectionKeysHint, type DirectionKeysHintProps } from './DirectionKeysHint'
8
9
  export { GuideProvider, type GuideProviderProps, useAutoShowGuide, useGuide } from './GuideProvider'
9
10
  export { createGuideSlice, createReturningPlayerMigrate, type GuideAction, type GuidePersistedState, type GuideSlice, type GuideSliceOptions, type GuideState, type GuideStorageReader } from './guideSlice'
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
2
2
 
3
3
  // One card of a game's how-to-play flow. Content is app-local data, not something this package
4
4
  // authors: only the app knows its own controls, its own key schemes on desktop web, its own win
5
- // condition. `art` is whatever illustrates the idea — a `SwipeHint`/`SeatDiagram` from this same
5
+ // condition. `art` is whatever illustrates the idea - a `SwipeHint`/`SeatDiagram` from this same
6
6
  // entry point, or anything the app draws itself (plain Views + Reanimated keeps it renderer-free).
7
7
  export interface GuideStep {
8
8
  title: string
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ export { StatSection } from './StatSection'
28
28
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
29
29
  // though a native-only consumer could safely skip TriggerGaugeHost's SkiaGate indirection. A
30
30
  // value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import into a chunk
31
- // that this entry point imports eagerly at the top level — since that chunk is then shared with
31
+ // that this entry point imports eagerly at the top level - since that chunk is then shared with
32
32
  // TriggerGaugeHost's own dynamic import too, it defeats TriggerGaugeHost's deferred loading for
33
33
  // every consumer, on every platform, not just the ones reaching for the raw component.
34
34
  // TriggerGaugeHost is the only supported way to render this.