@tastic/hud 0.16.0 → 0.18.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/dist/index.mjs CHANGED
@@ -17,13 +17,19 @@ import {
17
17
  StatRow,
18
18
  StatSection,
19
19
  UpdateDialog,
20
- getStaggeredWordDuration
21
- } from "./chunk-JXRBXQLQ.mjs";
20
+ getStaggeredWordDuration,
21
+ useCompactDialog
22
+ } from "./chunk-Z2WUUYTE.mjs";
22
23
  import {
23
- getOverlayCardColors,
24
+ COMPACT_DIALOG_HEIGHT,
25
+ DialogHeader,
26
+ OverlayBackdrop,
27
+ OverlayCard,
28
+ isCompactDialogHeight,
24
29
  overlayActionStyles,
25
- overlayStyles
26
- } from "./chunk-32U2OIRW.mjs";
30
+ overlayStyles,
31
+ useOverlayCardColors
32
+ } from "./chunk-PUF6TABL.mjs";
27
33
 
28
34
  // src/AchievementUnlockList.tsx
29
35
  import { getContrastColor, useAutoPaperTheme } from "@rific/auto-paper";
@@ -148,7 +154,7 @@ var styles2 = StyleSheet2.create({
148
154
 
149
155
  // src/TriggerGaugeHost.tsx
150
156
  import { jsx as jsx3 } from "react/jsx-runtime";
151
- var getTriggerGauge = () => import("./TriggerGauge-2OJOOF6I.mjs").then((mod) => ({ default: mod.TriggerGauge }));
157
+ var getTriggerGauge = () => import("./TriggerGauge-GIHKGAZE.mjs").then((mod) => ({ default: mod.TriggerGauge }));
152
158
  function TriggerGaugeHost(props) {
153
159
  return /* @__PURE__ */ jsx3(SkiaGate, { getComponent: getTriggerGauge, componentProps: props });
154
160
  }
@@ -874,16 +880,20 @@ export {
874
880
  ActivityStatSection,
875
881
  BaseSettingsDialog,
876
882
  BaseStatsScreen,
883
+ COMPACT_DIALOG_HEIGHT,
877
884
  ConfirmDialog,
878
885
  ContentGutter,
879
886
  ControlSchemePicker,
880
887
  CornerActionButtons,
881
888
  CornerStatusBadges,
889
+ DialogHeader,
882
890
  InlineColorPicker,
883
891
  LABELED_DROPDOWN_POPOVER_WIDTH,
884
892
  LOCKED_BADGE_COLOR,
885
893
  LabeledDropdown,
886
894
  MONO_FONT,
895
+ OverlayBackdrop,
896
+ OverlayCard,
887
897
  PlayerSetupPanel,
888
898
  PopoverBody,
889
899
  PressAwayOverlay,
@@ -901,12 +911,14 @@ export {
901
911
  getCpuDifficultyPopoverId,
902
912
  getInlineColorPickerContentSize,
903
913
  getLabeledDropdownContentHeight,
904
- getOverlayCardColors,
905
914
  getStaggeredWordDuration,
915
+ isCompactDialogHeight,
906
916
  loadSkiaWeb,
907
917
  overlayActionStyles,
908
918
  overlayStyles,
909
919
  useAutoAlign,
920
+ useCompactDialog,
921
+ useOverlayCardColors,
910
922
  usePopoverHost,
911
923
  useQuitConfirmation,
912
924
  useZoneClampedAlign
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -87,14 +87,14 @@
87
87
  "@infinitetoken/eslint-config": "^0.2.0",
88
88
  "@infinitetoken/jest-config": "^0.4.0",
89
89
  "@infinitetoken/tsconfig": "^0.5.0",
90
- "@rific/auto-paper": "^0.12.3",
91
- "@rific/core": "^0.2.0",
92
- "@rific/feedback-press": "^0.12.0",
90
+ "@rific/auto-paper": "^0.13.0",
91
+ "@rific/core": "^0.3.0",
92
+ "@rific/feedback-press": "^0.13.0",
93
93
  "@rific/updater": "^0.6.0",
94
94
  "@shopify/react-native-skia": "^2.6.2",
95
- "@tastic/achievements": "^0.2.0",
96
- "@tastic/core": "^0.12.0",
97
- "@tastic/profile": "^0.7.0",
95
+ "@tastic/achievements": "^0.5.0",
96
+ "@tastic/core": "^0.13.0",
97
+ "@tastic/profile": "^0.12.0",
98
98
  "@tastic/split-screen": "^0.5.0",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
@@ -108,7 +108,7 @@
108
108
  "prettier": "^3.8.3",
109
109
  "react": "^19.0.0",
110
110
  "react-dom": "^19.0.0",
111
- "react-native": "^0.85.3",
111
+ "react-native": "^0.86.3",
112
112
  "react-native-paper": "^5.15.2",
113
113
  "react-native-reanimated": "^4.5.1",
114
114
  "react-native-safe-area-context": "^5.10.0",
@@ -116,10 +116,10 @@
116
116
  "typescript": "^6.0.3"
117
117
  },
118
118
  "peerDependencies": {
119
- "@rific/auto-paper": ">=0.12.2",
119
+ "@rific/auto-paper": "^0.13.0",
120
120
  "@rific/feedback-press": ">=0.10.2",
121
121
  "@rific/updater": ">=0.4.0",
122
- "@shopify/react-native-skia": ">=1.5.0",
122
+ "@shopify/react-native-skia": ">=2.6.0",
123
123
  "@tastic/achievements": ">=0.2.0",
124
124
  "@tastic/core": ">=0.12.0",
125
125
  "@tastic/profile": ">=0.4.0",
@@ -1,12 +1,15 @@
1
- import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { AutoAppearancePicker, Dialog, FadingScrollView, useAutoPaperTheme, useBlurModule, useThemeSettings } from '@rific/auto-paper'
2
2
  import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
3
  import { useUpdater } from '@rific/updater'
4
- import { useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
4
+ import { rotateInsets, useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
5
5
  import { ReactNode, useContext, useState } from 'react'
6
- import { Platform, ScrollView, StyleSheet, View } from 'react-native'
6
+ import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
7
7
  import { Icon, Portal, Text } from 'react-native-paper'
8
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
8
9
 
9
10
  import { ConfirmDialog } from './ConfirmDialog'
11
+ import { DialogHeader, isCompactDialogHeight } from './DialogHeader'
12
+ import { overlayActionStyles, overlayStyles } from './overlayCard'
10
13
  import { RotatedFrame } from './RotatedFrame'
11
14
  import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
12
15
 
@@ -91,6 +94,18 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
91
94
  const { colors } = useAutoPaperTheme()
92
95
  const ambientRotation = useRotation()
93
96
  const rotation = rotationOverride ?? ambientRotation
97
+ // The sheet lays out in a RotatedFrame turned by `rotation` (below), so its height is the window's
98
+ // width when turned a quarter: the same compact rule as every other dialog's header.
99
+ const window = useWindowDimensions()
100
+ const turned = rotation === 90 || rotation === -90
101
+ const compactHeader = isCompactDialogHeight(turned ? window.width : window.height)
102
+ // The overlay cards' width (ConfirmDialog, the guide, every app's result card): 24pt from the
103
+ // frame's edges, never under a notch, capped at their 360 (overlayStyles.card). auto-paper's Dialog
104
+ // would give the sheet a 32pt margin and a 400 cap, 16pt narrower than a card on a phone, which
105
+ // showed when Settings and a confirm opened one after the other. The card is centered, so the
106
+ // Dialog's own 32pt padding is simply overhung.
107
+ const insets = rotateInsets(useSafeAreaInsets(), rotation)
108
+ const sheetWidth = Math.min(overlayStyles.card.maxWidth, (turned ? window.height : window.width) - 2 * Math.max(insets.left, insets.right, SHEET_GUTTER))
94
109
  // Also gates the Lock Orientation row below (alongside showLockOrientation itself) - true
95
110
  // unconditionally on native (see the hook's own doc), so this only actually excludes a
96
111
  // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
@@ -106,6 +121,8 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
106
121
  // manually here instead, gated on the NEW value so it only plays when switching that channel on.
107
122
  const sound = useContext(SoundContext)
108
123
  const { forceShort: forceHapticFeedback } = useVibration()
124
+ const { settings: themeSettings, set: setThemeSettings } = useThemeSettings()
125
+ const expoBlur = useBlurModule()
109
126
  // Covers check()'s three purely-informational cases (dev-mode disabled, web unsupported, no
110
127
  // update found) via onInfo below - a retro card overlay instead of the native Alert.alert those
111
128
  // cases fall back to by default.
@@ -139,6 +156,8 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
139
156
  const showHaptics = Platform.OS !== 'web' && !hideHaptics
140
157
  const showSound = !hideSound
141
158
  const showAppearance = !hideAppearance
159
+ // See the Blur row's own comment below for why it hides on Android and without expo-blur.
160
+ const showBlur = showAppearance && Platform.OS !== 'android' && expoBlur !== undefined && expoBlur !== null
142
161
  const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
143
162
 
144
163
  // Laid out in its own turned frame rather than rotated in place. Paper's Portal mounts at the app
@@ -155,169 +174,205 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
155
174
  return (
156
175
  <Portal>
157
176
  <RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
158
- <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
159
- <Dialog.Title>Settings</Dialog.Title>
177
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, { width: sheetWidth }]}>
160
178
  {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
161
- reserve room for a Dialog.Actions row below it) - overridden to 0 since this dialog has
162
- no actions row, and that gap otherwise reads as unexplained empty footer space. */}
179
+ reserve room for a Dialog.Actions row below it) - overridden to 0: the Done row below
180
+ carries its own spacing. It is the sheet's first child, so it runs to the card's top edge. */}
163
181
  <Dialog.ScrollArea style={styles.scrollArea}>
164
- <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
165
- {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
182
+ <FadingScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
183
+ {/* The fleet's one dialog header and Done button (DialogHeader, overlayActionStyles' single
184
+ button), so the sheet reads as one family with ConfirmDialog, the guide and every app's
185
+ result cards (Jay, 2026-10-15). The header scrolls away with the rows, so a long sheet
186
+ gives them the whole card once scrolled; Done stays pinned below. Tapping outside still
187
+ closes it too. */}
188
+ <DialogHeader icon='cog' title='Settings' compact={compactHeader} />
189
+ <View style={styles.content}>
190
+ {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
166
191
  between sections below) - same bare-row shape for all of them, no segmented control
167
192
  and no section heading (each row's own label already says what it is). Haptics only
168
193
  joins on native - there's nothing for it to control on web. */}
169
- <View style={styles.toggleGroup}>
170
- {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
194
+ <View style={styles.toggleGroup}>
195
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
171
196
  passed at all - nothing to lock), and hidden (not just disabled) on a desktop/laptop
172
197
  browser even when they are - see isTouchPrimary's own doc above. Still shown on
173
198
  native and on a touch-capable mobile browser, where a rotated window really does
174
199
  reflect a rotated device. */}
175
- {showLockOrientation && (
176
- <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
177
- <View style={styles.toggleContent}>
178
- <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.tertiary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.tertiaryContainer : colors.surfaceVariant} />
179
- <View style={styles.flexShrink}>
180
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
181
- Lock Orientation
182
- </Text>
183
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
184
- Pins the current layout
185
- </Text>
200
+ {showLockOrientation && (
201
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
202
+ <View style={styles.toggleContent}>
203
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.tertiary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.tertiaryContainer : colors.surfaceVariant} />
204
+ <View style={styles.flexShrink}>
205
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
206
+ Lock Orientation
207
+ </Text>
208
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
209
+ Pin current layout
210
+ </Text>
211
+ </View>
186
212
  </View>
187
- </View>
188
- </TouchableRipple>
189
- )}
213
+ </TouchableRipple>
214
+ )}
190
215
 
191
- {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin
216
+ {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin
192
217
  swizzle to read. Guards both edges - the top (Notification Center/Control Center)
193
218
  and the bottom (home-indicator swipe, incidentally Reachability too), per that
194
219
  package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
195
220
  accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
196
221
  surprise every player gets by default. */}
197
- {showEdgeGuard && (
198
- <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
199
- <View style={styles.toggleContent}>
200
- <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.tertiary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.tertiaryContainer : colors.surfaceVariant} />
201
- <View style={styles.flexShrink}>
202
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
203
- Edge Guard
204
- </Text>
205
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
206
- Requires a second swipe
207
- </Text>
222
+ {showEdgeGuard && (
223
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
224
+ <View style={styles.toggleContent}>
225
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.tertiary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.tertiaryContainer : colors.surfaceVariant} />
226
+ <View style={styles.flexShrink}>
227
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
228
+ Edge Guard
229
+ </Text>
230
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
231
+ Require second swipe
232
+ </Text>
233
+ </View>
208
234
  </View>
209
- </View>
210
- </TouchableRipple>
211
- )}
235
+ </TouchableRipple>
236
+ )}
212
237
 
213
- {/* Side by side, not stacked - neither needs a full row's width (single-line label,
238
+ {/* Side by side, not stacked - neither needs a full row's width (single-line label,
214
239
  no description under it the way Lock Orientation has), so sharing one row reads
215
240
  just as clearly and takes half the vertical space. toggleRow carries the same
216
241
  edge-alignment negative margin toggleButton normally carries itself; the buttons
217
242
  inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
218
243
  The row itself only renders when at least one of the two will - an empty flex row
219
244
  still eats its own gap/margin otherwise. */}
220
- {(showSound || showHaptics) && (
221
- <View style={styles.toggleRow}>
222
- {showSound && (
223
- <TouchableRipple
224
- soundDisabled
225
- onPress={() => {
226
- const enabled = !soundSettings.enabled
227
- setSoundSettings({ enabled })
228
- if (enabled) sound.selection?.()
229
- }}
230
- style={styles.toggleButtonInRow}
231
- accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
232
- >
233
- <View style={styles.toggleContent}>
234
- <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
245
+ {(showSound || showHaptics) && (
246
+ <View style={styles.toggleRow}>
247
+ {showSound && (
248
+ <TouchableRipple
249
+ soundDisabled
250
+ onPress={() => {
251
+ const enabled = !soundSettings.enabled
252
+ setSoundSettings({ enabled })
253
+ if (enabled) sound.selection?.()
254
+ }}
255
+ style={styles.toggleButtonInRow}
256
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
257
+ >
258
+ <View style={styles.toggleContent}>
259
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
260
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
261
+ Sound
262
+ </Text>
263
+ </View>
264
+ </TouchableRipple>
265
+ )}
266
+
267
+ {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
268
+ it there too, for a game that's deliberately silent on vibration. */}
269
+ {showHaptics && (
270
+ <TouchableRipple
271
+ hapticDisabled
272
+ onPress={() => {
273
+ const vibrate = !hapticSettings.vibrate
274
+ setHapticSettings({ vibrate })
275
+ if (vibrate) forceHapticFeedback()
276
+ }}
277
+ style={styles.toggleButtonInRow}
278
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
279
+ >
280
+ <View style={styles.toggleContent}>
281
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
282
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
283
+ Haptics
284
+ </Text>
285
+ </View>
286
+ </TouchableRipple>
287
+ )}
288
+ </View>
289
+ )}
290
+
291
+ {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
292
+ down in `children` (which sits below Appearance) - this is the "replay the intro"
293
+ entry point every game's own how-to-play flow shares. Dismisses this dialog first so
294
+ the guide's own overlay never opens on top of a still-visible Settings card. */}
295
+ {onShowHowToPlay && (
296
+ <TouchableRipple
297
+ onPress={() => {
298
+ onDismiss()
299
+ onShowHowToPlay()
300
+ }}
301
+ style={styles.toggleButton}
302
+ accessibilityLabel='How to Play'
303
+ >
304
+ <View style={styles.toggleContent}>
305
+ <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
306
+ <View style={styles.flexShrink}>
235
307
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
236
- Sound
308
+ How to Play
309
+ </Text>
310
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
311
+ Replay quick intro
237
312
  </Text>
238
313
  </View>
239
- </TouchableRipple>
240
- )}
314
+ </View>
315
+ </TouchableRipple>
316
+ )}
317
+ </View>
241
318
 
242
- {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
243
- it there too, for a game that's deliberately silent on vibration. */}
244
- {showHaptics && (
245
- <TouchableRipple
246
- hapticDisabled
247
- onPress={() => {
248
- const vibrate = !hapticSettings.vibrate
249
- setHapticSettings({ vibrate })
250
- if (vibrate) forceHapticFeedback()
251
- }}
252
- style={styles.toggleButtonInRow}
253
- accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
254
- >
319
+ {showAppearance && (
320
+ <View style={styles.section}>
321
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
322
+ APPEARANCE
323
+ </Text>
324
+ <AutoAppearancePicker showLabels={false} />
325
+ {/* Blur (Jay, 2026-10-15): one fleet-wide switch for auto-paper's own `blur` setting,
326
+ which every blur-capable surface follows on its own (this sheet, and the
327
+ OverlayCard behind ConfirmDialog, UpdateDialog and the guide). Off, or wherever
328
+ blur can't happen, those surfaces paint the theme's raised surface instead. So the
329
+ row only shows where turning it on would change something: not on Android
330
+ (auto-paper's BlurView needs a blur target there, which these cards don't have) and
331
+ not in an app that never handed expo-blur to auto-paper's Provider. */}
332
+ {showBlur && (
333
+ <TouchableRipple onPress={() => setThemeSettings({ blur: !themeSettings.blur })} style={styles.toggleButton} accessibilityLabel={`Blur ${themeSettings.blur ? 'on' : 'off'}`}>
255
334
  <View style={styles.toggleContent}>
256
- <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
257
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
258
- Haptics
259
- </Text>
335
+ <SettingIcon source={themeSettings.blur ? 'blur' : 'blur-off'} color={themeSettings.blur ? colors.tertiary : colors.onSurfaceVariant} containerColor={themeSettings.blur ? colors.tertiaryContainer : colors.surfaceVariant} />
336
+ {/* The one-word label alone didn't say what blurs (Jay, 2026-10-15). One line
337
+ even on a 360pt phone (216pt of room). */}
338
+ <View style={styles.flexShrink}>
339
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
340
+ Blur
341
+ </Text>
342
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
343
+ Frost behind dialogs
344
+ </Text>
345
+ </View>
260
346
  </View>
261
347
  </TouchableRipple>
262
348
  )}
263
349
  </View>
264
350
  )}
265
351
 
266
- {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
267
- down in `children` (which sits below Appearance) - this is the "replay the intro"
268
- entry point every game's own how-to-play flow shares. Dismisses this dialog first so
269
- the guide's own overlay never opens on top of a still-visible Settings card. */}
270
- {onShowHowToPlay && (
271
- <TouchableRipple
272
- onPress={() => {
273
- onDismiss()
274
- onShowHowToPlay()
275
- }}
276
- style={styles.toggleButton}
277
- accessibilityLabel='How to Play'
278
- >
279
- <View style={styles.toggleContent}>
280
- <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
281
- <View style={styles.flexShrink}>
282
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
283
- How to Play
284
- </Text>
285
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
286
- Replay the quick intro
287
- </Text>
288
- </View>
289
- </View>
290
- </TouchableRipple>
291
- )}
292
- </View>
293
-
294
- {showAppearance && (
295
- <View style={styles.section}>
296
- <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
297
- APPEARANCE
298
- </Text>
299
- <AutoAppearancePicker showLabels={false} />
300
- </View>
301
- )}
302
-
303
- {children}
352
+ {children}
304
353
 
305
- {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
354
+ {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
306
355
  alike) - hideUpdateCheck can hide it there too, for a native app that isn't wired up
307
356
  for EAS/OTA updates at all. */}
308
- {showUpdateCheck && (
309
- <View style={styles.section}>
310
- <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
311
- VERSION {version}
312
- {updateReady ? ' · UPDATE READY' : ''}
313
- </Text>
314
- <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
315
- Check for Updates
316
- </Button>
317
- </View>
318
- )}
319
- </ScrollView>
357
+ {showUpdateCheck && (
358
+ <View style={styles.section}>
359
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
360
+ VERSION {version}
361
+ {updateReady ? ' · UPDATE READY' : ''}
362
+ </Text>
363
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
364
+ Check for Updates
365
+ </Button>
366
+ </View>
367
+ )}
368
+ </View>
369
+ </FadingScrollView>
320
370
  </Dialog.ScrollArea>
371
+ <View style={[overlayActionStyles.actions, styles.footer]}>
372
+ <Button mode='contained' onPress={onDismiss} style={overlayActionStyles.actionButtonSingle} buttonColor={colors.primary} textColor={colors.onPrimary}>
373
+ Done
374
+ </Button>
375
+ </View>
321
376
  </Dialog>
322
377
  <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={0} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
323
378
  <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
@@ -326,10 +381,13 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
326
381
  )
327
382
  }
328
383
 
384
+ // The backdrop gutter every overlay card keeps from the screen's edges.
385
+ const SHEET_GUTTER = 24
386
+
329
387
  const styles = StyleSheet.create({
388
+ // The rows' sections, 24pt apart.
330
389
  content: {
331
- gap: 24,
332
- paddingVertical: 20
390
+ gap: 24
333
391
  },
334
392
  // Caps the card so it never grows past the screen - without this the dialog just keeps
335
393
  // growing to fit its content and the overflow gets clipped by the screen edge, which is
@@ -341,6 +399,11 @@ const styles = StyleSheet.create({
341
399
  flexShrink: {
342
400
  flexShrink: 1
343
401
  },
402
+ // The overlay cards' 24pt padding, as the sheet's own insets (its scroll area pads 24 sideways).
403
+ footer: {
404
+ paddingBottom: 24,
405
+ paddingHorizontal: 24
406
+ },
344
407
  iconBadge: {
345
408
  alignItems: 'center',
346
409
  borderRadius: 10,
@@ -348,9 +411,20 @@ const styles = StyleSheet.create({
348
411
  justifyContent: 'center',
349
412
  width: 36
350
413
  },
414
+ // No hairline dividers above and below the scroll area (Paper's MD2-era borders): none of the
415
+ // fleet's other dialogs draws one, so the sheet matches them (Jay, 2026-10-15).
351
416
  scrollArea: {
417
+ borderBottomWidth: 0,
418
+ borderTopWidth: 0,
352
419
  marginBottom: 0
353
420
  },
421
+ // The header and the rows scroll together: the card's 24pt inset above the header, the overlay
422
+ // cards' 16pt gap between it and the rows, and 16pt below the last row before the Done row.
423
+ scrollContent: {
424
+ gap: 16,
425
+ paddingBottom: 16,
426
+ paddingTop: 24
427
+ },
354
428
  section: {
355
429
  gap: 12
356
430
  },