@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -34,6 +34,13 @@
34
34
  "import": "./dist/index.mjs",
35
35
  "require": "./dist/index.js"
36
36
  },
37
+ "./chrome": {
38
+ "react-native": "./src/chrome/index.ts",
39
+ "browser": "./src/chrome/index.ts",
40
+ "types": "./dist/chrome/index.d.ts",
41
+ "import": "./dist/chrome/index.mjs",
42
+ "require": "./dist/chrome/index.js"
43
+ },
37
44
  "./guide": {
38
45
  "react-native": "./src/guide/index.ts",
39
46
  "browser": "./src/guide/index.ts",
@@ -8,7 +8,7 @@ import { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
8
8
  import { MONO_FONT } from './fonts'
9
9
 
10
10
  // AchievementCatalogRow now comes straight from @tastic/achievements' own getAchievementCatalogRows
11
- // (added in the same 2026-09-18 achievements-scaffolding pass this component belongs to) — no
11
+ // (added in the same 2026-09-18 achievements-scaffolding pass this component belongs to) - no
12
12
  // longer duplicated locally now that package has actually published the export (confirmed the two
13
13
  // shapes match exactly before wiring this real, type-only peer dependency).
14
14
 
@@ -2,7 +2,7 @@ import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { StyleSheet, View } from 'react-native'
3
3
  import { Icon, ProgressBar, Text } from 'react-native-paper'
4
4
 
5
- // A neutral gray for a locked badge's own background — exported so a caller computing its own
5
+ // A neutral gray for a locked badge's own background - exported so a caller computing its own
6
6
  // badge color (unlocked ? tierColor : LOCKED_BADGE_COLOR, matching LightCycles' own achievements.tsx)
7
7
  // doesn't need to invent or duplicate this exact tone.
8
8
  export const LOCKED_BADGE_COLOR = 'rgba(128,128,128,0.3)'
@@ -11,28 +11,28 @@ interface Props {
11
11
  icon: string
12
12
  title: string
13
13
  description: string
14
- // The badge's own background — pass the achievement's tier color when unlocked, or
14
+ // The badge's own background - pass the achievement's tier color when unlocked, or
15
15
  // LOCKED_BADGE_COLOR (this package's own export, above) when it isn't; this component doesn't
16
16
  // infer that from unlockedLabel itself, since a locked-vs-unlocked badge color scheme is a
17
17
  // caller-level design choice (see checkColor below for the identical reasoning on the status
18
18
  // icon's own color).
19
19
  badgeColor: string
20
20
  // undefined = locked. A precomputed display string (e.g. "Unlocked 2 days ago"), not a raw
21
- // timestamp — this component has no opinion on date formatting or streak conventions, which vary
21
+ // timestamp - this component has no opinion on date formatting or streak conventions, which vary
22
22
  // per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
23
23
  unlockedLabel?: string
24
- // Color for the unlocked check-circle icon/badge glyph — typically the same tier color passed to
24
+ // Color for the unlocked check-circle icon/badge glyph - typically the same tier color passed to
25
25
  // badgeColor, kept as its own prop rather than reused internally so a caller can special-case it
26
26
  // independently if its own tier scheme ever needs to (matching LightCycles' own call site, which
27
27
  // does pass the identical value to both today).
28
28
  checkColor?: string
29
- // Only rendered while locked (unlockedLabel is undefined) — 0..1.
29
+ // Only rendered while locked (unlockedLabel is undefined) - 0..1.
30
30
  progress?: number
31
31
  // The "Same for every profile" note LightCycles shows for a device-scoped achievement viewed
32
- // from a specific profile's own tab — optional since only an app with per-profile stat views has
32
+ // from a specific profile's own tab - optional since only an app with per-profile stat views has
33
33
  // this concept at all.
34
34
  deviceMarker?: boolean
35
- // Independently optional overrides for this row's own text/background colors — each defaults to
35
+ // Independently optional overrides for this row's own text/background colors - each defaults to
36
36
  // the same dark-mode-derived formula as StatRow/StatSection/BaseStatsScreen (see that component's
37
37
  // own doc for why) when omitted, so every existing caller renders identically to before. A caller
38
38
  // with its own app-wide chrome palette passes whichever of the three it needs.
@@ -41,14 +41,14 @@ interface Props {
41
41
  sectionBg?: string
42
42
  }
43
43
 
44
- // One row per achievement — a tier-colored (or locked-gray) badge, title/description, an optional
44
+ // One row per achievement - a tier-colored (or locked-gray) badge, title/description, an optional
45
45
  // progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
46
46
  // verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
47
47
  // "ALL ACHIEVEMENTS" list. fg/fgMuted/its own section background default to the same
48
48
  // useAutoPaperTheme()-derived formula as StatRow/StatSection/BaseStatsScreen (see BaseStatsScreen's
49
49
  // own doc for why), each independently overridable via the matching prop for a caller with its own
50
50
  // chrome palette. Has no opinion on what an achievement actually IS (id, unlock predicate, catalog)
51
- // — that stays entirely app-local; this is purely the presentational row.
51
+ // - that stays entirely app-local; this is purely the presentational row.
52
52
  export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props) {
53
53
  const { dark } = useAutoPaperTheme()
54
54
  const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
@@ -16,9 +16,11 @@ interface SettingIconProps {
16
16
  containerColor: string
17
17
  }
18
18
 
19
- // Small colored badge per row (icon tinted on its own MD3 container color) — echoes the
19
+ // Small colored badge per row (icon tinted on its own MD3 container color) - echoes the
20
20
  // primary/secondary/tertiary triad that's already each consuming app's own identity, so the
21
- // settings list picks up that same palette instead of introducing new colors of its own.
21
+ // settings list picks up that same palette instead of introducing new colors of its own. Every
22
+ // on/off row (Lock Orientation, Edge Guard, Sound, Haptics) is tertiary while on, and an app adding
23
+ // its own toggle rows should match; secondary is for the always-on action row (How to Play).
22
24
  function SettingIcon({ source, color, containerColor }: SettingIconProps) {
23
25
  return (
24
26
  <View style={[styles.iconBadge, { backgroundColor: containerColor }]}>
@@ -36,50 +38,50 @@ export interface BaseSettingsDialogProps {
36
38
  // Defaults to a live ambient read via useRotation() when omitted. Pass 0 when the dialog already
37
39
  // renders under a Portal.Host inside a turned frame of the caller's own (Solitaire's screens).
38
40
  rotation?: ViewRotation
39
- // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
41
+ // Each app's own OTA version (e.g. release.otaVersion) - not something this package can read
40
42
  // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
41
43
  // since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not
42
- // a formatted string) — this just interpolates it into the VERSION label same as the original
44
+ // a formatted string) - this just interpolates it into the VERSION label same as the original
43
45
  // inline JSX did.
44
46
  version: string | number
45
- // Optional, and always passed as a pair — omit both entirely for a game that's portrait-only (or
47
+ // Optional, and always passed as a pair - omit both entirely for a game that's portrait-only (or
46
48
  // otherwise has no orientation-based reflow at all), where there's nothing to lock against.
47
49
  lockOrientation?: boolean
48
50
  onLockOrientationChange?: (value: boolean) => void
49
- // Optional, and always passed as a pair — omit both entirely for a game with no full-screen edge-
51
+ // Optional, and always passed as a pair - omit both entirely for a game with no full-screen edge-
50
52
  // anchored swipe controls (nothing to guard). iOS-only regardless of whether the pair is passed:
51
53
  // @tastic/edge-guard's own swizzle is a no-op on every other platform.
52
54
  deferBottomEdgeGestures?: boolean
53
55
  onDeferBottomEdgeGestures?: (value: boolean) => void
54
56
  // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
55
57
  // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
56
- // caller passes in — unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
58
+ // caller passes in - unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
57
59
  // these, so hiding one takes its own explicit flag instead. All default to shown, matching every
58
60
  // app's existing behavior before this prop existed.
59
61
  hideSound?: boolean
60
- // Haptics is already native-only (see the row's own Platform.OS check) — this hides it even
62
+ // Haptics is already native-only (see the row's own Platform.OS check) - this hides it even
61
63
  // there, for a game that's deliberately silent on vibration.
62
64
  hideHaptics?: boolean
63
65
  hideAppearance?: boolean
64
- // Check for Updates is already native-only (a web build has no OTA update concept at all) — this
66
+ // Check for Updates is already native-only (a web build has no OTA update concept at all) - this
65
67
  // hides it even there, for a native app that isn't wired up for EAS/OTA updates at all.
66
68
  hideUpdateCheck?: boolean
67
- // Surfaces a failed update check (e.g. via @rific/toaster's error()) — omit for an app with no
69
+ // Surfaces a failed update check (e.g. via @rific/toaster's error()) - omit for an app with no
68
70
  // toast system wired up; the check itself still runs and still reports success/no-update through
69
71
  // the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
70
72
  onUpdateError?: (message: string) => void
71
73
  // Omit to hide the row entirely (a game with no how-to-play flow, or a screen where replaying one
72
- // would be disruptive — e.g. over a live match). Called AFTER this dialog's own onDismiss, so the
74
+ // would be disruptive - e.g. over a live match). Called AFTER this dialog's own onDismiss, so the
73
75
  // two overlays never stack: wire it straight to @tastic/hud/guide's `useGuide().open`.
74
76
  onShowHowToPlay?: () => void
75
77
  // App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
76
- // rendered between Appearance and Check for Updates — this component only owns the settings
78
+ // rendered between Appearance and Check for Updates - this component only owns the settings
77
79
  // every game shares, never anything about how a particular game plays.
78
80
  children?: ReactNode
79
81
  }
80
82
 
81
- // The settings shell every @tastic game shares — orientation lock, the optional edge-guard toggle,
82
- // sound/haptics, appearance, and update checking — extracted after the same six rows were
83
+ // The settings shell every @tastic game shares - orientation lock, the optional edge-guard toggle,
84
+ // sound/haptics, appearance, and update checking - extracted after the same six rows were
83
85
  // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
84
86
  // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
85
87
  // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
@@ -89,7 +91,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
89
91
  const { colors } = useAutoPaperTheme()
90
92
  const ambientRotation = useRotation()
91
93
  const rotation = rotationOverride ?? ambientRotation
92
- // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
94
+ // Also gates the Lock Orientation row below (alongside showLockOrientation itself) - true
93
95
  // unconditionally on native (see the hook's own doc), so this only actually excludes a
94
96
  // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
95
97
  // against; a resize there would just make the setting silently stop reflowing the layout instead
@@ -98,21 +100,21 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
98
100
  const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings()
99
101
  const { settings: soundSettings, set: setSoundSettings } = useSoundSettings()
100
102
  // Sound/Haptics toggle themselves: the ripple's automatic press feedback fires on onPressIn,
101
- // before onPress applies the toggle, so it reflects the OLD enabled value — backwards from what
103
+ // before onPress applies the toggle, so it reflects the OLD enabled value - backwards from what
102
104
  // a settings toggle should confirm (turning off would click/buzz, turning on would go silent).
103
105
  // Both rows disable their own automatic channel below (soundDisabled/hapticDisabled) and fire it
104
106
  // manually here instead, gated on the NEW value so it only plays when switching that channel on.
105
107
  const sound = useContext(SoundContext)
106
108
  const { forceShort: forceHapticFeedback } = useVibration()
107
109
  // Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no
108
- // update found) via onInfo below — a retro card overlay instead of the native Alert.alert those
110
+ // update found) via onInfo below - a retro card overlay instead of the native Alert.alert those
109
111
  // cases fall back to by default.
110
112
  const [infoMessage, setInfoMessage] = useState<{ title: string; message: string } | null>(null)
111
- // autoCheck: false — every consuming app's own root layout already runs the background check via
113
+ // autoCheck: false - every consuming app's own root layout already runs the background check via
112
114
  // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
113
115
  // second AppState listener and double every foreground-resume update check. This instance only
114
116
  // ever checks on an explicit tap of the button below. Deliberately its own useUpdater() instance
115
- // rather than rendering UpdateDialog directly here, for the same reason — UpdateDialog owns a
117
+ // rather than rendering UpdateDialog directly here, for the same reason - UpdateDialog owns a
116
118
  // second AppState listener of its own.
117
119
  const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge()
118
120
  const { check, checking, updateReady } = useUpdater({
@@ -156,24 +158,24 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
156
158
  <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
157
159
  <Dialog.Title>Settings</Dialog.Title>
158
160
  {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
159
- reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
161
+ reserve room for a Dialog.Actions row below it) - overridden to 0 since this dialog has
160
162
  no actions row, and that gap otherwise reads as unexplained empty footer space. */}
161
163
  <Dialog.ScrollArea style={styles.scrollArea}>
162
164
  <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
163
165
  {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
164
- between sections below) — same bare-row shape for all of them, no segmented control
166
+ between sections below) - same bare-row shape for all of them, no segmented control
165
167
  and no section heading (each row's own label already says what it is). Haptics only
166
- joins on native — there's nothing for it to control on web. */}
168
+ joins on native - there's nothing for it to control on web. */}
167
169
  <View style={styles.toggleGroup}>
168
170
  {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
169
- passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
170
- browser even when they are — see isTouchPrimary's own doc above. Still shown on
171
+ passed at all - nothing to lock), and hidden (not just disabled) on a desktop/laptop
172
+ browser even when they are - see isTouchPrimary's own doc above. Still shown on
171
173
  native and on a touch-capable mobile browser, where a rotated window really does
172
174
  reflect a rotated device. */}
173
175
  {showLockOrientation && (
174
176
  <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
175
177
  <View style={styles.toggleContent}>
176
- <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
178
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.tertiary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.tertiaryContainer : colors.surfaceVariant} />
177
179
  <View style={styles.flexShrink}>
178
180
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
179
181
  Lock Orientation
@@ -186,8 +188,8 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
186
188
  </TouchableRipple>
187
189
  )}
188
190
 
189
- {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
190
- swizzle to read. Guards both edges — the top (Notification Center/Control Center)
191
+ {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin
192
+ swizzle to read. Guards both edges - the top (Notification Center/Control Center)
191
193
  and the bottom (home-indicator swipe, incidentally Reachability too), per that
192
194
  package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
193
195
  accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
@@ -195,7 +197,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
195
197
  {showEdgeGuard && (
196
198
  <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
197
199
  <View style={styles.toggleContent}>
198
- <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
200
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.tertiary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.tertiaryContainer : colors.surfaceVariant} />
199
201
  <View style={styles.flexShrink}>
200
202
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
201
203
  Edge Guard
@@ -208,12 +210,12 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
208
210
  </TouchableRipple>
209
211
  )}
210
212
 
211
- {/* Side by side, not stacked — neither needs a full row's width (single-line label,
213
+ {/* Side by side, not stacked - neither needs a full row's width (single-line label,
212
214
  no description under it the way Lock Orientation has), so sharing one row reads
213
215
  just as clearly and takes half the vertical space. toggleRow carries the same
214
216
  edge-alignment negative margin toggleButton normally carries itself; the buttons
215
217
  inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
216
- The row itself only renders when at least one of the two will — an empty flex row
218
+ The row itself only renders when at least one of the two will - an empty flex row
217
219
  still eats its own gap/margin otherwise. */}
218
220
  {(showSound || showHaptics) && (
219
221
  <View style={styles.toggleRow}>
@@ -237,7 +239,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
237
239
  </TouchableRipple>
238
240
  )}
239
241
 
240
- {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
242
+ {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
241
243
  it there too, for a game that's deliberately silent on vibration. */}
242
244
  {showHaptics && (
243
245
  <TouchableRipple
@@ -262,7 +264,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
262
264
  )}
263
265
 
264
266
  {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
265
- down in `children` (which sits below Appearance) — this is the "replay the intro"
267
+ down in `children` (which sits below Appearance) - this is the "replay the intro"
266
268
  entry point every game's own how-to-play flow shares. Dismisses this dialog first so
267
269
  the guide's own overlay never opens on top of a still-visible Settings card. */}
268
270
  {onShowHowToPlay && (
@@ -301,7 +303,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
301
303
  {children}
302
304
 
303
305
  {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
304
- alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
306
+ alike) - hideUpdateCheck can hide it there too, for a native app that isn't wired up
305
307
  for EAS/OTA updates at all. */}
306
308
  {showUpdateCheck && (
307
309
  <View style={styles.section}>
@@ -329,7 +331,7 @@ const styles = StyleSheet.create({
329
331
  gap: 24,
330
332
  paddingVertical: 20
331
333
  },
332
- // Caps the card so it never grows past the screen — without this the dialog just keeps
334
+ // Caps the card so it never grows past the screen - without this the dialog just keeps
333
335
  // growing to fit its content and the overflow gets clipped by the screen edge, which is
334
336
  // what happened in landscape where there's less height to work with. Dialog.ScrollArea +
335
337
  // ScrollView below then take over and let the content scroll within that bound.
@@ -7,22 +7,22 @@ import { Text } from 'react-native-paper'
7
7
 
8
8
  import { ConfirmDialog } from './ConfirmDialog'
9
9
 
10
- // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
10
+ // react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
11
11
  // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point) just
12
12
  // to type one optional prop.
13
13
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
14
14
 
15
15
  interface Props {
16
- // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
16
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) - only
17
17
  // affects this component's own Portal-rendered reset-confirm dialog (via ConfirmDialog's own
18
18
  // rotation prop), which (like any Portal content) is unaffected by whichever screen's own
19
19
  // FakeLandscapeView wraps the trigger and so has to rotate itself. Defaults to a live ambient
20
- // read via useRotation() when omitted — note this does NOT make the header/scrollable content
20
+ // read via useRotation() when omitted - note this does NOT make the header/scrollable content
21
21
  // below rotate; the caller's own screen still needs its own FakeLandscapeView wrap for that
22
22
  // (this component has no transform on its own main body).
23
23
  rotation?: ViewRotation
24
24
  title?: string
25
- // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
25
+ // 'headlineSmall' (this screen's own default, below) - was originally 'displaySmall' (the full
26
26
  // display tier's own smallest step), then 'headlineLarge', both still read as oversized for a
27
27
  // header that's paired with a small 24px back-button glyph rather than standing alone the way a
28
28
  // title screen's own wordmark does; headlineSmall sits close to that icon's own size instead of
@@ -31,7 +31,7 @@ interface Props {
31
31
  titleVariant?: MD3TextVariant
32
32
  onBack: () => void
33
33
  insets: { top: number; left: number; bottom: number; right: number }
34
- // Optional, and always passed as a pair — a caller with nothing resettable (or that deliberately
34
+ // Optional, and always passed as a pair - a caller with nothing resettable (or that deliberately
35
35
  // doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
36
36
  // is caller-owned since "erases all stats and achievements" is only accurate for a caller that
37
37
  // actually tracks both; a caller with only high scores, say, would want its own wording.
@@ -39,7 +39,7 @@ interface Props {
39
39
  resetLabel?: string
40
40
  resetConfirmTitle?: string
41
41
  resetConfirmBody?: string
42
- // Independently optional overrides for this screen's own header/background colors — each
42
+ // Independently optional overrides for this screen's own header/background colors - each
43
43
  // defaults to the literal-black/white-by-appearance formula described below when omitted, so
44
44
  // every existing caller (none of which pass these) renders identically to before. A caller with
45
45
  // its own app-wide chrome palette (e.g. a tinted background rather than neutral black/white)
@@ -49,10 +49,10 @@ interface Props {
49
49
  // reset-confirm dialog's own icon/title/card. Both are gone: the reset-confirm step used to be a
50
50
  // hand-rolled lookalike of ConfirmDialog with its own copy of the overlay/card/button styling,
51
51
  // which is exactly how it drifted from ConfirmDialog's real look in the first place (different
52
- // button layout, different Cancel styling, and — via cardBg — a different, caller-tintable card
52
+ // button layout, different Cancel styling, and - via cardBg - a different, caller-tintable card
53
53
  // background no other ConfirmDialog in the fleet has). It now renders a real ConfirmDialog
54
54
  // instance below instead of its own copy, which by construction can never drift from every other
55
- // confirm prompt in the app again — at the cost of losing that per-caller tinting, which is the
55
+ // confirm prompt in the app again - at the cost of losing that per-caller tinting, which is the
56
56
  // right trade: a "you're about to erase everything" prompt should look and read identically
57
57
  // everywhere, not take on whichever screen happened to trigger it.
58
58
  fg?: string
@@ -60,15 +60,15 @@ interface Props {
60
60
  children: ReactNode
61
61
  }
62
62
 
63
- // The stats/achievements screen shell every @tastic game shares — back button + title header,
63
+ // The stats/achievements screen shell every @tastic game shares - back button + title header,
64
64
  // scrollable content area, and an optional reset-everything action with its own confirm-before-
65
- // destroying dialog — extracted after the same header/scroll/reset-confirm shape was independently
65
+ // destroying dialog - extracted after the same header/scroll/reset-confirm shape was independently
66
66
  // built into LightCycles' own achievements.tsx. Colors default to the same literal-black/white-by-
67
67
  // appearance formula BaseSettingsDialog hardcodes internally (same as this package's own
68
- // BaseSettingsDialog) — zero-config for every existing caller — but fg/bg are each independently
68
+ // BaseSettingsDialog) - zero-config for every existing caller - but fg/bg are each independently
69
69
  // overridable for a caller whose own app-wide chrome palette isn't that literal black/white
70
70
  // convention. `children` is the seam for whatever isn't shared: this component has no opinion on
71
- // what a "stat" is or how achievements are catalogued — see StatRow/StatSection/AchievementRow for
71
+ // what a "stat" is or how achievements are catalogued - see StatRow/StatSection/AchievementRow for
72
72
  // the smaller presentational pieces built to go inside it.
73
73
  export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, children }: Props) {
74
74
  const { dark, colors } = useAutoPaperTheme()
@@ -139,7 +139,7 @@ const styles = StyleSheet.create({
139
139
  resetButton: {
140
140
  marginTop: 16
141
141
  },
142
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it — without this, a
142
+ // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
143
143
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
144
144
  // can render at its full unclipped content height instead of the screen's), which leaves the
145
145
  // header's back button touch target unreliable underneath it.
@@ -76,14 +76,14 @@ const styles = StyleSheet.create({
76
76
  textAlign: 'center'
77
77
  },
78
78
  // Additive on top of overlayCard's own shared `card` shape (borderRadius/borderWidth/gap/maxWidth/
79
- // padding) — claims its full allowance instead of just maxWidth alone, which only caps how wide the
79
+ // padding) - claims its full allowance instead of just maxWidth alone, which only caps how wide the
80
80
  // card is allowed to get - without an explicit width, a flex-centered child sizes to its own content
81
81
  // instead of stretching, so the card (and therefore actions below) can end up narrower than the
82
82
  // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
83
83
  card: {
84
84
  width: '100%'
85
85
  },
86
- // Additive on top of overlayCard's own shared `overlay` backdrop — gives the card room to breathe
86
+ // Additive on top of overlayCard's own shared `overlay` backdrop - gives the card room to breathe
87
87
  // from the screen edges now that it reliably stretches to fill its full width allowance (see
88
88
  // card's own width comment) - without this, width: '100%' would run the card edge-to-edge on a
89
89
  // screen narrower than maxWidth's own 360.
@@ -11,7 +11,7 @@ export interface ControlSchemePickerProps<T extends string | number> {
11
11
  value: T
12
12
  onChange: (value: T) => void
13
13
  options: MenuOption<T>[]
14
- // Another seat's currently-selected scheme, if any — passed straight through as the underlying
14
+ // Another seat's currently-selected scheme, if any - passed straight through as the underlying
15
15
  // section's own takenValue (see SectionedDropdown's own doc: stays in the list, shown disabled,
16
16
  // rather than filtered out). Two local seats sharing one physical keyboard (and, depending on your
17
17
  // own option set, one mouse) can't both be assigned the same scheme at once; this is what a caller
@@ -22,11 +22,11 @@ export interface ControlSchemePickerProps<T extends string | number> {
22
22
  onAccentColor?: string
23
23
  dark: boolean
24
24
  align?: 'left' | 'right' | 'center'
25
- // Passed straight through to the underlying SectionedDropdown — see that component's own
25
+ // Passed straight through to the underlying SectionedDropdown - see that component's own
26
26
  // `alignOverride` doc. Lets a caller inside a @tastic/split-screen zone substitute
27
27
  // useZoneClampedAlign's result for this picker's own popover placement.
28
28
  alignOverride?: AlignResult
29
- // Passed straight through to the underlying SectionedDropdown — see that component's own
29
+ // Passed straight through to the underlying SectionedDropdown - see that component's own
30
30
  // `rotation` doc. Defaults to a live ambient useRotation() read when omitted, same as
31
31
  // SectionedDropdown itself.
32
32
  rotation?: PopoverRotation
@@ -35,23 +35,23 @@ export interface ControlSchemePickerProps<T extends string | number> {
35
35
 
36
36
  // A single-select SectionedDropdown, opinionated for exactly one job: letting a local seat pick
37
37
  // which physical input drives it in a couch-multiplayer game. Fixes the trigger icon and
38
- // accessibility label so every game's control-scheme picker looks and reads the same, and — the
38
+ // accessibility label so every game's control-scheme picker looks and reads the same, and - the
39
39
  // actual reason this exists as its own component rather than a usage snippet in each app's own
40
- // docs — bakes in the "does this even mean anything right now" gate every consumer was independently
40
+ // docs - bakes in the "does this even mean anything right now" gate every consumer was independently
41
41
  // re-deriving: a physical keyboard scheme has no meaning on a touch-primary device (there's nothing
42
42
  // to pick a *keyboard* layout for), and this repo's own components target React Native Web, where
43
43
  // `Platform.OS !== 'web'` means native/touch already, so keying off `useIsTouchPrimaryDevice` alone
44
- // isn't enough on its own — a native touch device still needs the `Platform.OS` check to be excluded
44
+ // isn't enough on its own - a native touch device still needs the `Platform.OS` check to be excluded
45
45
  // the same way a desktop browser needs the touch check. Renders nothing at all when either
46
46
  // condition fails, rather than a disabled/empty picker, so a caller can mount this unconditionally
47
47
  // inside its own per-seat layout (a `View` row alongside a color picker, say) without its own
48
- // `Platform`/`useIsTouchPrimaryDevice` branch — an always-rendered-but-sometimes-null child is a
48
+ // `Platform`/`useIsTouchPrimaryDevice` branch - an always-rendered-but-sometimes-null child is a
49
49
  // no-op in a flex layout, so the surrounding row collapses to just its other children automatically.
50
50
  //
51
51
  // Deliberately generic over `T` (matching SectionedDropdown's own `MenuOption<T>`) rather than
52
- // hardcoding a fixed scheme union: this repo has no opinion on which schemes exist — the raw
52
+ // hardcoding a fixed scheme union: this repo has no opinion on which schemes exist - the raw
53
53
  // physical-key layouts (wasd/arrows/ijkl/numpad) live in `@tastic/input`, and any extra values with
54
- // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
54
+ // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label - so a
55
55
  // caller always supplies its own `options` (and typically its own `T`) rather than this component
56
56
  // importing `@tastic/input` and choosing a set for every consumer.
57
57
  export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>) {
@@ -4,29 +4,29 @@ import { StyleSheet } from 'react-native'
4
4
  interface Props {
5
5
  onBack: () => void
6
6
  onSettings: () => void
7
- // Full-contrast foreground — same convention as every other component keying off a `dark` prop
7
+ // Full-contrast foreground - same convention as every other component keying off a `dark` prop
8
8
  // (BaseSettingsDialog, SectionedDropdown, etc.), just resolved by the caller since this component
9
9
  // never reads theme state itself.
10
10
  fg: string
11
11
  // Safe-area insets already remapped onto the screen's real visual edges (see
12
- // @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
12
+ // @tastic/split-screen's rotateInsets) - this component just adds the fixed 8px margin on top,
13
13
  // it doesn't know anything about physical device orientation itself.
14
14
  insets: { top: number; left: number; right: number }
15
15
  // Overrides the top-left button's icon (a react-native-paper/MaterialCommunityIcons name) and its
16
- // accessibility label — default 'arrow-left'/'Back'. Not every screen's top-left control is
16
+ // accessibility label - default 'arrow-left'/'Back'. Not every screen's top-left control is
17
17
  // actually "undo the last navigation": a flat menu/detail screen with nothing deeper to pop back
18
18
  // through often wants a literal home/menu affordance driving an absolute navigation instead (see
19
19
  // e.g. Solitaire's GameChrome, whose onBack navigates straight to its root route). Pass both
20
- // together — an icon without a matching label (or vice versa) misleads screen readers/tooltips.
20
+ // together - an icon without a matching label (or vice versa) misleads screen readers/tooltips.
21
21
  backIcon?: string
22
22
  backLabel?: string
23
23
  }
24
24
 
25
- // Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
25
+ // Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners - the
26
26
  // default reachability answer for a two-player screen: correct any time both players share one
27
27
  // screen but only one of them (or neither, in vs-CPU) is on the far side of a rotated zone. Not the
28
28
  // right answer for a screen where the top corners themselves fall *inside* one player's own rotated
29
- // zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
29
+ // zone (two-player face-to-face) - see SharedActionBand for that case instead, which callers select
30
30
  // between based on their own game-mode/orientation state (this component has no opinion on which).
31
31
  export function CornerActionButtons({ onBack, onSettings, fg, insets, backIcon = 'arrow-left', backLabel = 'Back' }: Props) {
32
32
  return (
@@ -1,7 +1,7 @@
1
1
  import { StyleSheet, View } from 'react-native'
2
2
  import { Icon } from 'react-native-paper'
3
3
 
4
- // Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported —
4
+ // Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported -
5
5
  // this package has never taken a dependency on @tastic/split-screen; every orientation/rotation-
6
6
  // derived value it touches (ConfirmDialog's and BaseSettingsDialog's own `rotation` props, this
7
7
  // component's orientationMode/p1OnRight below) is always a plain value the caller already resolved
@@ -9,7 +9,7 @@ import { Icon } from 'react-native-paper'
9
9
  export type OrientationMode = 'faceToFace' | 'sideBySide'
10
10
 
11
11
  export interface CornerStatusBadgeSeat {
12
- // Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty — a
12
+ // Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty - a
13
13
  // seat with nothing to show right now is different from no seat at all (see `p1`/`p2` on
14
14
  // CornerStatusBadgesProps below), and the empty badge is itself the answer ("nothing"), not a
15
15
  // reason to hide the corner.
@@ -18,11 +18,11 @@ export interface CornerStatusBadgeSeat {
18
18
 
19
19
  export interface CornerStatusBadgesProps {
20
20
  orientationMode: OrientationMode
21
- // Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight — only
21
+ // Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight - only
22
22
  // meaningful when orientationMode is 'sideBySide'; ignored in 'faceToFace', where seat 1 always
23
23
  // owns the bottom half of a shared screen and seat 2 the top half, regardless of physical rotation.
24
24
  p1OnRight: boolean
25
- // Independently optional — omit a seat entirely to skip rendering its badge at all (e.g. a solo
25
+ // Independently optional - omit a seat entirely to skip rendering its badge at all (e.g. a solo
26
26
  // mode with no second seat to show anything for). Pass an object (even `{}`) to render that
27
27
  // seat's badge with nothing in it.
28
28
  p1?: CornerStatusBadgeSeat
@@ -31,21 +31,21 @@ export interface CornerStatusBadgesProps {
31
31
 
32
32
  const BADGE_SIZE = 30
33
33
 
34
- // Deliberately understated (small, low-opacity, no per-seat color) — a quiet corner landmark, not
34
+ // Deliberately understated (small, low-opacity, no per-seat color) - a quiet corner landmark, not
35
35
  // something that should compete for attention with whatever's on screen underneath it.
36
36
  function StatusBadge({ icon }: { icon?: string }) {
37
37
  return <View style={styles.badge}>{icon && <Icon source={icon} size={16} color='rgba(255,255,255,0.85)' />}</View>
38
38
  }
39
39
 
40
40
  // Generic per-seat corner status badge: at most one caller-supplied icon per seat (or none), each
41
- // pinned to that seat's own screen corner. Has zero opinion on what the icon means — originally
41
+ // pinned to that seat's own screen corner. Has zero opinion on what the icon means - originally
42
42
  // extracted from LightCycles' PowerupHud.tsx and Snake's SnakePowerupHud.tsx, which both used this
43
43
  // exact positioning and styling to reveal a held power-up, but this component only ever sees a
44
44
  // resolved icon name (or undefined) per seat, never the domain value behind it, so it's equally
45
45
  // suited to any other per-seat status a caller wants glanceable in a screen corner (connection
46
46
  // state, a turn-order marker, anything that's a single icon or nothing). Corner picked per seat
47
- // from orientationMode/p1OnRight — the same "is this seat on the right" convention
48
- // @tastic/split-screen's own consumers already use for zone/rotation decisions — so each badge
47
+ // from orientationMode/p1OnRight - the same "is this seat on the right" convention
48
+ // @tastic/split-screen's own consumers already use for zone/rotation decisions - so each badge
49
49
  // always sits in that seat's own zone rather than a side fixed regardless of which way the device
50
50
  // is being held. In 'faceToFace', column doesn't matter (each seat's zone spans the full width):
51
51
  // seat 1 stays bottom, seat 2 stays top, regardless of p1OnRight.