@tastic/hud 0.8.5 → 0.13.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.
package/README.md CHANGED
@@ -38,6 +38,31 @@ corner of the screen.
38
38
  - **`TriggerGauge`** — decorative ring of tick marks around a trigger, showing which option(s) are
39
39
  active without opening the popover.
40
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
+ name header (a saved-profile switcher for a human seat, a CPU-difficulty `LabeledDropdown` for a
43
+ non-human one, or a plain label when neither applies), a color picker (`InlineColorPicker`)
44
+ optionally paired with a caller-rendered `secondPicker` slot, and a `ReadyButton` for human seats.
45
+ Ported from five apps' own near-identical `PlayerSetupPanel.tsx`/`LobbyPlayerPanel.tsx`
46
+ (AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
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
50
+ default for *any* screen with top-corner icon buttons, home screens included: every app in the
51
+ fleet still hand-rolls its own trophy/cog `IconButton` pair on the home screen specifically
52
+ instead of reusing this component there, a real (if minor) missed-reuse case worth fixing next
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
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
59
+ caller stacking several words (AirHockey's "AIR"/"HOCKEY", LightCycles' "Light"/"Cycles") renders
60
+ more than one `StaggeredWord` and threads `startIndex` through so the whole wordmark reads as one
61
+ continuous cascade instead of each word restarting its own stagger from zero.
62
+ `getStaggeredWordDuration(letterCount)` is the matching total-duration formula, exported as a
63
+ plain function rather than something read off a ref or hook, so a caller's own follow-on
64
+ animation (a paddle rally, a bouncing puck, a looping trail) can compute once, outside render,
65
+ when the lettering has mostly landed.
41
66
  - **`PressAwayOverlay`** — an invisible full-bleed tap-catcher for press-away-to-close. This is the
42
67
  one piece that needs care in a split-screen layout — see below.
43
68
  - **`BaseSettingsDialog`** / **`BaseStatsScreen`** / **`ConfirmDialog`** — shared shells for the
@@ -118,37 +143,414 @@ your shared/global settings) gets the whole-screen overlay; every other player's
118
143
  must stay mounted (shielding, if not actually closing anything) whenever *any* host with broader
119
144
  reach is open — not just their own.
120
145
 
121
- ## Install
146
+ ## Integrating BaseSettingsDialog
122
147
 
123
- Published to the public npm registry as `@tastic/hud`. The self-reading rotation behavior described
124
- above is newer than the latest published version, though — for now it only exists in local,
125
- `yalc`-linked builds (see below) until it's published for real; the published version's `rotation`
126
- prop on these four components defaults to a plain `0` rather than reading `@tastic/core`'s
127
- `useRotation()`.
148
+ `BaseSettingsDialog` is a shared shell, not a drop-in screen — every one of the five consuming apps
149
+ (AirHockey, BoxHockey, Pong, LightCycles, Snake) wraps it in its own local
150
+ `src/components/SettingsDialog.tsx` adapter rather than rendering it directly from a route. The
151
+ adapter's whole job is to bridge whatever local settings mechanism the app uses onto this package's
152
+ fixed prop contract — it should carry no UI of its own beyond that plumbing.
128
153
 
129
- ```bash
130
- npm install @tastic/hud
154
+ That local mechanism genuinely varies per app. AirHockey/BoxHockey/LightCycles thread a
155
+ `settings`/`setSettings` pair in as props from a caller-owned `GameSettings` object (each adapter's
156
+ own three call sites already have one in scope for other reasons). Pong reads that same shape off
157
+ its own `useGameSettings()` hook internally instead of taking it as props. Snake — the one app in
158
+ the fleet with real Redux Toolkit state — reads `lockOrientation`/`deferBottomEdgeGestures` via
159
+ `useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
160
+ inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
161
+ which of these an app uses: sound, haptics, appearance, and update-checking are identical across
162
+ every app and live entirely inside this package now, so `lockOrientation`/`onLockOrientationChange`
163
+ and `deferBottomEdgeGestures`/`onDeferBottomEdgeGestures` are the only two settings an adapter
164
+ actually needs to source itself, however it does that.
165
+
166
+ Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
167
+ knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
168
+ one up — an adapter that leaves `onUpdateError` unset silently swallows a failed update check
169
+ instead of surfacing it anywhere, which is a real bug this fleet actually shipped in one app before
170
+ being caught and fixed. If your app has no toast system, route it to whatever your own
171
+ error-surfacing convention is; just don't leave it as a no-op.
172
+
173
+ A minimal adapter, following the shape every current one uses:
174
+
175
+ ```tsx
176
+ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, setSettings }: SettingsDialogProps) {
177
+ const { error } = useToast()
178
+
179
+ return (
180
+ <BaseSettingsDialog
181
+ visible={visible}
182
+ onDismiss={onDismiss}
183
+ rotation={rotation}
184
+ version={release.otaVersion}
185
+ lockOrientation={settings.lockOrientation}
186
+ onLockOrientationChange={(value) => setSettings({ lockOrientation: value })}
187
+ deferBottomEdgeGestures={settings.deferBottomEdgeGestures}
188
+ onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })}
189
+ onUpdateError={(message) => error('Update check failed', message)}
190
+ />
191
+ )
192
+ }
131
193
  ```
132
194
 
133
- ### Local dev via yalc (for unpublished changes)
195
+ ## Zone-aware popovers (`useZoneClampedAlign`)
134
196
 
