@tastic/hud 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +110 -79
  2. package/dist/chrome/index.d.mts +239 -0
  3. package/dist/chrome/index.d.ts +239 -0
  4. package/dist/chrome/index.js +772 -0
  5. package/dist/chrome/index.mjs +44 -0
  6. package/dist/chunk-OTMMST5M.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 +22 -22
  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 +36 -34
  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 +6 -6
  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,61 @@
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
54
+ *inside* one player's own rotated zone (two-player face-to-face) - see `SharedActionBand` for
55
55
  that case instead.
56
- - **`StaggeredWord`** / **`getStaggeredWordDuration`** — the letter-by-letter stagger-in animation
56
+ - **`StaggeredWord`** / **`getStaggeredWordDuration`** - the letter-by-letter stagger-in animation
57
57
  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
58
+ stagger, 320ms per-letter duration, and back-ease overshoot. Renders one word/line at a time - a
59
59
  caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
60
60
  more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
61
61
  continuous cascade instead of each word restarting its own stagger from zero.
@@ -63,9 +63,9 @@ corner of the screen.
63
63
  plain function rather than something read off a ref or hook, so a caller's own follow-on
64
64
  animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
65
65
  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
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
69
69
  settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored
70
70
  out after being independently copied into several games. See Rotation below for how each of these
71
71
  (plus `SectionedDropdown`/`InlineColorPicker`) handles rotating inside a `@tastic/split-screen`
@@ -75,21 +75,21 @@ corner of the screen.
75
75
 
76
76
  `SectionedDropdown`, `InlineColorPicker`, `BaseSettingsDialog`, and `BaseStatsScreen` all read the
77
77
  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
78
+ `useRotation()` - you don't need to pass anything for a popover/dialog to read correctly no matter
79
79
  which way the phone is held, as long as your app mounts `@tastic/core`'s `OrientationProvider`
80
80
  (or `@tastic/split-screen`'s `AccelerometerOrientationProvider`, a compat alias for the same thing)
81
81
  somewhere above it.
82
82
 
83
83
  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
84
+ when present - reach for it only when a specific instance genuinely needs to differ from the live
85
85
  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).
86
+ live reading - see `@tastic/split-screen`'s own `DualZoneLayout` docs for why that lag exists).
87
87
 
88
88
  This matters more than it might look: `useAutoAlign` (what `SectionedDropdown`/`InlineColorPicker`
89
89
  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
90
+ trigger's **pre-transform** layout position - confirmed against a real rotated screen, not just
91
91
  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
92
+ way - a bare CSS `transform: rotate()`, not a real OS-level orientation change), that pre-transform
93
93
  position can be nowhere near where the trigger actually is on screen, which is exactly the bug this
94
94
  rotation-awareness fixes: without it, a popover can pick an alignment for where its trigger *used to
95
95
  be* before rotating, clipping off the edge it's actually now near instead of the one it was near.
@@ -135,7 +135,7 @@ board inside it.
135
135
 
136
136
  A single full-screen `PressAwayOverlay` works fine for a one-player screen. It does **not** work
137
137
  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
138
+ player 2's side - even one that has nothing to do with player 1 - falls through and closes player
139
139
  1's popover. That defeats the point of letting both players drive their own settings at once.
140
140
 
141
141
  The fix is two overlays with an asymmetric relationship, not two independent halves:
