@tastic/hud 0.8.4 → 0.12.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,293 @@ 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
139
298
 
140
- cd ../your-game
141
- yalc add @tastic/hud
142
- npm install
299
+ interface QuitConfirmation {
300
+ confirmVisible: boolean
301
+ requestBack: () => void
302
+ cancelBack: () => void
303
+ }
143
304
  ```
144
305
 
145
- Re-run `npm run build && yalc push` from this package after any change to propagate it to every
146
- linked consumer at once.
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
+ ```
351
+
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
+ }
394
+ ```
395
+
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` is the only place in this package with a `@tastic/achievements`
413
+ dependency, and even there it's type-only (`AchievementCatalogRow`) — never a value/runtime
414
+ import. See Peer Dependencies below.
415
+
416
+ ## Install
417
+
418
+ Published to the public npm registry as `@tastic/hud`. Everything described above, including the
419
+ self-reading rotation behavior on these four components (defaulting to `@tastic/core`'s own
420
+ `useRotation()` rather than a plain `0`), `PlayerSetupPanel`, and `StaggeredWord`, is live on npm.
421
+
422
+ ```bash
423
+ npm install @tastic/hud
424
+ ```
147
425
 
148
426
  ## Peer dependencies
149
427
 
150
428
  `react`, `react-native`, `react-native-paper` (`Icon`, `IconButton`, `Text`), `@rific/auto-paper`
151
429
  (`defaultColors`, `getContrastColor`, `getBlendedColor`, `SeedColor`), `@rific/feedback-press`
