@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
package/README.md CHANGED
@@ -1,61 +1,67 @@
1
1
  # @tastic/hud
2
2
 
3
- Visual component kit for local-multiplayer React Native games — inline (non-modal) popovers,
3
+ Visual component kit for local-multiplayer React Native games - inline (non-modal) popovers,
4
4
  dropdowns, color pickers, gauges, ready buttons, and dialogs. Every interactive piece stays scoped
5
5
  to one player's own zone on a shared screen, so it never blocks the rest of the screen the way a
6
6
  full-screen modal would.
7
7
 
8
8
  Depends on [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core) for live device-
9
- rotation tracking (see Rotation below) — every popover/dialog here reads it directly, so it stays
9
+ rotation tracking (see Rotation below) - every popover/dialog here reads it directly, so it stays
10
10
  legible even rotated 180° inside a [`@tastic/split-screen`](https://github.com/jayrdeaton/react-native-split-screen)
11
11
  zone (that package's own two-player layout engine is what actually owns the rotated zone; this kit
12
12
  and split-screen don't depend on each other, they just both sit on `@tastic/core`).
13
13
 
14
14
  ## Why not a modal?
15
15
 
16
- A full-screen `Dialog` blocks the whole screen. In a two-player lobby that's a non-starter — player
16
+ A full-screen `Dialog` blocks the whole screen. In a two-player lobby that's a non-starter - player
17
17
  2 can't touch anything while player 1's color picker is open. Every popover here renders **inline**,
18
18
  as an absolutely-positioned sibling of its own trigger, so it never blocks anything outside its own
19
19
  corner of the screen.
20
20
 
21
21
  ## What's in here
22
22
 
23
- - **`usePopoverHost()`** — tracks which *one* popover (by id) is open within a group, so opening one
23
+ - **`usePopoverHost()`**: tracks which *one* popover (by id) is open within a group, so opening one
24
24
  closes any sibling already open in the same group.
25
- - **`PopoverBody`** — the low-level popover shell (position, caret, alignment). `SectionedDropdown`
25
+ - **`PopoverBody`**: the low-level popover shell (position, caret, alignment). `SectionedDropdown`
26
26
  and `InlineColorPicker` are built on it.
27
- - **`useAutoAlign`** — measures a trigger's actual on-screen position and picks whichever alignment
28
- (left/right/center, above/below) keeps a popover from overflowing a screen edge — including a
27
+ - **`useAutoAlign`**: measures a trigger's actual on-screen position and picks whichever alignment
28
+ (left/right/center, above/below) keeps a popover from overflowing a screen edge - including a
29
29
  `maxHeight` for the caller to cap content and scroll when neither direction has enough room.
30
- - **`SectionedDropdown`** — a popover holding any mix of single-select ("pick one", radio-style) and
30
+ - **`SectionedDropdown`**: a popover holding any mix of single-select ("pick one", radio-style) and
31
31
  multi-select ("pick some", checkbox-style, optional all/clear footer) sections, divided by rules.
32
- - **`InlineColorPicker`** — swatch-grid color popover, columns auto-clamped to 4 or 5 (the default
32
+ - **`InlineColorPicker`**: swatch-grid color popover, columns auto-clamped to 4 or 5 (the default
33
33
  `swatches` list is exactly 20 colors, so that's the only range that fills every row completely),
34
34
  with an optional "taken" color (shown disabled, or swappable). The trigger itself is a `size`-
35
35
  configurable circle showing either an `icon` or a short `tag` (e.g. a saved player identity's own
36
- initial or emoji) — `tag` renders at a larger ratio automatically when it's an emoji, since an
36
+ initial or emoji) - `tag` renders at a larger ratio automatically when it's an emoji, since an
37
37
  emoji glyph reads visually smaller than a bold letter at the same font size.
38
- - **`TriggerGauge`** — decorative ring of tick marks around a trigger, showing which option(s) are
38
+ - **`TriggerGauge`**: decorative ring of tick marks around a trigger, showing which option(s) are
39
39
  active without opening the popover.
40
- - **`ReadyButton`** — a per-player or standalone ready toggle.
41
- - **`PlayerSetupPanel`** — one panel per player slot in a local-multiplayer loadout/lobby screen: a
40
+ - **`ReadyButton`**: a per-player or standalone ready toggle.
41
+ - **`PlayerSetupPanel`**: one panel per player slot in a local-multiplayer loadout/lobby screen: a
42
42
  name header (a saved-profile switcher for a human seat, a CPU-difficulty `LabeledDropdown` for a
43
43
  non-human one, or a plain label when neither applies), a color picker (`InlineColorPicker`)
44
44
  optionally paired with a caller-rendered `secondPicker` slot, and a `ReadyButton` for human seats.
45
45
  Ported from five apps' own near-identical `PlayerSetupPanel.tsx`/`LobbyPlayerPanel.tsx`
46
46
  (AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
47
47
  exact skeleton. See "The secondPicker slot" below before wiring up that one prop.
48
- - **`CornerActionButtons`** — Back (top-left) + Settings (top-right) icon buttons fixed at a
49
- screen's rotated corners. This isn't just a loadout/lobby-screen convention — it's the right
48
+ - **`CornerActionButtons`**: Back (top-left) + Settings (top-right) icon buttons fixed at a
49
+ screen's rotated corners. This isn't just a loadout/lobby-screen convention - it's the right
50
50
  default for *any* screen with top-corner icon buttons, home screens included: every app in the
51
51
  fleet still hand-rolls its own trophy/cog `IconButton` pair on the home screen specifically
52
52
  instead of reusing this component there, a real (if minor) missed-reuse case worth fixing next
53
53
  time one of those screens gets touched. Not the right fit for a screen where the top corners fall
54
- *inside* one player's own rotated zone (two-player face-to-face) — see `SharedActionBand` for
55
- that case instead.
56
- - **`StaggeredWord`** / **`getStaggeredWordDuration`** — the letter-by-letter stagger-in animation
54
+ *inside* one player's own rotated zone (two-player face-to-face) - see `SharedActionBand` for
55
+ that case instead. Each button's touch target is its own 40pt box. Pass the optional `hitSlop`
56
+ (a number or `Insets`, forwarded to both buttons) to grow it without changing anything visible:
57
+ `hitSlop={4}` makes a 48pt target, and the glyphs and press ripple stay as they are. It needs
58
+ `@rific/feedback-press` 0.13.0 or later, whose `IconButton` makes `hitSlop` reach past Paper's
59
+ clipped box (older versions silently ignore it). Omit it to keep the 40pt target. Leave room for
60
+ it: the slop reaches past each button's box, and where it overlaps another touch target,
61
+ whichever is drawn on top gets the press.
62
+ - **`StaggeredWord`** / **`getStaggeredWordDuration`** - the letter-by-letter stagger-in animation
57
63
  every app's own hero-title screen had hand-rolled independently, identical down to the same 45ms
58
- stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time — a
64
+ stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time - a
59
65
  caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
60
66
  more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
61
67
  continuous cascade instead of each word restarting its own stagger from zero.
@@ -63,9 +69,9 @@ corner of the screen.
63
69
  plain function rather than something read off a ref or hook, so a caller's own follow-on
64
70
  animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
65
71
  when the lettering has mostly landed.
66
- - **`PressAwayOverlay`** — an invisible full-bleed tap-catcher for press-away-to-close. This is the
67
- one piece that needs care in a split-screen layout — see below.
68
- - **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** — shared shells for the
72
+ - **`PressAwayOverlay`**: an invisible full-bleed tap-catcher for press-away-to-close. This is the
73
+ one piece that needs care in a split-screen layout - see below.
74
+ - **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** - shared shells for the
69
75
  settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored
70
76
  out after being independently copied into several games. See Rotation below for how each of these
71
77
  (plus `SectionedDropdown`/`InlineColorPicker`) handles rotating inside a `@tastic/split-screen`
@@ -75,21 +81,21 @@ corner of the screen.
75
81
 
76
82
  `SectionedDropdown`, `InlineColorPicker`, `BaseSettingsDialog`, and `BaseStatsScreen` all read the
77
83
  device's live rotation themselves, via [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core)'s
78
- `useRotation()` — you don't need to pass anything for a popover/dialog to read correctly no matter
84
+ `useRotation()` - you don't need to pass anything for a popover/dialog to read correctly no matter
79
85
  which way the phone is held, as long as your app mounts `@tastic/core`'s `OrientationProvider`
80
86
  (or `@tastic/split-screen`'s `AccelerometerOrientationProvider`, a compat alias for the same thing)
81
87
  somewhere above it.
82
88
 
83
89
  Each of the four also still accepts an explicit `rotation` prop, which overrides the ambient read
84
- when present — reach for it only when a specific instance genuinely needs to differ from the live
90
+ when present - reach for it only when a specific instance genuinely needs to differ from the live
85
91
  app-wide rotation (e.g. a fading, lagged `panelLayout` value mid-transition, rather than the raw
86
- live reading — see `@tastic/split-screen`'s own `DualZoneLayout` docs for why that lag exists).
92
+ live reading - see `@tastic/split-screen`'s own `DualZoneLayout` docs for why that lag exists).
87
93
 
88
94
  This matters more than it might look: `useAutoAlign` (what `SectionedDropdown`/`InlineColorPicker`
89
95
  use to pick left/right/above/below) measures a trigger via `measureInWindow`, which reports the
90
- trigger's **pre-transform** layout position — confirmed against a real rotated screen, not just
96
+ trigger's **pre-transform** layout position - confirmed against a real rotated screen, not just
91
97
  reasoned about. Inside a `FakeLandscapeView`-rotated zone (or any other ancestor rotated the same
92
- way — a bare CSS `transform: rotate()`, not a real OS-level orientation change), that pre-transform
98
+ way - a bare CSS `transform: rotate()`, not a real OS-level orientation change), that pre-transform
93
99
  position can be nowhere near where the trigger actually is on screen, which is exactly the bug this
94
100
  rotation-awareness fixes: without it, a popover can pick an alignment for where its trigger *used to
95
101
  be* before rotating, clipping off the edge it's actually now near instead of the one it was near.
@@ -135,7 +141,7 @@ board inside it.
135
141
 
136
142
  A single full-screen `PressAwayOverlay` works fine for a one-player screen. It does **not** work
137
143
  for a two-player split screen: if player 1's press-away covers the whole screen, then any tap on
138
- player 2's side — even one that has nothing to do with player 1 — falls through and closes player
144
+ player 2's side - even one that has nothing to do with player 1 - falls through and closes player
139
145
  1's popover. That defeats the point of letting both players drive their own settings at once.
140
146
 
141
147
  The fix is two overlays with an asymmetric relationship, not two independent halves:
@@ -147,18 +153,18 @@ const p2Host = usePopoverHost()
147
153
  return (
148
154
  <View style={styles.container}>
149
155
  {/* Player 1's overlay covers the ENTIRE screen. This is correct even though player 1 visually
150
- only owns "their side" — any shared-settings row that reads as "player 1's" (see your own
156
+ only owns "their side" - any shared-settings row that reads as "player 1's" (see your own
151
157
  layout) may not stay confined to a literal half of the screen, and this overlay needs to cover
152
158
  everywhere player 1 might have something open. */}
153
159
  <PressAwayOverlay active={p1Host.openId !== null} onPress={p1Host.close} />
154
160
 
155
161
  {/* Player 2's overlay is scoped to just their own zone (half the screen, whichever side
156
- they're on — see @tastic/split-screen's panelLayout for how to size this) — and, being a later
162
+ they're on - see @tastic/split-screen's panelLayout for how to size this) - and, being a later
157
163
  sibling, it paints on top of player 1's overlay within that rect, so a tap there is always
158
164
  player 2's to own.
159
165
 
160
166
  Critically: this has to mount whenever EITHER host is open, not just p2Host. If it only mounted
161
- for p2Host, then closing player 2's popover would unmount it — exposing player 1's full-screen
167
+ for p2Host, then closing player 2's popover would unmount it - exposing player 1's full-screen
162
168
  overlay underneath for the rest of that render, and the next tap anywhere on player 2's side
163
169
  (even if player 2 has nothing open) would fall through and close player 1's popover instead.
164
170
  Mounting it any time p1Host is open too "shields" player 2's zone from player 1's overlay
@@ -166,7 +172,7 @@ return (
166
172
  closed. */}
167
173
  <PressAwayOverlay active={p1Host.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={styles.p2Zone} />
168
174
 
169
- {/* Real content goes after both overlays — plain paint order (later siblings on top) is what
175
+ {/* Real content goes after both overlays - plain paint order (later siblings on top) is what
170
176
  keeps every real trigger/button directly tappable; only genuinely empty space falls through to
171
177
  the overlays above. */}
172
178
  <Player1Panel host={p1Host} />
@@ -178,21 +184,21 @@ return (
178
184
  The general rule for N players: the "owner" of the broadest zone (usually whoever's panel absorbs
179
185
  your shared/global settings) gets the whole-screen overlay; every other player's zone-scoped overlay
180
186
  must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
181
- reach is open — not just their own.
187
+ reach is open - not just their own.
182
188
 
183
189
  ## Integrating BaseSettingsDialog
184
190
 
185
- `BaseSettingsDialog` is a shared shell, not a drop-in screen — every one of the five consuming apps
191
+ `BaseSettingsDialog` is a shared shell, not a drop-in screen - every one of the five consuming apps
186
192
  (AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
187
193
  `src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
188
194
  adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's
189
- fixed prop contract — it should carry no UI of its own beyond that plumbing.
195
+ fixed prop contract - it should carry no UI of its own beyond that plumbing.
190
196
 
191
197
  That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
192
198
  `settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
193
199
  own three call sites already have one in scope for other reasons). Pong reads that same shape off
194
- its own `useGameSettings()` hook internally instead of taking it as props. Snake — the one app in
195
- the fleet with real Redux Toolkit state — reads `lockOrientation`/`deferBottomEdgeGestures` via
200
+ its own `useGameSettings()` hook internally instead of taking it as props. Snake - the one app in
201
+ the fleet with real Redux Toolkit state - reads `lockOrientation`/`deferBottomEdgeGestures` via
196
202
  `useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
197
203
  inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
198
204
  which of these an app uses: sound, haptics, appearance, and update-checking are identical across
@@ -202,7 +208,7 @@ actually needs to source itself, however it does that.
202
208
 
203
209
  Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
204
210
  knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
205
- one up — an adapter that leaves `onUpdateError` unset silently swallows a failed update check
211
+ one up - an adapter that leaves `onUpdateError` unset silently swallows a failed update check
206
212
  instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
207
213
  being caught and fixed. If your app has no toast system, route it to whatever your own
208
214
  error-surfacing convention is; just don't leave it as a no-op.
@@ -231,30 +237,30 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, set
231
237
 
232
238
  ## Zone-aware popovers (`useZoneClampedAlign`)
233
239
 
234
- `useAutoAlign` — and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
235
- `LabeledDropdown` — only knows about the full device window edge. It has no concept of a
240
+ `useAutoAlign` - and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
241
+ `LabeledDropdown` - only knows about the full device window edge. It has no concept of a
236
242
  `@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
237
243
  line where one player's zone stops and the other's begins. A popover host that lives entirely
238
244
  inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
239
245
  choosing an alignment that opens toward the shared band, or overflows past the zone boundary into
240
- the other player's half — not because `useAutoAlign` measured wrong, but because the window edge it
246
+ the other player's half - not because `useAutoAlign` measured wrong, but because the window edge it
241
247
  correctly measured against isn't the boundary that actually matters there.
242
248
 
243
- LightCycles hit this for real — its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
244
- inside a rotated split-screen zone — and originally built its own local fix for it rather than
249
+ LightCycles hit this for real - its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
250
+ inside a rotated split-screen zone - and originally built its own local fix for it rather than
245
251
  waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
246
252
  first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
247
253
  decision (and `maxHeight`) against the zone's real edge, via `@tastic/split-screen`'s
248
- `useZoneBounds()`, instead of the window edge — including correcting for a 180°-rotated zone
254
+ `useZoneBounds()`, instead of the window edge - including correcting for a 180°-rotated zone
249
255
  inverting `PopoverBody`'s local 'above'/'below' labels relative to their real-world direction.
250
- `@tastic/split-screen` is a peer dependency of this package because of this hook specifically — it's
256
+ `@tastic/split-screen` is a peer dependency of this package because of this hook specifically - it's
251
257
  only ever imported if you actually call `useZoneClampedAlign`.
252
258
 
253
259
  Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
254
- `align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out — exported as `AlignResult`, see
260
+ `align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out - exported as `AlignResult`, see
255
261
  below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
256
262
  `@tastic/split-screen` zone (`useZoneBounds()` returns `null`), it's a pure passthrough to
257
- `useAutoAlign`'s own decision — safe to reach for even in a component that might or might not end up
263
+ `useAutoAlign`'s own decision - safe to reach for even in a component that might or might not end up
258
264
  inside one:
259
265
 
260
266
  ```tsx
@@ -272,10 +278,10 @@ their own internal, zone-blind `useAutoAlign` call:
272
278
  `ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
273
279
  `SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
274
280
  own internal color/CPU-difficulty pickers aren't reachable this way (they're not a `ReactNode` slot
275
- your own code renders — see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
281
+ your own code renders - see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
276
282
  `cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
277
283
  `LabeledDropdown`'s own `alignOverride` internally. Computing `open` for either one needs that
278
- picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` — call
284
+ picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` - call
279
285
  `getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
280
286
  guessing or hardcoding the naming convention yourself:
281
287
 
@@ -292,7 +298,7 @@ For any other popover-hosting component that might sit inside a split-screen zon
292
298
  ## The secondPicker slot (read this before wiring one up)
293
299
 
294
300
  `PlayerSetupPanel`'s `secondPicker` is a caller-rendered `ReactNode` slot next to the built-in color
295
- picker, not a fixed prop shape — the five apps this component was extracted from each wanted a
301
+ picker, not a fixed prop shape - the five apps this component was extracted from each wanted a
296
302
  genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
297
303
  nothing at all), so rather than this package baking in one shape most consumers don't need, the
298
304
  panel just renders whatever `secondPicker` is given, wherever it's given.
@@ -316,11 +322,11 @@ If whatever you slot in opens its own popover, pass its popover id as `secondPic
316
322
  Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
317
323
  fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
318
324
  `pickerRow` needs the elevated z-index that lets a popover escape the row's bounds and paint above a
319
- later sibling (the `ReadyButton` beneath it, or another panel next to it — see `pickerRowOpen` in
325
+ later sibling (the `ReadyButton` beneath it, or another panel next to it - see `pickerRowOpen` in
320
326
  the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
321
327
  popover is open, so that elevation never kicks in, and your popover can silently render underneath
322
328
  sibling content instead of on top of it. If your `secondPicker` never opens a popover of its own (a
323
- plain toggle, static content, or nothing at all), omit `secondPickerId` — there's nothing for it to
329
+ plain toggle, static content, or nothing at all), omit `secondPickerId` - there's nothing for it to
324
330
  track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
325
331
  popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
326
332
 
@@ -342,26 +348,26 @@ interface QuitConfirmation {
342
348
 
343
349
  Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
344
350
  `requestBack` to the back button's `onPress`. The hook deliberately doesn't own or render the
345
- `<ConfirmDialog>` itself — title/message/icon/labels/rotation stay authored at each call site (a
351
+ `<ConfirmDialog>` itself - title/message/icon/labels/rotation stay authored at each call site (a
346
352
  score pairing in most apps, a round-history pip row in LightCycles), matching this package's
347
353
  established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
348
354
  `useAutoAlign`, `useZoneClampedAlign` all do the same).
349
355
 
350
- **`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` — this is a load-bearing
356
+ **`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` - this is a load-bearing
351
357
  correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
352
358
  render from state that, in several apps, doesn't exist yet at that point in the render.
353
359
  AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
354
- `paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` — and
360
+ `paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` - and
355
361
  `useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
356
362
  read. A plain boolean here would create a real circular render dependency:
357
363
  `confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
358
364
  confirmVisible`. The lazy getter breaks the cycle: `hasProgress` is only invoked inside
359
- `requestBack`, at tap time, well after `useGameState` has already produced that render's state —
365
+ `requestBack`, at tap time, well after `useGameState` has already produced that render's state -
360
366
  the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
361
367
  `onBackPress` closure always did before this hook existed:
362
368
 
363
369
  ```tsx
364
- // AirHockey's game.tsx — hasProgress reads `scores`, a value useGameState (below) hasn't produced
370
+ // AirHockey's game.tsx - hasProgress reads `scores`, a value useGameState (below) hasn't produced
365
371
  // yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
366
372
  // inside requestBack, well after this render has finished and `scores` is a real value.
367
373
  const [settingsOpen, setSettingsOpen] = useState(false)
@@ -378,7 +384,7 @@ const { scores } = state
378
384
  <ConfirmDialog
379
385
  visible={confirmVisible}
380
386
  title='Quit Match?'
381
- message={`${scores[0]} – ${scores[1]}`}
387
+ message={`${scores[0]} - ${scores[1]}`}
382
388
  confirmLabel='Quit'
383
389
  cancelLabel='Cancel'
384
390
  onConfirm={safeBack}
@@ -387,8 +393,8 @@ const { scores } = state
387
393
  ```
388
394
 
389
395
  A game with no such circular dependency (Snake reads `humanScore`/`opponentScore`, LightCycles
390
- reads `roundHistory.length`) still passes a getter — `() => humanScore > 0 || (opponentScore ?? 0) > 0`
391
- — it's just a plain wrapper around an expression that could have been a bare boolean there; the
396
+ reads `roundHistory.length`) still passes a getter - `() => humanScore > 0 || (opponentScore ?? 0) > 0`;
397
+ it's just a plain wrapper around an expression that could have been a bare boolean there; the
392
398
  signature stays uniform across every call site rather than special-casing the apps that don't
393
399
  currently need the deferred read.
394
400
 
@@ -396,13 +402,13 @@ currently need the deferred read.
396
402
 
397
403
  Two presentational components for an achievements/stats screen, designed to pair with
398
404
  [`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
399
- `getAchievementCatalogRows` — the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
405
+ `getAchievementCatalogRows` - the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
400
406
  `unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
401
407
  its own); the two components that actually render the result live here. Mirrors this package's
402
408
  existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
403
409
  (pure data) boundary.
404
410
 
405
- **`AchievementCatalogSection`** — an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
411
+ **`AchievementCatalogSection`** - an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
406
412
  row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
407
413
  siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
408
414
  here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
@@ -418,9 +424,9 @@ interface AchievementCatalogSectionProps {
418
424
  }
419
425
  ```
420
426
 
421
- **`ActivityStatSection`** — the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
427
+ **`ActivityStatSection`** - the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
422
428
  Streak). Takes a plain structural `ActivityStats` shape rather than importing
423
- `@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly — this
429
+ `@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly - this
424
430
  component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
425
431
  stats object with these three fields satisfies it:
426
432
 
@@ -448,7 +454,7 @@ return (
448
454
 
449
455
  `AchievementCatalogSection` and `AchievementUnlockList` (below) are the only places in this package
450
456
  with a `@tastic/achievements` dependency, and both are type-only (`AchievementCatalogRow`,
451
- `AchievementTier`) — never a value/runtime import. See Peer Dependencies below.
457
+ `AchievementTier`) - never a value/runtime import. See Peer Dependencies below.
452
458
 
453
459
  ## Achievement unlocks on a result card (`AchievementUnlockList`)
454
460
 
@@ -479,7 +485,7 @@ pick it up. A guest seat's avatar draws the `color` you pass as-is, so pass it a
479
485
  A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on
480
486
  demand from Settings. Import it from the **`@tastic/hud/guide` subpath**, not the main barrel: it's a
481
487
  standalone build that pulls in only `react`, `react-native`, `react-native-paper`,
482
- `react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` — none of Skia,
488
+ `react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` - none of Skia,
483
489
  `@tastic/core`, `@tastic/profile`, `@tastic/split-screen`, `@tastic/achievements` or `@rific/updater`
484
490
  (all marked optional in `peerDependenciesMeta`), so an app that has none of them can use it. A test
485
491
  (`src/__tests__/guideImports.test.ts`) fails if any file under `src/guide/` ever imports one.
@@ -487,7 +493,7 @@ standalone build that pulls in only `react`, `react-native`, `react-native-paper
487
493
  ```tsx
488
494
  import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'
489
495
 
490
- // 1. A persisted "seen" version — its own slice, mounted under `guide` in your root reducer.
496
+ // 1. A persisted "seen" version - its own slice, mounted under `guide` in your root reducer.
491
497
  const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }
492
498
 
493
499
  // 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
@@ -505,7 +511,7 @@ const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, red
505
511
  // 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
506
512
  useAutoShowGuide()
507
513
 
508
- // 4. Replay from Settings — BaseSettingsDialog renders a "How to Play" row when this is passed.
514
+ // 4. Replay from Settings - BaseSettingsDialog renders a "How to Play" row when this is passed.
509
515
  const { open } = useGuide()
510
516
  <BaseSettingsDialog {...props} onShowHowToPlay={open} />
511
517
  ```
@@ -514,7 +520,7 @@ const { open } = useGuide()
514
520
  an existing player has a payload that simply predates the `guide` key. `createGuideSlice` stamps the
515
521
  second with `currentVersion` instead of `0`, so an OTA update never greets them with a tutorial (they
516
522
  can still replay it). Only finishing or skipping writes; a replay never does. Bump `currentVersion`
517
- only for a real controls/rules change — everyone sees it again.
523
+ only for a real controls/rules change - everyone sees it again.
518
524
  - **If your last shipped build predates your redux-persist store, you also need the migrate.** Those
519
525
  players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the
520
526
  shipped build wrote (read them from the last shipped commit, not today's source):
@@ -529,7 +535,7 @@ const { open } = useGuide()
529
535
 
530
536
  No root store but any of those keys present means "played an earlier build": that launch rehydrates
531
537
  with an empty payload, which the slice grandfathers. An existing root store passes through untouched.
532
- - **Home only.** Call `useAutoShowGuide()` from the Home screen — that placement is what keeps it from
538
+ - **Home only.** Call `useAutoShowGuide()` from the Home screen - that placement is what keeps it from
533
539
  firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up
534
540
  by both players. Pass `false` to hold it back (e.g. while a deep link is being handled).
535
541
  - **Leave the row off during a live match.** Omit `onShowHowToPlay` on any screen where Settings pauses or
@@ -549,6 +555,36 @@ const { open } = useGuide()
549
555
  and only say what's true in every mode ("your half" is only true in two-player).
550
556
  - **Not `onboarding`.** LightCycles and Snake already use that word for their 3-2-1-GO countdown.
551
557
 
558
+ ## Fleet chrome without the heavy peers (`@tastic/hud/chrome`)
559
+
560
+ The shared dialogs, stats screen pieces, corner buttons and style tokens, from a standalone entry that never
561
+ touches the main barrel. It's for an app that has `@tastic/core` and `@rific/updater` but none of Skia,
562
+ `@tastic/profile` or `@tastic/split-screen` (Hangman). It exports, with their props types:
563
+
564
+ - Dialogs: `BaseSettingsDialog`, `ConfirmDialog`, `UpdateDialog`, `RotatedFrame`
565
+ - Stats: `BaseStatsScreen`, `StatSection`, `StatRow`, `AchievementRow`, `LOCKED_BADGE_COLOR`,
566
+ `AchievementCatalogSection`, `ActivityStatSection`
567
+ - Chrome: `CornerActionButtons`, `ContentGutter`, `StaggeredWord`, `getStaggeredWordDuration`
568
+ - Style tokens: `getOverlayCardColors`, `overlayStyles`, `overlayActionStyles`, `MONO_FONT`
569
+
570
+ Each is the same component the main entry exports and is documented above. Only the import path changes:
571
+
572
+ ```tsx
573
+ import { BaseSettingsDialog, ConfirmDialog, CornerActionButtons } from '@tastic/hud/chrome'
574
+ ```
575
+
576
+ It pulls in only `react`, `react-native`, `react-native-paper`, `react-native-reanimated`,
577
+ `react-native-safe-area-context`, `@rific/auto-paper`, `@rific/feedback-press`, `@rific/updater` and
578
+ `@tastic/core`. `AchievementCatalogSection` uses `@tastic/achievements` for types only, so nothing loads it at
579
+ runtime, but an app whose TypeScript resolves the `react-native` export condition (the fleet's tsconfig does)
580
+ type-checks hud's source and needs it installed. `AchievementUnlockList` isn't here because it renders
581
+ `@tastic/profile`'s `ProfileChip`. A test (`src/__tests__/chromeImports.test.ts`) walks the import graph from
582
+ `src/chrome/index.ts` and fails if anything it reaches imports another package, value-imports
583
+ `@tastic/achievements`, reaches the main barrel, or has a `.web` twin.
584
+
585
+ Metro resolves both entries to the same source files, so an app that imports some components from here and
586
+ others from `@tastic/hud` still bundles each module once.
587
+
552
588
  ## Install
553
589
 
554
590
  Published to the public npm registry as `@tastic/hud`. Everything described above, including the
@@ -571,8 +607,9 @@ when no rotation applies.
571
607
  ## Peer dependencies
572
608
 
573
609
  None of these are bundled, so use whatever versions your app already has. Six are marked `optional` in
574
- `peerDependenciesMeta` only so that an app using just the `@tastic/hud/guide` subpath (Hangman) can
575
- install hud without them. **npm does not install optional peers for you**, so an app that imports the
610
+ `peerDependenciesMeta` so a light app can install hud without the ones it doesn't use: `./guide` needs
611
+ none of the six, and `./chrome` needs only `@tastic/core` and `@rific/updater` (plus
612
+ `@tastic/achievements` to type-check, see above). **npm does not install optional peers for you**, so an app that imports the
576
613
  main `@tastic/hud` entry must list every peer that entry loads in its own `dependencies`, including
577
614
  the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to
578
615
  resolve module".
@@ -580,14 +617,14 @@ resolve module".
580
617
  | Peer | Floor | Needed by |
581
618
  | --- | --- | --- |
582
619
  | `react`, `react-native` | >=19.0.0, >=0.76.0 | every entry |
583
- | `react-native-paper` | >=5.0.0 | main entry, `./guide` |
584
- | `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide` |
585
- | `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide` |
586
- | `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide` |
587
- | `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`) |
588
- | `@tastic/core` | >=0.12.0, optional | main entry: `useRotation()` and most dialogs; `RotatedFrame` and `BaseSettingsDialog` need `FakeLandscapeView`'s `rotation`/`passThrough`, first shipped in 0.12.0 |
589
- | `@rific/updater` | >=0.4.0, optional | main entry: `BaseSettingsDialog`, `UpdateDialog` |
620
+ | `react-native-paper` | >=5.0.0 | main entry, `./guide`, `./chrome` |
621
+ | `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide`, `./chrome` (`StaggeredWord`) |
622
+ | `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide`, `./chrome` |
623
+ | `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide`, `./chrome` |
624
+ | `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`), `./chrome` (`RotatedFrame`, `BaseSettingsDialog`) |
625
+ | `@tastic/core` | >=0.12.0, optional | main entry: `useRotation()` and most dialogs; `RotatedFrame` and `BaseSettingsDialog` need `FakeLandscapeView`'s `rotation`/`passThrough`, first shipped in 0.12.0; `./chrome` (the same dialogs) |
626
+ | `@rific/updater` | >=0.4.0, optional | main entry and `./chrome`: `BaseSettingsDialog`, `UpdateDialog` |
590
627
  | `@tastic/profile` | >=0.4.0, optional | main entry: `PlayerSetupPanel`, `AchievementUnlockList` (`ProfileChip`) |
591
628
  | `@tastic/split-screen` | >=0.5.0, optional | main entry: `useZoneClampedAlign` |
592
629
  | `@shopify/react-native-skia` | >=1.5.0, optional | `TriggerGaugeHost`'s gauge (lazy-loaded behind `SkiaGate`) and `./skia-gate` |
593
- | `@tastic/achievements` | >=0.2.0, optional | `AchievementCatalogSection`, `AchievementUnlockList`, type-only (`AchievementCatalogRow`, first exported in 0.2.0; `AchievementTier`) |
630
+ | `@tastic/achievements` | >=0.2.0, optional | `AchievementCatalogSection`, `AchievementUnlockList`, type-only (`AchievementCatalogRow`, first exported in 0.2.0; `AchievementTier`); `./chrome` needs it installed only to type-check |