@@ -147,18 +147,18 @@ const p2Host = usePopoverHost()
147
147
  return (
148
148
  <View style={styles.container}>
149
149
  {/* 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
150
+ only owns "their side" - any shared-settings row that reads as "player 1's" (see your own
151
151
  layout) may not stay confined to a literal half of the screen, and this overlay needs to cover
152
152
  everywhere player 1 might have something open. */}
153
153
  <PressAwayOverlay active={p1Host.openId !== null} onPress={p1Host.close} />
154
154
 
155
155
  {/* 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
156
+ they're on - see @tastic/split-screen's panelLayout for how to size this) - and, being a later
157
157
  sibling, it paints on top of player 1's overlay within that rect, so a tap there is always
158
158
  player 2's to own.
159
159
 
160
160
  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
161
+ for p2Host, then closing player 2's popover would unmount it - exposing player 1's full-screen
162
162
  overlay underneath for the rest of that render, and the next tap anywhere on player 2's side
163
163
  (even if player 2 has nothing open) would fall through and close player 1's popover instead.
164
164
  Mounting it any time p1Host is open too "shields" player 2's zone from player 1's overlay
@@ -166,7 +166,7 @@ return (
166
166
  closed. */}
167
167
  <PressAwayOverlay active={p1Host.openId !== null || p2Host.openId !== null} onPress={p2Host.close} style={styles.p2Zone} />
168
168
 
169
- {/* Real content goes after both overlays — plain paint order (later siblings on top) is what
169
+ {/* Real content goes after both overlays - plain paint order (later siblings on top) is what
170
170
  keeps every real trigger/button directly tappable; only genuinely empty space falls through to
171
171
  the overlays above. */}
172
172
  <Player1Panel host={p1Host} />
@@ -178,21 +178,21 @@ return (
178
178
  The general rule for N players: the "owner" of the broadest zone (usually whoever's panel absorbs
179
179
  your shared/global settings) gets the whole-screen overlay; every other player's zone-scoped overlay
180
180
  must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
181
- reach is open — not just their own.
181
+ reach is open - not just their own.
182
182
 
183
183
  ## Integrating BaseSettingsDialog
184
184
 
185
- `BaseSettingsDialog` is a shared shell, not a drop-in screen — every one of the five consuming apps
185
+ `BaseSettingsDialog` is a shared shell, not a drop-in screen - every one of the five consuming apps
186
186
  (AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
187
187
  `src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
188
188
  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.
189
+ fixed prop contract - it should carry no UI of its own beyond that plumbing.
190
190
 
191
191
  That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
192
192
  `settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
193
193
  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
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
196
196
  `useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
197
197
  inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
198
198
  which of these an app uses: sound, haptics, appearance, and update-checking are identical across
@@ -202,7 +202,7 @@ actually needs to source itself, however it does that.
202
202
 
203
203
  Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
204
204
  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
205
+ one up - an adapter that leaves `onUpdateError` unset silently swallows a failed update check
206
206
  instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
207
207
  being caught and fixed. If your app has no toast system, route it to whatever your own
208
208
  error-surfacing convention is; just don't leave it as a no-op.
@@ -231,30 +231,30 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, set
231
231
 
232
232
  ## Zone-aware popovers (`useZoneClampedAlign`)
233
233
 
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
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
236
236
  `@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
237
237
  line where one player's zone stops and the other's begins. A popover host that lives entirely
238
238
  inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
239
239
  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
240
+ the other player's half - not because `useAutoAlign` measured wrong, but because the window edge it
241
241
  correctly measured against isn't the boundary that actually matters there.
242
242
 
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
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
245
245
  waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
246
246
  first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
247
247
  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
248
+ `useZoneBounds()`, instead of the window edge - including correcting for a 180°-rotated zone
249
249
  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
250
+ `@tastic/split-screen` is a peer dependency of this package because of this hook specifically - it's
251
251
  only ever imported if you actually call `useZoneClampedAlign`.
252
252
 
253
253
  Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
254
- `align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out — exported as `AlignResult`, see
254
+ `align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out - exported as `AlignResult`, see
255
255
  below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
256
256
  `@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
257
+ `useAutoAlign`'s own decision - safe to reach for even in a component that might or might not end up
258
258
  inside one:
259
259
 
260
260
  ```tsx
@@ -272,10 +272,10 @@ their own internal, zone-blind `useAutoAlign` call:
272
272
  `ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
273
273
  `SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
274
274
  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
275
+ your own code renders - see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
276
276
  `cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
277
277
  `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
278
+ picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` - call
279
279
  `getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
280
280
  guessing or hardcoding the naming convention yourself:
281
281
 
@@ -292,7 +292,7 @@ For any other popover-hosting component that might sit inside a split-screen zon
292
292
  ## The secondPicker slot (read this before wiring one up)
293
293
 
294
294
  `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
295
+ picker, not a fixed prop shape - the five apps this component was extracted from each wanted a
296
296
  genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
297
297
  nothing at all), so rather than this package baking in one shape most consumers don't need, the
298
298
  panel just renders whatever `secondPicker` is given, wherever it's given.
@@ -316,11 +316,11 @@ If whatever you slot in opens its own popover, pass its popover id as `secondPic
316
316
  Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
317
317
  fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
318
318
  `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
319
+ later sibling (the `ReadyButton` beneath it, or another panel next to it - see `pickerRowOpen` in
320
320
  the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
321
321
  popover is open, so that elevation never kicks in, and your popover can silently render underneath
322
322
  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
323
+ plain toggle, static content, or nothing at all), omit `secondPickerId` - there's nothing for it to
324
324
  track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
325
325
  popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
326
326
 
@@ -342,26 +342,26 @@ interface QuitConfirmation {
342
342
 
343
343
  Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
344
344
  `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
345
+ `<ConfirmDialog>` itself - title/message/icon/labels/rotation stay authored at each call site (a
346
346
  score pairing in most apps, a round-history pip row in LightCycles), matching this package's
347
347
  established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
348
348
  `useAutoAlign`, `useZoneClampedAlign` all do the same).
349
349
 
350
- **`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` — this is a load-bearing
350
+ **`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` - this is a load-bearing
351
351
  correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
352
352
  render from state that, in several apps, doesn't exist yet at that point in the render.
353
353
  AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
354
- `paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` — and
354
+ `paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` - and
355
355
  `useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
356
356
  read. A plain boolean here would create a real circular render dependency:
357
357
  `confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
358
358
  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 —
359
+ `requestBack`, at tap time, well after `useGameState` has already produced that render's state -
360
360
  the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
361
361
  `onBackPress` closure always did before this hook existed:
362
362
 
363
363
  ```tsx
364
- // AirHockey's game.tsx — hasProgress reads `scores`, a value useGameState (below) hasn't produced
364
+ // AirHockey's game.tsx - hasProgress reads `scores`, a value useGameState (below) hasn't produced
365
365
  // yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
366
366
  // inside requestBack, well after this render has finished and `scores` is a real value.
367
367
  const [settingsOpen, setSettingsOpen] = useState(false)
@@ -378,7 +378,7 @@ const { scores } = state
378
378
  <ConfirmDialog
379
379
  visible={confirmVisible}
380
380
  title='Quit Match?'
381
- message={`${scores[0]} – ${scores[1]}`}
381
+ message={`${scores[0]} - ${scores[1]}`}
382
382
  confirmLabel='Quit'
383
383
  cancelLabel='Cancel'
384
384
  onConfirm={safeBack}
@@ -387,8 +387,8 @@ const { scores } = state
387
387
  ```
388
388
 
389
389
  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
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
392
392
  signature stays uniform across every call site rather than special-casing the apps that don't
393
393
  currently need the deferred read.
394
394
 
@@ -396,13 +396,13 @@ currently need the deferred read.
396
396
 
397
397
  Two presentational components for an achievements/stats screen, designed to pair with
398
398
  [`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
399
- `getAchievementCatalogRows` — the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
399
+ `getAchievementCatalogRows` - the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
400
400
  `unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
401
401
  its own); the two components that actually render the result live here. Mirrors this package's
402
402
  existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
403
403
  (pure data) boundary.
404
404
 
405
- **`AchievementCatalogSection`** — an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
405
+ **`AchievementCatalogSection`** - an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
406
406
  row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
407
407
  siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
408
408
  here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
@@ -418,9 +418,9 @@ interface AchievementCatalogSectionProps {
418
418
  }
419
419
  ```
420
420
 
421
- **`ActivityStatSection`** — the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
421
+ **`ActivityStatSection`** - the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
422
422
  Streak). Takes a plain structural `ActivityStats` shape rather than importing
423
- `@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly — this
423
+ `@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly - this
424
424
  component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
425
425
  stats object with these three fields satisfies it:
426
426
 
@@ -448,7 +448,7 @@ return (
448
448
 
449
449
  `AchievementCatalogSection` and `AchievementUnlockList` (below) are the only places in this package
450
450
  with a `@tastic/achievements` dependency, and both are type-only (`AchievementCatalogRow`,
451
- `AchievementTier`) — never a value/runtime import. See Peer Dependencies below.
451
+ `AchievementTier`) - never a value/runtime import. See Peer Dependencies below.
452
452
 
453
453
  ## Achievement unlocks on a result card (`AchievementUnlockList`)
454
454
 
@@ -479,7 +479,7 @@ pick it up. A guest seat's avatar draws the `color` you pass as-is, so pass it a
479
479
  A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on
480
480
  demand from Settings. Import it from the **`@tastic/hud/guide` subpath**, not the main barrel: it's a
481
481
  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,
482
+ `react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` - none of Skia,
483
483
  `@tastic/core`, `@tastic/profile`, `@tastic/split-screen`, `@tastic/achievements` or `@rific/updater`
484
484
  (all marked optional in `peerDependenciesMeta`), so an app that has none of them can use it. A test
485
485
  (`src/__tests__/guideImports.test.ts`) fails if any file under `src/guide/` ever imports one.
@@ -487,7 +487,7 @@ standalone build that pulls in only `react`, `react-native`, `react-native-paper
487
487
  ```tsx
488
488
  import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'
489
489
 
490
- // 1. A persisted "seen" version — its own slice, mounted under `guide` in your root reducer.
490
+ // 1. A persisted "seen" version - its own slice, mounted under `guide` in your root reducer.
491
491
  const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }
492
492
 
493
493
  // 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
@@ -505,7 +505,7 @@ const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, red
505
505
  // 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
506
506
  useAutoShowGuide()
507
507
 
508
- // 4. Replay from Settings — BaseSettingsDialog renders a "How to Play" row when this is passed.
508
+ // 4. Replay from Settings - BaseSettingsDialog renders a "How to Play" row when this is passed.
509
509
  const { open } = useGuide()
510
510
  <BaseSettingsDialog {...props} onShowHowToPlay={open} />
511
511
  ```
@@ -514,7 +514,7 @@ const { open } = useGuide()
514
514
  an existing player has a payload that simply predates the `guide` key. `createGuideSlice` stamps the
515
515
  second with `currentVersion` instead of `0`, so an OTA update never greets them with a tutorial (they
516
516
  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.
517
+ only for a real controls/rules change - everyone sees it again.
518
518
  - **If your last shipped build predates your redux-persist store, you also need the migrate.** Those
519
519
  players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the
520
520
  shipped build wrote (read them from the last shipped commit, not today's source):
@@ -529,7 +529,7 @@ const { open } = useGuide()
529
529
 
530
530
  No root store but any of those keys present means "played an earlier build": that launch rehydrates
531
531
  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
532
+ - **Home only.** Call `useAutoShowGuide()` from the Home screen - that placement is what keeps it from
533
533
  firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up
534
534
  by both players. Pass `false` to hold it back (e.g. while a deep link is being handled).
535
535
  - **Leave the row off during a live match.** Omit `onShowHowToPlay` on any screen where Settings pauses or
@@ -549,6 +549,36 @@ const { open } = useGuide()
549
549
  and only say what's true in every mode ("your half" is only true in two-player).
550
550
  - **Not `onboarding`.** LightCycles and Snake already use that word for their 3-2-1-GO countdown.
551
551
 
552
+ ## Fleet chrome without the heavy peers (`@tastic/hud/chrome`)
553
+
554
+ The shared dialogs, stats screen pieces, corner buttons and style tokens, from a standalone entry that never
555
+ touches the main barrel. It's for an app that has `@tastic/core` and `@rific/updater` but none of Skia,
556
+ `@tastic/profile` or `@tastic/split-screen` (Hangman). It exports, with their props types:
557
+
558
+ - Dialogs: `BaseSettingsDialog`, `ConfirmDialog`, `UpdateDialog`, `RotatedFrame`
559
+ - Stats: `BaseStatsScreen`, `StatSection`, `StatRow`, `AchievementRow`, `LOCKED_BADGE_COLOR`,
560
+ `AchievementCatalogSection`, `ActivityStatSection`
561
+ - Chrome: `CornerActionButtons`, `ContentGutter`, `StaggeredWord`, `getStaggeredWordDuration`
562
+ - Style tokens: `getOverlayCardColors`, `overlayStyles`, `overlayActionStyles`, `MONO_FONT`
563
+
564
+ Each is the same component the main entry exports and is documented above. Only the import path changes:
565
+
566
+ ```tsx
567
+ import { BaseSettingsDialog, ConfirmDialog, CornerActionButtons } from '@tastic/hud/chrome'
568
+ ```
569
+
570
+ It pulls in only `react`, `react-native`, `react-native-paper`, `react-native-reanimated`,
571
+ `react-native-safe-area-context`, `@rific/auto-paper`, `@rific/feedback-press`, `@rific/updater` and
572
+ `@tastic/core`. `AchievementCatalogSection` uses `@tastic/achievements` for types only, so nothing loads it at
573
+ runtime, but an app whose TypeScript resolves the `react-native` export condition (the fleet's tsconfig does)
574
+ type-checks hud's source and needs it installed. `AchievementUnlockList` isn't here because it renders
575
+ `@tastic/profile`'s `ProfileChip`. A test (`src/__tests__/chromeImports.test.ts`) walks the import graph from
576
+ `src/chrome/index.ts` and fails if anything it reaches imports another package, value-imports
577
+ `@tastic/achievements`, reaches the main barrel, or has a `.web` twin.
578
+
579
+ Metro resolves both entries to the same source files, so an app that imports some components from here and
580
+ others from `@tastic/hud` still bundles each module once.
581
+
552
582
  ## Install
553
583
 
554
584
  Published to the public npm registry as `@tastic/hud`. Everything described above, including the
@@ -571,8 +601,9 @@ when no rotation applies.
571
601
  ## Peer dependencies
572
602
 
573
603
  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
604
+ `peerDependenciesMeta` so a light app can install hud without the ones it doesn't use: `./guide` needs
605
+ none of the six, and `./chrome` needs only `@tastic/core` and `@rific/updater` (plus
606
+ `@tastic/achievements` to type-check, see above). **npm does not install optional peers for you**, so an app that imports the
576
607
  main `@tastic/hud` entry must list every peer that entry loads in its own `dependencies`, including
577
608
  the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to
578
609
  resolve module".
@@ -580,14 +611,14 @@ resolve module".
580
611
  | Peer | Floor | Needed by |
581
612
  | --- | --- | --- |
582
613
  | `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` |
614
+ | `react-native-paper` | >=5.0.0 | main entry, `./guide`, `./chrome` |
615
+ | `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide`, `./chrome` (`StaggeredWord`) |
616
+ | `@rific/auto-paper` | >=0.12.2 | main entry (`BaseSettingsDialog` needs the nested `Portal.Host` first shipped in 0.12.2), `./guide`, `./chrome` |
617
+ | `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide`, `./chrome` |
618
+ | `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`), `./chrome` (`RotatedFrame`, `BaseSettingsDialog`) |
619
+ | `@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) |
620
+ | `@rific/updater` | >=0.4.0, optional | main entry and `./chrome`: `BaseSettingsDialog`, `UpdateDialog` |
590
621
  | `@tastic/profile` | >=0.4.0, optional | main entry: `PlayerSetupPanel`, `AchievementUnlockList` (`ProfileChip`) |
591
622
  | `@tastic/split-screen` | >=0.5.0, optional | main entry: `useZoneClampedAlign` |
592
623
  | `@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`) |
624
+ | `@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 |