135
- ```bash
136
- cd react-native-hud
137
- npm run build
138
- yalc publish
197
+ `useAutoAlign` — and everything built on it, including `SectionedDropdown`, `InlineColorPicker`, and
198
+ `LabeledDropdown` — only knows about the full device window edge. It has no concept of a
199
+ `@tastic/split-screen` zone boundary: the shared neutral band between two players' halves, or the
200
+ line where one player's zone stops and the other's begins. A popover host that lives entirely
201
+ inside one player's zone (rather than centered app-wide, the way `BaseSettingsDialog` is) can end up
202
+ choosing an alignment that opens toward the shared band, or overflows past the zone boundary into
203
+ the other player's half — not because `useAutoAlign` measured wrong, but because the window edge it
204
+ correctly measured against isn't the boundary that actually matters there.
205
+
206
+ LightCycles hit this for real — its `PlayerSetupPanel`-hosted color/CPU-difficulty pickers live
207
+ inside a rotated split-screen zone — and originally built its own local fix for it rather than
208
+ waiting for this package to grow zone-awareness. That fix is now `useZoneClampedAlign`, a
209
+ first-class export of this package: it wraps `useAutoAlign`'s result and re-derives the vertical
210
+ decision (and `maxHeight`) against the zone's real edge, via `@tastic/split-screen`'s
211
+ `useZoneBounds()`, instead of the window edge — including correcting for a 180°-rotated zone
212
+ inverting `PopoverBody`'s local 'above'/'below' labels relative to their real-world direction.
213
+ `@tastic/split-screen` is a peer dependency of this package because of this hook specifically — it's
214
+ only ever imported if you actually call `useZoneClampedAlign`.
215
+
216
+ Same signature and return shape as `useAutoAlign` (`open`, `contentWidth`, `contentHeight` in;
217
+ `align`, `verticalAlign`, `maxHeight`, `measured`, `triggerRef` out — exported as `AlignResult`, see
218
+ below) so it's a drop-in replacement anywhere `useAutoAlign` is called directly. Outside a
219
+ `@tastic/split-screen` zone (`useZoneBounds()` returns `null`), it's a pure passthrough to
220
+ `useAutoAlign`'s own decision — safe to reach for even in a component that might or might not end up
221
+ inside one:
222
+
223
+ ```tsx
224
+ const auto = useZoneClampedAlign(open, POPOVER_WIDTH, contentHeight)
225
+ ```
226
+
227
+ `LabeledDropdown`, `SectionedDropdown`, and `InlineColorPicker` all accept the result of the above
228
+ via an `alignOverride` prop (typed `AlignResult`, also exported), substituting it wholesale for
229
+ their own internal, zone-blind `useAutoAlign` call:
230
+
231
+ ```tsx
232
+ <SectionedDropdown id='p1-arena' host={host} sections={sections} accentColor={color} mutedColor={muted} dark={dark} alignOverride={useZoneClampedAlign(open, MENU_WIDTH, contentHeight)} />
233
+ ```
234
+
235
+ `ControlSchemePicker` forwards both `alignOverride` and `rotation` straight through to the
236
+ `SectionedDropdown` it renders internally, so the same call works there too. `PlayerSetupPanel`'s
237
+ own internal color/CPU-difficulty pickers aren't reachable this way (they're not a `ReactNode` slot
238
+ your own code renders — see "The secondPicker slot" below), so it exposes `colorAlignOverride` and
239
+ `cpuDifficultyAlignOverride` props instead, forwarded straight to `InlineColorPicker`'s and
240
+ `LabeledDropdown`'s own `alignOverride` internally. Computing `open` for either one needs that
241
+ picker's own popover id, which `PlayerSetupPanel` derives internally from `idPrefix` — call
242
+ `getColorPopoverId(idPrefix)`/`getCpuDifficultyPopoverId(idPrefix)` (both exported) rather than
243
+ guessing or hardcoding the naming convention yourself:
244
+
245
+ ```tsx
246
+ const cpuOpen = host.openId === getCpuDifficultyPopoverId('p2')
247
+ const cpuAlign = useZoneClampedAlign(cpuOpen, LABELED_DROPDOWN_POPOVER_WIDTH, getLabeledDropdownContentHeight(cpuDifficultyOptions.length))
248
+
249
+ <PlayerSetupPanel idPrefix='p2' host={host} cpuDifficulty={difficulty} cpuDifficultyOptions={cpuDifficultyOptions} onCpuDifficultyChange={setDifficulty} cpuDifficultyAlignOverride={cpuAlign} /* ...the rest of your usual props */ />
250
+ ```
251
+
252
+ For any other popover-hosting component that might sit inside a split-screen zone, call
253
+ `useZoneClampedAlign` directly in place of `useAutoAlign`.
254
+
255
+ ## The secondPicker slot (read this before wiring one up)
256
+
257
+ `PlayerSetupPanel`'s `secondPicker` is a caller-rendered `ReactNode` slot next to the built-in color
258
+ picker, not a fixed prop shape — the five apps this component was extracted from each wanted a
259
+ genuinely different second control there (a `ControlSchemePicker`, a raw `SectionedDropdown`, or
260
+ nothing at all), so rather than this package baking in one shape most consumers don't need, the
261
+ panel just renders whatever `secondPicker` is given, wherever it's given.
262
+
263
+ If whatever you slot in opens its own popover, pass its popover id as `secondPickerId` too:
264
+
265
+ ```tsx
266
+ <PlayerSetupPanel
267
+ idPrefix='p1'
268
+ host={sharedHost}
269
+ color={color}
270
+ onColorChange={setColor}
271
+ swatches={swatches}
272
+ isHuman
273
+ secondPicker={<ControlSchemePicker id='p1-scheme' host={sharedHost} value={scheme} onChange={setScheme} />}
274
+ secondPickerId='p1-scheme'
275
+ dark={dark}
276
+ />
277
+ ```
278
+
279
+ Skipping `secondPickerId` when your slotted picker does open a popover is easy to get wrong, and it
280
+ fails silently rather than throwing: `PlayerSetupPanel` uses it to decide whether its own
281
+ `pickerRow` needs the elevated z-index that lets a popover escape the row's bounds and paint above a
282
+ later sibling (the `ReadyButton` beneath it, or another panel next to it — see `pickerRowOpen` in
283
+ the component's own source). Without `secondPickerId`, the panel has no way to know your picker's
284
+ popover is open, so that elevation never kicks in, and your popover can silently render underneath
285
+ sibling content instead of on top of it. If your `secondPicker` never opens a popover of its own (a
286
+ plain toggle, static content, or nothing at all), omit `secondPickerId` — there's nothing for it to
287
+ track. And if that slotted picker's own popover lives inside a split-screen zone, see "Zone-aware
288
+ popovers" above for how to wire `alignOverride`/`rotation` into whatever you construct there.
289
+
290
+ ## Quit confirmation (`useQuitConfirmation`)
291
+
292
+ Captures the `onBackPress` + `<ConfirmDialog>` "Quit Match?" pattern every fleet `game.tsx` used to
293
+ hand-wire on its own (Snake, AirHockey, BoxHockey, Pong, LightCycles): interrupt backing out of a
294
+ match with a confirmation only when there's progress worth losing, otherwise back out immediately.
295
+
296
+ ```ts
297
+ function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation
298
+
299
+ interface QuitConfirmation {
300
+ confirmVisible: boolean
301
+ requestBack: () => void
302
+ cancelBack: () => void
303
+ }
304
+ ```
305
+
306
+ Wire `confirmVisible`/`cancelBack` straight to `<ConfirmDialog visible={...} onCancel={...}>`, and
307
+ `requestBack` to the back button's `onPress`. The hook deliberately doesn't own or render the
308
+ `<ConfirmDialog>` itself — title/message/icon/labels/rotation stay authored at each call site (a
309
+ score pairing in most apps, a round-history pip row in LightCycles), matching this package's
310
+ established "hooks return data, JSX stays at the call site" convention (`usePopoverHost`,
311
+ `useAutoAlign`, `useZoneClampedAlign` all do the same).
312
+
313
+ **`hasProgress` is a lazy `() => boolean` getter, not a plain `boolean` — this is a load-bearing
314
+ correction, not a style choice.** A plain-boolean argument forces the caller to compute it every
315
+ render from state that, in several apps, doesn't exist yet at that point in the render.
316
+ AirHockey/BoxHockey/Pong all fold this hook's own `confirmVisible` into a
317
+ `paused = settingsOpen || confirmVisible` that gates `useGameState(paused)` — and
318
+ `useGameState`'s *return value* (scores, lives, bricks) is exactly what `hasProgress` needs to
319
+ read. A plain boolean here would create a real circular render dependency:
320
+ `confirmVisible → paused → useGameState(paused) → {scores/lives/bricks} → hasProgress →
321
+ confirmVisible`. The lazy getter breaks the cycle: `hasProgress` is only invoked inside
322
+ `requestBack`, at tap time, well after `useGameState` has already produced that render's state —
323
+ the getter's closure just reads whatever `scores` is bound to *by then*, the same way a hand-rolled
324
+ `onBackPress` closure always did before this hook existed:
325
+
326
+ ```tsx
327
+ // AirHockey's game.tsx — hasProgress reads `scores`, a value useGameState (below) hasn't produced
328
+ // yet at the point useQuitConfirmation is called. That's fine: the getter is only ever called from
329
+ // inside requestBack, well after this render has finished and `scores` is a real value.
330
+ const [settingsOpen, setSettingsOpen] = useState(false)
331
+ const { confirmVisible, requestBack, cancelBack } = useQuitConfirmation(() => scores[0] > 0 || scores[1] > 0, safeBack)
332
+ const paused = settingsOpen || confirmVisible
333
+
334
+ const { state } = useGameState(board, scoreToWin, friction, paused)
335
+ const { scores } = state
336
+
337
+ // ...
338
+
339
+ <IconButton icon='arrow-left' onPress={requestBack} />
340
+
341
+ <ConfirmDialog
342
+ visible={confirmVisible}
343
+ title='Quit Match?'
344
+ message={`${scores[0]} – ${scores[1]}`}
345
+ confirmLabel='Quit'
346
+ cancelLabel='Cancel'
347
+ onConfirm={safeBack}
348
+ onCancel={cancelBack}
349
+ />
350
+ ```
139
351
 
140
- cd ../your-game
141
- yalc add @tastic/hud
142
- npm install
352
+ A game with no such circular dependency (Snake reads `humanScore`/`opponentScore`, LightCycles
353
+ reads `roundHistory.length`) still passes a getter — `() => humanScore > 0 || (opponentScore ?? 0) > 0`
354
+ — it's just a plain wrapper around an expression that could have been a bare boolean there; the
355
+ signature stays uniform across every call site rather than special-casing the apps that don't
356
+ currently need the deferred read.
357
+
358
+ ## Achievements catalog (`AchievementCatalogSection`, `ActivityStatSection`)
359
+
360
+ Two presentational components for an achievements/stats screen, designed to pair with
361
+ [`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements)'s own
362
+ `getAchievementCatalogRows` — the row-computation logic (reading `ACHIEVEMENT_CATALOG`,
363
+ `unlockedAchievements`, and stats) lives in that headless package (no react-native/Paper deps of
364
+ its own); the two components that actually render the result live here. Mirrors this package's
365
+ existing `AchievementRow` (hud, presentational, fully-precomputed props) / achievement-engine-shaped
366
+ (pure data) boundary.
367
+
368
+ **`AchievementCatalogSection`** — an "ALL ACHIEVEMENTS" heading plus one `<AchievementRow>` per
369
+ row. Returns a `Fragment`, not a `View`: every call site renders the heading and rows as flat
370
+ siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
371
+ here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
372
+ (`row.tierColor` once `row.unlockedAt` is set, else the locked color) rather than upstream, since
373
+ `getAchievementCatalogRows` deliberately leaves that choice to whatever renders the rows.
374
+
375
+ ```ts
376
+ interface AchievementCatalogSectionProps {
377
+ rows: AchievementCatalogRow[] // from @tastic/achievements' getAchievementCatalogRows
378
+ label?: string // default 'ALL ACHIEVEMENTS'
379
+ fg?: string
380
+ fgMuted?: string
381
+ }
382
+ ```
383
+
384
+ **`ActivityStatSection`** — the "ACTIVITY" `<StatSection>` (Days Played / Day Streak / Best Day
385
+ Streak). Takes a plain structural `ActivityStats` shape rather than importing
386
+ `@tastic/achievements`' own `DayStreakState` type, even though the fields match exactly — this
387
+ component has zero dependency, not even type-only, on `@tastic/achievements`; any caller's own
388
+ stats object with these three fields satisfies it:
389
+
390
+ ```ts
391
+ interface ActivityStatSectionProps {
392
+ stats: { distinctDaysPlayed: number; currentDayStreak: number; bestDayStreak: number }
393
+ }
143
394
  ```
144
395
 
145
- Re-run `npm run build && yalc push` from this package after any change to propagate it to every
146
- linked consumer at once.
396
+ Composing both with `@tastic/achievements`' own row computation:
397
+
398
+ ```tsx
399
+ import { getAchievementCatalogRows } from '@tastic/achievements'
400
+ import { AchievementCatalogSection, ActivityStatSection, BaseStatsScreen } from '@tastic/hud'
401
+
402
+ const rows = getAchievementCatalogRows(ACHIEVEMENT_CATALOG, deviceStats, statsView, unlockedAchievements, profileId)
403
+
404
+ return (
405
+ <BaseStatsScreen /* ...your usual props */>
406
+ <ActivityStatSection stats={statsView} />
407
+ <AchievementCatalogSection rows={rows} />
408
+ </BaseStatsScreen>
409
+ )
410
+ ```
411
+
412
+ `AchievementCatalogSection` and `AchievementUnlockList` (below) are the only places in this package
413
+ with a `@tastic/achievements` dependency, and both are type-only (`AchievementCatalogRow`,
414
+ `AchievementTier`) — never a value/runtime import. See Peer Dependencies below.
415
+
416
+ ## Achievement unlocks on a result card (`AchievementUnlockList`)
417
+
418
+ The achievements a match just unlocked, for a game-over or round-over card: one row per
419
+ achievement, each with its owner's avatar (the seat's `ProfileChip`, or a plain avatar in the seat's
420
+ color for a guest), a tier-colored icon badge, and the title. Rows are listed seat by seat. Renders
421
+ nothing when no seat unlocked anything, so it can sit in the card unconditionally.
422
+
423
+ ```tsx
424
+ import { ACHIEVEMENT_TIER_COLORS } from '@tastic/achievements'
425
+ import { AchievementUnlockList } from '@tastic/hud'
426
+
427
+ <AchievementUnlockList
428
+ unlocks={seatUnlocks} // Partial<Record<Seat, { id, icon, title, tier }[]>>
429
+ seats={[1, 2]}
430
+ owners={{ 1: { profile: p1Profile, color: p1Color }, 2: { profile: null, color: p2Color } }}
431
+ tierColors={ACHIEVEMENT_TIER_COLORS} // a prop, so @tastic/achievements stays type-only here
432
+ />
433
+ ```
434
+
435
+ Optional `fg` (title color, defaults from the theme) and `style` (the list container).
436
+
437
+ For muted profile colors, mount `@tastic/profile`'s `ProfileColorProvider`: the `ProfileChip`s here
438
+ pick it up. A guest seat's avatar draws the `color` you pass as-is, so pass it already muted.
439
+
440
+ ## How-to-play guide (`@tastic/hud/guide`)
441
+
442
+ A short, skippable, paged "how to play" card that shows once on a fresh install and can be replayed on
443
+ demand from Settings. Import it from the **`@tastic/hud/guide` subpath**, not the main barrel: it's a
444
+ standalone build that pulls in only `react`, `react-native`, `react-native-paper`,
445
+ `react-native-reanimated`, `@rific/auto-paper` and `@rific/feedback-press` — none of Skia,
446
+ `@tastic/core`, `@tastic/profile`, `@tastic/split-screen`, `@tastic/achievements` or `@rific/updater`
447
+ (all marked optional in `peerDependenciesMeta`), so an app that has none of them can use it. A test
448
+ (`src/__tests__/guideImports.test.ts`) fails if any file under `src/guide/` ever imports one.
449
+
450
+ ```tsx
451
+ import { createGuideSlice, GuideProvider, SeatDiagram, SwipeHint, useAutoShowGuide, useGuide } from '@tastic/hud/guide'
452
+
453
+ // 1. A persisted "seen" version — its own slice, mounted under `guide` in your root reducer.
454
+ const guide = createGuideSlice({ currentVersion: 1 }) // { actions.markSeen, reducer, selectVersionSeen }
455
+
456
+ // 2. Once, near the root, wrapping your screens (inside PersistGate + your theme/Paper providers).
457
+ // `rotation` is your game's live fake-landscape rotation (useRotation / getViewRotation).
458
+ <GuideProvider
459
+ steps={[{ title: 'Steer Your Cycle', body: 'Swipe up, down, left or right.', art: <SwipeHint direction='up' /> }]}
460
+ currentVersion={1}
461
+ seenVersion={useSelector(guide.selectVersionSeen)}
462
+ onSeen={(v) => dispatch(guide.actions.markSeen(v))}
463
+ rotation={rotation}
464
+ >
465
+ <Screens />
466
+ </GuideProvider>
467
+
468
+ // 3. On the Home screen, and nowhere else: opens it once per session for a player who hasn't seen it.
469
+ useAutoShowGuide()
470
+
471
+ // 4. Replay from Settings — BaseSettingsDialog renders a "How to Play" row when this is passed.
472
+ const { open } = useGuide()
473
+ <BaseSettingsDialog {...props} onShowHowToPlay={open} />
474
+ ```
475
+
476
+ - **Existing players are grandfathered.** On a fresh install redux-persist's `REHYDRATE` has no payload;
477
+ an existing player has a payload that simply predates the `guide` key. `createGuideSlice` stamps the
478
+ second with `currentVersion` instead of `0`, so an OTA update never greets them with a tutorial (they
479
+ can still replay it). Only finishing or skipping writes; a replay never does. Bump `currentVersion`
480
+ only for a real controls/rules change — everyone sees it again.
481
+ - **If your last shipped build predates your redux-persist store, you also need the migrate.** Those
482
+ players have no root store yet, which looks exactly like a fresh install. Pass the AsyncStorage keys the
483
+ shipped build wrote (read them from the last shipped commit, not today's source):
484
+
485
+ ```ts
486
+ const persistConfig = {
487
+ key: 'root',
488
+ storage: AsyncStorage,
489
+ migrate: createReturningPlayerMigrate(AsyncStorage, ['pong.settings', 'pong.stats', 'pong.achievements'])
490
+ }
491
+ ```
492
+
493
+ No root store but any of those keys present means "played an earlier build": that launch rehydrates
494
+ with an empty payload, which the slice grandfathers. An existing root store passes through untouched.
495
+ - **Home only.** Call `useAutoShowGuide()` from the Home screen — that placement is what keeps it from
496
+ firing over a live match or a two-seat loadout, where a single centered card can't be read right-way-up
497
+ by both players. Pass `false` to hold it back (e.g. while a deep link is being handled).
498
+ - **Leave the row off during a live match.** Omit `onShowHowToPlay` on any screen where Settings pauses or
499
+ overlays a running game.
500
+ - **Content is the app's.** A `GuideStep` is `{ title, body, art? }`; only the app knows its own controls
501
+ and win condition. Shared illustrations for the arcade games: `SwipeHint` (animated finger sweeping
502
+ toward an arrowhead; `pointer='mouse'` for a desktop seat that plays by dragging), `DirectionKeysHint`
503
+ (an inverted-T of key caps, its keyboard counterpart; pass the seat's real keys as `labels`, arrow
504
+ icons when omitted, `axis='vertical'` for up/down-only games), `SeatDiagram` (a phone split into two
505
+ seat-colored halves) and `SeatDevicesHint` (its desktop counterpart: a keyboard and/or mouse, each in
506
+ the color of the seat using it). All take their colors from the theme's primary/secondary by default. Give every
507
+ card an illustration: the pager sizes all pages to the tallest, so a card without one leaves a gap.
508
+ - **Fleet copy conventions.** Title Case titles ("Last One Riding Wins"), one-word buttons. The action
509
+ row is ConfirmDialog's (`overlayActionStyles`): outlined Skip (first card only) or Back on the left,
510
+ contained Next on the right, `Ready` on the last card. None of that is configurable: every game gets the identical
511
+ dialog and supplies only its cards (Jay, 2026-09-24: "only their content should be different"). Short bodies,
512
+ and only say what's true in every mode ("your half" is only true in two-player).
513
+ - **Not `onboarding`.** LightCycles and Snake already use that word for their 3-2-1-GO countdown.
514
+
515
+ ## Install
516
+
517
+ Published to the public npm registry as `@tastic/hud`. Everything described above, including the
518
+ self-reading rotation behavior on these four components (defaulting to `@tastic/core`'s own
519
+ `useRotation()` rather than a plain `0`), `PlayerSetupPanel`, and `StaggeredWord`, is live on npm.
520
+
521
+ ```bash
522
+ npm install @tastic/hud
523
+ ```
524
+
525
+ ## ContentGutter
526
+
527
+ Caps a play area at `maxContentWidth` and splits the leftover width into two equal gutters
528
+ (`leftGutter`/`rightGutter` decorations). The width it clamps against is `@tastic/core`'s
529
+ `useRotatedWindowDimensions()`, so under a fake-landscape ancestor (`FakeLandscapeView`: OS window
530
+ stays portrait, the screen is only visually rotated) it caps against the swapped, post-rotation width
531
+ instead of the never-changing raw window width. It is an exact identity with `useWindowDimensions()`
532
+ when no rotation applies.
147
533
 
148
534
  ## Peer dependencies
149
535
 
150
- `react`, `react-native`, `react-native-paper` (`Icon`, `IconButton`, `Text`), `@rific/auto-paper`
151
- (`defaultColors`, `getContrastColor`, `getBlendedColor`, `SeedColor`), `@rific/feedback-press`
152
- (`IconButton`, `TouchableRipple`), [`@tastic/core`](https://github.com/jayrdeaton/react-native-game-core)
153
- (>=0.1.0 — `useRotation()`, see Rotation above) — none of these are bundled, so use whatever
154
- versions your app already has.
536
+ None of these are bundled, so use whatever versions your app already has. Six are marked `optional` in
537
+ `peerDependenciesMeta` only so that an app using just the `@tastic/hud/guide` subpath (Hangman) can
538
+ install hud without them. **npm does not install optional peers for you**, so an app that imports the
539
+ main `@tastic/hud` entry must list every peer that entry loads in its own `dependencies`, including
540
+ the four optional ones marked "main entry" below. Without them, Metro fails to bundle with "Unable to
541
+ resolve module".
542
+
543
+ | Peer | Floor | Needed by |
544
+ | --- | --- | --- |
545
+ | `react`, `react-native` | >=19.0.0, >=0.76.0 | every entry |
546
+ | `react-native-paper` | >=5.0.0 | main entry, `./guide` |
547
+ | `react-native-reanimated` | >=3.0.0 | main entry (`StaggeredWord`), `./guide` |
548
+ | `@rific/auto-paper` | >=0.9.4 | main entry (`AutoAppearancePicker` first shipped in 0.9.4), `./guide` |
549
+ | `@rific/feedback-press` | >=0.10.2 | main entry (`useSoundSettings` first shipped in 0.10.2), `./guide` |
550
+ | `react-native-safe-area-context` | >=5.0.0 | main entry (`useZoneClampedAlign`) |
551
+ | `@tastic/core` | >=0.5.0, optional | main entry: `useRotation()` and most dialogs; `ContentGutter` needs `useRotatedWindowDimensions()`, first shipped in 0.5.0 |
552
+ | `@rific/updater` | >=0.4.0, optional | main entry: `BaseSettingsDialog`, `UpdateDialog` |
553
+ | `@tastic/profile` | >=0.4.0, optional | main entry: `PlayerSetupPanel`, `AchievementUnlockList` (`ProfileChip`) |
554
+ | `@tastic/split-screen` | >=0.5.0, optional | main entry: `useZoneClampedAlign` |
555
+ | `@shopify/react-native-skia` | >=1.5.0, optional | `TriggerGaugeHost`'s gauge (lazy-loaded behind `SkiaGate`) and `./skia-gate` |
556
+ | `@tastic/achievements` | >=0.2.0, optional | `AchievementCatalogSection`, `AchievementUnlockList`, type-only (`AchievementCatalogRow`, first exported in 0.2.0; `AchievementTier`) |
@@ -0,0 +1,77 @@
1
+ // src/overlayCard.ts
2
+ import { StyleSheet } from "react-native";
3
+ function getOverlayCardColors(dark) {
4
+ return {
5
+ fg: dark ? "#FFFFFF" : "#000000",
6
+ fgMuted: dark ? "rgba(255,255,255,0.4)" : "rgba(0,0,0,0.4)",
7
+ cardBg: dark ? "#111111" : "#F2F2F2",
8
+ cardBorder: dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"
9
+ };
10
+ }
11
+ var overlayStyles = StyleSheet.create({
12
+ card: {
13
+ alignItems: "center",
14
+ borderRadius: 20,
15
+ borderWidth: 1,
16
+ gap: 16,
17
+ maxWidth: 360,
18
+ padding: 32
19
+ },
20
+ overlay: {
21
+ alignItems: "center",
22
+ backgroundColor: "rgba(0,0,0,0.72)",
23
+ bottom: 0,
24
+ justifyContent: "center",
25
+ left: 0,
26
+ position: "absolute",
27
+ right: 0,
28
+ top: 0
29
+ }
30
+ });
31
+ var overlayActionStyles = StyleSheet.create({
32
+ // minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
33
+ // still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
34
+ // on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
35
+ // row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
36
+ // correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
37
+ // "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
38
+ // tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
39
+ // nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
40
+ // allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
41
+ // flexBasis) reaches the outer view, which is the real flex item inside this row; every other
42
+ // key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
43
+ // defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
44
+ // correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
45
+ // sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
46
+ // correctly regardless of which of the two views it ends up on. react-native-paper's Button also
47
+ // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
48
+ // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
49
+ // fixable by just giving the row more width, until confirmed live that these two-word labels
50
+ // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room ConfirmDialog's own
51
+ // card width fix provides, on a narrow enough screen.
52
+ actionButton: {
53
+ flexGrow: 1,
54
+ minWidth: 128
55
+ },
56
+ // A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
57
+ // update-check "no update found" message) has nothing left to share actions' width with, so
58
+ // actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
59
+ // normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
60
+ // the card - centered by actions' own justifyContent below.
61
+ actionButtonSingle: {
62
+ minWidth: 128
63
+ },
64
+ actions: {
65
+ flexDirection: "row",
66
+ flexWrap: "wrap",
67
+ gap: 12,
68
+ justifyContent: "center",
69
+ width: "100%"
70
+ }
71
+ });
72
+
73
+ export {
74
+ getOverlayCardColors,
75
+ overlayStyles,
76
+ overlayActionStyles
77
+ };