152
430
  (`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.
431
+ (>=0.1.0 — `useRotation()`, see Rotation above),
432
+ [`@tastic/achievements`](https://github.com/jayrdeaton/react-native-game-achievements) (>=0.1.2 —
433
+ `AchievementCatalogRow`, in `AchievementCatalogSection.tsx` only, and type-only: no value/runtime
434
+ import, see Achievements catalog above) — none of these are bundled, so use whatever versions your
435
+ app already has.
package/dist/index.d.mts CHANGED
@@ -1,12 +1,22 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, RefObject } from 'react';
3
+ import { AchievementCatalogRow } from '@tastic/achievements';
3
4
  import { ViewRotation } from '@tastic/core';
4
- import { StyleProp, ViewStyle, View } from 'react-native';
5
+ import { StyleProp, ViewStyle, View, TextStyle, LayoutChangeEvent } from 'react-native';
5
6
  import { SeedColor } from '@rific/auto-paper';
7
+ import { Profile } from '@tastic/profile';
6
8
  export { SkiaGate, SkiaGateProps } from './SkiaGate.mjs';
7
9
 
10
+ interface Props$e {
11
+ rows: AchievementCatalogRow[];
12
+ label?: string;
13
+ fg?: string;
14
+ fgMuted?: string;
15
+ }
16
+ declare function AchievementCatalogSection({ rows, label, fg, fgMuted: fgMutedOverride }: Props$e): react.JSX.Element;
17
+
8
18
  declare const LOCKED_BADGE_COLOR = "rgba(128,128,128,0.3)";
9
- interface Props$c {
19
+ interface Props$d {
10
20
  icon: string;
11
21
  title: string;
12
22
  description: string;
@@ -19,7 +29,17 @@ interface Props$c {
19
29
  fgMuted?: string;
20
30
  sectionBg?: string;
21
31
  }
22
- declare function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props$c): react.JSX.Element;
32
+ declare function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props$d): react.JSX.Element;
33
+
34
+ interface ActivityStats {
35
+ distinctDaysPlayed: number;
36
+ currentDayStreak: number;
37
+ bestDayStreak: number;
38
+ }
39
+ interface Props$c {
40
+ stats: ActivityStats;
41
+ }
42
+ declare function ActivityStatSection({ stats }: Props$c): react.JSX.Element;
23
43
 
24
44
  interface BaseSettingsDialogProps {
25
45
  visible: boolean;
@@ -87,11 +107,18 @@ declare function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style
87
107
  type PopoverAlign = 'left' | 'right' | 'center';
88
108
  type PopoverVerticalAlign = 'below' | 'above';
89
109
  type PopoverRotation = ViewRotation;
110
+ interface AlignResult {
111
+ align: PopoverAlign;
112
+ verticalAlign: PopoverVerticalAlign;
113
+ maxHeight: number;
114
+ measured: boolean;
115
+ triggerRef: RefObject<View | null>;
116
+ }
90
117
  declare function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): {
91
118
  align: PopoverAlign;
92
119
  maxHeight: number;
93
120
  measured: boolean;
94
- triggerRef: react.RefObject<View | null>;
121
+ triggerRef: RefObject<View | null>;
95
122
  verticalAlign: PopoverVerticalAlign;
96
123
  };
97
124
 
@@ -137,6 +164,7 @@ interface Props$9 {
137
164
  onAccentColor?: string;
138
165
  dark: boolean;
139
166
  align?: 'left' | 'right' | 'center';
167
+ alignOverride?: AlignResult;
140
168
  rotation?: PopoverRotation;
141
169
  autoDismiss?: boolean;
142
170
  labelFontFamily?: string;
@@ -145,7 +173,7 @@ interface Props$9 {
145
173
  clear: string;
146
174
  };
147
175
  }
148
- declare function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss, labelFontFamily, allClearLabels }: Props$9): react.JSX.Element;
176
+ declare function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, autoDismiss, labelFontFamily, allClearLabels }: Props$9): react.JSX.Element;
149
177
 
150
178
  interface ControlSchemePickerProps<T extends string | number> {
151
179
  id: string;
@@ -159,9 +187,11 @@ interface ControlSchemePickerProps<T extends string | number> {
159
187
  onAccentColor?: string;
160
188
  dark: boolean;
161
189
  align?: 'left' | 'right' | 'center';
190
+ alignOverride?: AlignResult;
191
+ rotation?: PopoverRotation;
162
192
  labelFontFamily?: string;
163
193
  }
164
- declare function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>): react.JSX.Element | null;
194
+ declare function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>): react.JSX.Element | null;
165
195
 
166
196
  interface Props$8 {
167
197
  onBack: () => void;
@@ -191,6 +221,10 @@ declare function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: Corn
191
221
 
192
222
  declare const MONO_FONT: string;
193
223
 
224
+ declare function getInlineColorPickerContentSize(swatchCount: number, windowWidth: number, columns?: number): {
225
+ width: number;
226
+ height: number;
227
+ };
194
228
  interface Props$7 {
195
229
  id: string;
196
230
  host: PopoverHost;
@@ -202,6 +236,7 @@ interface Props$7 {
202
236
  allowSwapTaken?: boolean;
203
237
  dark: boolean;
204
238
  align?: 'left' | 'right' | 'center';
239
+ alignOverride?: AlignResult;
205
240
  rotation?: PopoverRotation;
206
241
  icon?: string;
207
242
  tag?: string;
@@ -210,7 +245,7 @@ interface Props$7 {
210
245
  columns?: number;
211
246
  size?: number;
212
247
  }
213
- declare function InlineColorPicker({ id, host, value, onChange, previewValue, swatches, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$7): react.JSX.Element;
248
+ declare function InlineColorPicker({ id, host, value, onChange, previewValue, swatches, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon, tag, labelFontFamily, autoDismiss, columns, size }: Props$7): react.JSX.Element;
214
249
 
215
250
  declare const LABELED_DROPDOWN_POPOVER_WIDTH = 180;
216
251
  declare function getLabeledDropdownContentHeight(optionCount: number): number;
@@ -219,13 +254,6 @@ interface LabeledDropdownOption<T extends string> {
219
254
  label: string;
220
255
  icon?: string;
221
256
  }
222
- interface AlignResult {
223
- align: PopoverAlign;
224
- verticalAlign: PopoverVerticalAlign;
225
- maxHeight: number;
226
- measured: boolean;
227
- triggerRef: RefObject<View | null>;
228
- }
229
257
  interface Props$6<T extends string> {
230
258
  id: string;
231
259
  host: PopoverHost;
@@ -269,6 +297,42 @@ declare const overlayStyles: {
269
297
  };
270
298
  };
271
299
 
300
+ declare function getColorPopoverId(idPrefix: string): string;
301
+ declare function getCpuDifficultyPopoverId(idPrefix: string): string;
302
+ interface PlayerSetupPanelProps<C extends string = string> {
303
+ idPrefix: string;
304
+ host?: PopoverHost;
305
+ label?: string;
306
+ labelFontFamily?: string;
307
+ color: string;
308
+ onColorChange: (hex: string) => void;
309
+ swatches: SeedColor[];
310
+ takenColor?: string;
311
+ allowSwapTaken?: boolean;
312
+ colorAlignOverride?: AlignResult;
313
+ colorRotation?: PopoverRotation;
314
+ isHuman: boolean;
315
+ secondPicker?: ReactNode;
316
+ secondPickerId?: string;
317
+ ready?: boolean;
318
+ onToggleReady?: () => void;
319
+ dark: boolean;
320
+ showReadyButton?: boolean;
321
+ profiles?: Profile[];
322
+ selectedProfileId?: string | null;
323
+ takenProfileId?: string | null;
324
+ guestLabel?: string;
325
+ onProfileSelect?: (profile: Profile | null) => void;
326
+ onManageProfiles?: () => void;
327
+ cpuDifficulty?: C;
328
+ cpuDifficultyOptions?: LabeledDropdownOption<C>[];
329
+ onCpuDifficultyChange?: (value: C) => void;
330
+ cpuDifficultyAlignOverride?: AlignResult;
331
+ humanIcon?: string;
332
+ cpuIcon?: string;
333
+ }
334
+ declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
335
+
272
336
  interface Props$5 {
273
337
  visible: boolean;
274
338
  children: ReactNode;
@@ -308,6 +372,22 @@ interface Props$2 {
308
372
  }
309
373
  declare function SharedActionBand({ onBack, onSettings, onRandomize, onReset, fg, popoverOpen, children }: Props$2): react.JSX.Element;
310
374
 
375
+ declare function getStaggeredWordDuration(letterCount: number): number;
376
+ interface StaggeredWordProps {
377
+ word: string;
378
+ color: string;
379
+ fontFamily: string;
380
+ fontSize: number;
381
+ lineHeight: number;
382
+ reducedMotion: boolean;
383
+ startIndex?: number;
384
+ fontWeight?: TextStyle['fontWeight'];
385
+ letterSpacing?: number;
386
+ style?: StyleProp<ViewStyle>;
387
+ onLayout?: (e: LayoutChangeEvent) => void;
388
+ }
389
+ declare function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex, fontWeight, letterSpacing, style, onLayout }: StaggeredWordProps): react.JSX.Element;
390
+
311
391
  interface Props$1 {
312
392
  label: string;
313
393
  value: string;
@@ -345,4 +425,16 @@ interface UpdateDialogProps {
345
425
  }
346
426
  declare function UpdateDialog({ rotation, autoCheck, autoPrompt, onError }: UpdateDialogProps): react.JSX.Element;
347
427
 
348
- export { AchievementRow, BaseSettingsDialog, type BaseSettingsDialogProps, BaseStatsScreen, ConfirmDialog, type ConfirmDialogProps, ContentGutter, ControlSchemePicker, type ControlSchemePickerProps, CornerActionButtons, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LOCKED_BADGE_COLOR, LabeledDropdown, type LabeledDropdownOption, MONO_FONT, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, type OverlayCardColors, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, StatRow, StatSection, TriggerGaugeHost, type TriggerGaugeProps, UpdateDialog, type UpdateDialogProps, getLabeledDropdownContentHeight, getOverlayCardColors, loadSkiaWeb, overlayStyles, useAutoAlign, usePopoverHost };
428
+ interface QuitConfirmation {
429
+ /** Wire directly to <ConfirmDialog visible={...}>. */
430
+ confirmVisible: boolean;
431
+ /** Wire directly to the back button's onPress (GameActionChips/IconButton onBack/onPress). */
432
+ requestBack: () => void;
433
+ /** Wire directly to <ConfirmDialog onCancel={...}>. */
434
+ cancelBack: () => void;
435
+ }
436
+ declare function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation;
437
+
438
+ declare function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): ReturnType<typeof useAutoAlign>;
439
+
440
+ export { AchievementCatalogSection, AchievementRow, ActivityStatSection, type AlignResult, BaseSettingsDialog, type BaseSettingsDialogProps, BaseStatsScreen, ConfirmDialog, type ConfirmDialogProps, ContentGutter, ControlSchemePicker, type ControlSchemePickerProps, CornerActionButtons, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LOCKED_BADGE_COLOR, LabeledDropdown, type LabeledDropdownOption, MONO_FONT, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, type OverlayCardColors, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, StaggeredWord, type StaggeredWordProps, StatRow, StatSection, TriggerGaugeHost, type TriggerGaugeProps, UpdateDialog, type UpdateDialogProps, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, getOverlayCardColors, getStaggeredWordDuration, loadSkiaWeb, overlayStyles, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };