@tastic/hud 0.17.0 → 0.19.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,16 +17,19 @@ import {
17
17
  StatRow,
18
18
  StatSection,
19
19
  UpdateDialog,
20
- getStaggeredWordDuration
21
- } from "./chunk-27FIA26F.mjs";
20
+ getStaggeredWordDuration,
21
+ useCompactDialog
22
+ } from "./chunk-HGJKNLMD.mjs";
22
23
  import {
24
+ COMPACT_DIALOG_HEIGHT,
25
+ DialogHeader,
23
26
  OverlayBackdrop,
24
27
  OverlayCard,
25
- getOverlayCardColors,
28
+ isCompactDialogHeight,
26
29
  overlayActionStyles,
27
30
  overlayStyles,
28
31
  useOverlayCardColors
29
- } from "./chunk-AX4GC5KU.mjs";
32
+ } from "./chunk-PUF6TABL.mjs";
30
33
 
31
34
  // src/AchievementUnlockList.tsx
32
35
  import { getContrastColor, useAutoPaperTheme } from "@rific/auto-paper";
@@ -151,7 +154,7 @@ var styles2 = StyleSheet2.create({
151
154
 
152
155
  // src/TriggerGaugeHost.tsx
153
156
  import { jsx as jsx3 } from "react/jsx-runtime";
154
- var getTriggerGauge = () => import("./TriggerGauge-2OJOOF6I.mjs").then((mod) => ({ default: mod.TriggerGauge }));
157
+ var getTriggerGauge = () => import("./TriggerGauge-GIHKGAZE.mjs").then((mod) => ({ default: mod.TriggerGauge }));
155
158
  function TriggerGaugeHost(props) {
156
159
  return /* @__PURE__ */ jsx3(SkiaGate, { getComponent: getTriggerGauge, componentProps: props });
157
160
  }
@@ -877,11 +880,13 @@ export {
877
880
  ActivityStatSection,
878
881
  BaseSettingsDialog,
879
882
  BaseStatsScreen,
883
+ COMPACT_DIALOG_HEIGHT,
880
884
  ConfirmDialog,
881
885
  ContentGutter,
882
886
  ControlSchemePicker,
883
887
  CornerActionButtons,
884
888
  CornerStatusBadges,
889
+ DialogHeader,
885
890
  InlineColorPicker,
886
891
  LABELED_DROPDOWN_POPOVER_WIDTH,
887
892
  LOCKED_BADGE_COLOR,
@@ -906,12 +911,13 @@ export {
906
911
  getCpuDifficultyPopoverId,
907
912
  getInlineColorPickerContentSize,
908
913
  getLabeledDropdownContentHeight,
909
- getOverlayCardColors,
910
914
  getStaggeredWordDuration,
915
+ isCompactDialogHeight,
911
916
  loadSkiaWeb,
912
917
  overlayActionStyles,
913
918
  overlayStyles,
914
919
  useAutoAlign,
920
+ useCompactDialog,
915
921
  useOverlayCardColors,
916
922
  usePopoverHost,
917
923
  useQuitConfirmation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.17.0",
3
+ "version": "0.19.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,15 +87,15 @@
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",
93
- "@rific/updater": "^0.6.0",
90
+ "@rific/auto-paper": "^0.13.2",
91
+ "@rific/core": "^0.3.1",
92
+ "@rific/feedback-press": "^0.13.0",
93
+ "@rific/updater": "^0.7.1",
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",
98
- "@tastic/split-screen": "^0.5.0",
95
+ "@tastic/achievements": "^0.6.0",
96
+ "@tastic/core": "^0.14.0",
97
+ "@tastic/profile": "^0.12.0",
98
+ "@tastic/split-screen": "^0.5.2",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
101
101
  "@types/jest": "^30.0.0",
@@ -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, useBlurModule, useThemeSettings } 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
 
@@ -53,6 +56,13 @@ export interface BaseSettingsDialogProps {
53
56
  // @tastic/edge-guard's own swizzle is a no-op on every other platform.
54
57
  deferBottomEdgeGestures?: boolean
55
58
  onDeferBottomEdgeGestures?: (value: boolean) => void
59
+ // Optional, and always passed as a pair - omit both for an app that doesn't keep the screen awake.
60
+ // This dialog only shows and reports the toggle; the app itself keeps the screen on while it's set
61
+ // (expo-keep-awake on its match screen), so this package needs no native dependency for it. Shown
62
+ // on the same devices as Lock Orientation (see isTouchPrimary below): a desktop/laptop browser
63
+ // stays awake on its own while someone is playing with its keyboard or mouse.
64
+ keepAwake?: boolean
65
+ onKeepAwakeChange?: (value: boolean) => void
56
66
  // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
57
67
  // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
58
68
  // caller passes in - unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
@@ -80,18 +90,30 @@ export interface BaseSettingsDialogProps {
80
90
  children?: ReactNode
81
91
  }
82
92
 
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
93
+ // The settings shell every @tastic game shares - orientation lock, the optional edge-guard and
94
+ // keep-awake toggles, sound/haptics, appearance, and update checking - extracted after the same six rows were
85
95
  // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
86
96
  // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
87
97
  // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
88
98
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
89
99
  // forking this component.
90
- export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
100
+ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
91
101
  const { colors } = useAutoPaperTheme()
92
102
  const ambientRotation = useRotation()
93
103
  const rotation = rotationOverride ?? ambientRotation
94
- // Also gates the Lock Orientation row below (alongside showLockOrientation itself) - true
104
+ // The sheet lays out in a RotatedFrame turned by `rotation` (below), so its height is the window's
105
+ // width when turned a quarter: the same compact rule as every other dialog's header.
106
+ const window = useWindowDimensions()
107
+ const turned = rotation === 90 || rotation === -90
108
+ const compactHeader = isCompactDialogHeight(turned ? window.width : window.height)
109
+ // The overlay cards' width (ConfirmDialog, the guide, every app's result card): 24pt from the
110
+ // frame's edges, never under a notch, capped at their 360 (overlayStyles.card). auto-paper's Dialog
111
+ // would give the sheet a 32pt margin and a 400 cap, 16pt narrower than a card on a phone, which
112
+ // showed when Settings and a confirm opened one after the other. The card is centered, so the
113
+ // Dialog's own 32pt padding is simply overhung.
114
+ const insets = rotateInsets(useSafeAreaInsets(), rotation)
115
+ const sheetWidth = Math.min(overlayStyles.card.maxWidth, (turned ? window.height : window.width) - 2 * Math.max(insets.left, insets.right, SHEET_GUTTER))
116
+ // Also gates the Lock Orientation and Keep Awake rows below (alongside their own props) - true
95
117
  // unconditionally on native (see the hook's own doc), so this only actually excludes a
96
118
  // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
97
119
  // against; a resize there would just make the setting silently stop reflowing the layout instead
@@ -138,6 +160,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
138
160
 
139
161
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
140
162
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
163
+ const showKeepAwake = isTouchPrimary && keepAwake !== undefined && onKeepAwakeChange !== undefined
141
164
  const showHaptics = Platform.OS !== 'web' && !hideHaptics
142
165
  const showSound = !hideSound
143
166
  const showAppearance = !hideAppearance
@@ -159,186 +182,222 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
159
182
  return (
160
183
  <Portal>
161
184
  <RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
162
- <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
163
- <Dialog.Title>Settings</Dialog.Title>
185
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, { width: sheetWidth }]}>
164
186
  {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
165
- reserve room for a Dialog.Actions row below it) - overridden to 0 since this dialog has
166
- no actions row, and that gap otherwise reads as unexplained empty footer space. */}
187
+ reserve room for a Dialog.Actions row below it) - overridden to 0: the Done row below
188
+ carries its own spacing. It is the sheet's first child, so it runs to the card's top edge. */}
167
189
  <Dialog.ScrollArea style={styles.scrollArea}>
168
- <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
169
- {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
190
+ <FadingScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
191
+ {/* The fleet's one dialog header and Done button (DialogHeader, overlayActionStyles' single
192
+ button), so the sheet reads as one family with ConfirmDialog, the guide and every app's
193
+ result cards (Jay, 2026-10-15). The header scrolls away with the rows, so a long sheet
194
+ gives them the whole card once scrolled; Done stays pinned below. Tapping outside still
195
+ closes it too. */}
196
+ <DialogHeader icon='cog' title='Settings' compact={compactHeader} />
197
+ <View style={styles.content}>
198
+ {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
170
199
  between sections below) - same bare-row shape for all of them, no segmented control
171
200
  and no section heading (each row's own label already says what it is). Haptics only
172
201
  joins on native - there's nothing for it to control on web. */}
173
- <View style={styles.toggleGroup}>
174
- {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
202
+ <View style={styles.toggleGroup}>
203
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
175
204
  passed at all - nothing to lock), and hidden (not just disabled) on a desktop/laptop
176
205
  browser even when they are - see isTouchPrimary's own doc above. Still shown on
177
206
  native and on a touch-capable mobile browser, where a rotated window really does
178
207
  reflect a rotated device. */}
179
- {showLockOrientation && (
180
- <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
181
- <View style={styles.toggleContent}>
182
- <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.tertiary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.tertiaryContainer : colors.surfaceVariant} />
183
- <View style={styles.flexShrink}>
184
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
185
- Lock Orientation
186
- </Text>
187
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
188
- Pins the current layout
189
- </Text>
208
+ {showLockOrientation && (
209
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
210
+ <View style={styles.toggleContent}>
211
+ <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.tertiary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.tertiaryContainer : colors.surfaceVariant} />
212
+ <View style={styles.flexShrink}>
213
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
214
+ Lock Orientation
215
+ </Text>
216
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
217
+ Pin current layout
218
+ </Text>
219
+ </View>
190
220
  </View>
191
- </View>
192
- </TouchableRipple>
193
- )}
221
+ </TouchableRipple>
222
+ )}
194
223
 
195
- {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin
224
+ {/* iOS only - mirrors into UserDefaults for @tastic/edge-guard's own config plugin
196
225
  swizzle to read. Guards both edges - the top (Notification Center/Control Center)
197
226
  and the bottom (home-indicator swipe, incidentally Reachability too), per that
198
227
  package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
199
228
  accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
200
229
  surprise every player gets by default. */}
201
- {showEdgeGuard && (
202
- <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
203
- <View style={styles.toggleContent}>
204
- <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.tertiary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.tertiaryContainer : colors.surfaceVariant} />
205
- <View style={styles.flexShrink}>
206
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
207
- Edge Guard
208
- </Text>
209
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
210
- Requires a second swipe
211
- </Text>
230
+ {showEdgeGuard && (
231
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
232
+ <View style={styles.toggleContent}>
233
+ <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.tertiary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.tertiaryContainer : colors.surfaceVariant} />
234
+ <View style={styles.flexShrink}>
235
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
236
+ Edge Guard
237
+ </Text>
238
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
239
+ Require second swipe
240
+ </Text>
241
+ </View>
212
242
  </View>
213
- </View>
214
- </TouchableRipple>
215
- )}
243
+ </TouchableRipple>
244
+ )}
216
245
 
217
- {/* Side by side, not stacked - neither needs a full row's width (single-line label,
246
+ {/* See the keepAwake prop's own doc: the app does the keeping awake, this only toggles it. */}
247
+ {showKeepAwake && (
248
+ <TouchableRipple onPress={() => onKeepAwakeChange!(!keepAwake)} style={styles.toggleButton} accessibilityLabel={`Keep awake ${keepAwake ? 'on' : 'off'}`}>
249
+ <View style={styles.toggleContent}>
250
+ <SettingIcon source={keepAwake ? 'eye' : 'eye-closed'} color={keepAwake ? colors.tertiary : colors.onSurfaceVariant} containerColor={keepAwake ? colors.tertiaryContainer : colors.surfaceVariant} />
251
+ <View style={styles.flexShrink}>
252
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
253
+ Keep Awake
254
+ </Text>
255
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
256
+ Stop auto-lock mid-match
257
+ </Text>
258
+ </View>
259
+ </View>
260
+ </TouchableRipple>
261
+ )}
262
+
263
+ {/* Side by side, not stacked - neither needs a full row's width (single-line label,
218
264
  no description under it the way Lock Orientation has), so sharing one row reads
219
265
  just as clearly and takes half the vertical space. toggleRow carries the same
220
266
  edge-alignment negative margin toggleButton normally carries itself; the buttons
221
267
  inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
222
268
  The row itself only renders when at least one of the two will - an empty flex row
223
269
  still eats its own gap/margin otherwise. */}
224
- {(showSound || showHaptics) && (
225
- <View style={styles.toggleRow}>
226
- {showSound && (
227
- <TouchableRipple
228
- soundDisabled
229
- onPress={() => {
230
- const enabled = !soundSettings.enabled
231
- setSoundSettings({ enabled })
232
- if (enabled) sound.selection?.()
233
- }}
234
- style={styles.toggleButtonInRow}
235
- accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
236
- >
237
- <View style={styles.toggleContent}>
238
- <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
239
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
240
- Sound
241
- </Text>
242
- </View>
243
- </TouchableRipple>
244
- )}
270
+ {(showSound || showHaptics) && (
271
+ <View style={styles.toggleRow}>
272
+ {showSound && (
273
+ <TouchableRipple
274
+ soundDisabled
275
+ onPress={() => {
276
+ const enabled = !soundSettings.enabled
277
+ setSoundSettings({ enabled })
278
+ if (enabled) sound.selection?.()
279
+ }}
280
+ style={styles.toggleButtonInRow}
281
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
282
+ >
283
+ <View style={styles.toggleContent}>
284
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
285
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
286
+ Sound
287
+ </Text>
288
+ </View>
289
+ </TouchableRipple>
290
+ )}
245
291
 
246
- {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
292
+ {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
247
293
  it there too, for a game that's deliberately silent on vibration. */}
248
- {showHaptics && (
249
- <TouchableRipple
250
- hapticDisabled
251
- onPress={() => {
252
- const vibrate = !hapticSettings.vibrate
253
- setHapticSettings({ vibrate })
254
- if (vibrate) forceHapticFeedback()
255
- }}
256
- style={styles.toggleButtonInRow}
257
- accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
258
- >
259
- <View style={styles.toggleContent}>
260
- <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
261
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
262
- Haptics
263
- </Text>
264
- </View>
265
- </TouchableRipple>
266
- )}
267
- </View>
268
- )}
294
+ {showHaptics && (
295
+ <TouchableRipple
296
+ hapticDisabled
297
+ onPress={() => {
298
+ const vibrate = !hapticSettings.vibrate
299
+ setHapticSettings({ vibrate })
300
+ if (vibrate) forceHapticFeedback()
301
+ }}
302
+ style={styles.toggleButtonInRow}
303
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
304
+ >
305
+ <View style={styles.toggleContent}>
306
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
307
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
308
+ Haptics
309
+ </Text>
310
+ </View>
311
+ </TouchableRipple>
312
+ )}
313
+ </View>
314
+ )}
269
315
 
270
- {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
316
+ {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
271
317
  down in `children` (which sits below Appearance) - this is the "replay the intro"
272
318
  entry point every game's own how-to-play flow shares. Dismisses this dialog first so
273
319
  the guide's own overlay never opens on top of a still-visible Settings card. */}
274
- {onShowHowToPlay && (
275
- <TouchableRipple
276
- onPress={() => {
277
- onDismiss()
278
- onShowHowToPlay()
279
- }}
280
- style={styles.toggleButton}
281
- accessibilityLabel='How to Play'
282
- >
283
- <View style={styles.toggleContent}>
284
- <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
285
- <View style={styles.flexShrink}>
286
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
287
- How to Play
288
- </Text>
289
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
290
- Replay the quick intro
291
- </Text>
320
+ {onShowHowToPlay && (
321
+ <TouchableRipple
322
+ onPress={() => {
323
+ onDismiss()
324
+ onShowHowToPlay()
325
+ }}
326
+ style={styles.toggleButton}
327
+ accessibilityLabel='How to Play'
328
+ >
329
+ <View style={styles.toggleContent}>
330
+ <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
331
+ <View style={styles.flexShrink}>
332
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
333
+ How to Play
334
+ </Text>
335
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
336
+ Replay quick intro
337
+ </Text>
338
+ </View>
292
339
  </View>
293
- </View>
294
- </TouchableRipple>
295
- )}
296
- </View>
340
+ </TouchableRipple>
341
+ )}
342
+ </View>
297
343
 
298
- {showAppearance && (
299
- <View style={styles.section}>
300
- <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
301
- APPEARANCE
302
- </Text>
303
- <AutoAppearancePicker showLabels={false} />
304
- {/* Blur (Jay, 2026-10-15): one fleet-wide switch for auto-paper's own `blur` setting,
344
+ {showAppearance && (
345
+ <View style={styles.section}>
346
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
347
+ APPEARANCE
348
+ </Text>
349
+ <AutoAppearancePicker showLabels={false} />
350
+ {/* Blur (Jay, 2026-10-15): one fleet-wide switch for auto-paper's own `blur` setting,
305
351
  which every blur-capable surface follows on its own (this sheet, and the
306
352
  OverlayCard behind ConfirmDialog, UpdateDialog and the guide). Off, or wherever
307
353
  blur can't happen, those surfaces paint the theme's raised surface instead. So the
308
354
  row only shows where turning it on would change something: not on Android
309
355
  (auto-paper's BlurView needs a blur target there, which these cards don't have) and
310
356
  not in an app that never handed expo-blur to auto-paper's Provider. */}
311
- {showBlur && (
312
- <TouchableRipple onPress={() => setThemeSettings({ blur: !themeSettings.blur })} style={styles.toggleButton} accessibilityLabel={`Blur ${themeSettings.blur ? 'on' : 'off'}`}>
313
- <View style={styles.toggleContent}>
314
- <SettingIcon source={themeSettings.blur ? 'blur' : 'blur-off'} color={themeSettings.blur ? colors.tertiary : colors.onSurfaceVariant} containerColor={themeSettings.blur ? colors.tertiaryContainer : colors.surfaceVariant} />
315
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
316
- Blur
317
- </Text>
318
- </View>
319
- </TouchableRipple>
320
- )}
321
- </View>
322
- )}
357
+ {showBlur && (
358
+ <TouchableRipple onPress={() => setThemeSettings({ blur: !themeSettings.blur })} style={styles.toggleButton} accessibilityLabel={`Blur ${themeSettings.blur ? 'on' : 'off'}`}>
359
+ <View style={styles.toggleContent}>
360
+ <SettingIcon source={themeSettings.blur ? 'blur' : 'blur-off'} color={themeSettings.blur ? colors.tertiary : colors.onSurfaceVariant} containerColor={themeSettings.blur ? colors.tertiaryContainer : colors.surfaceVariant} />
361
+ {/* The one-word label alone didn't say what blurs (Jay, 2026-10-15). One line
362
+ even on a 360pt phone (216pt of room). */}
363
+ <View style={styles.flexShrink}>
364
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
365
+ Blur
366
+ </Text>
367
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
368
+ Frost behind dialogs
369
+ </Text>
370
+ </View>
371
+ </View>
372
+ </TouchableRipple>
373
+ )}
374
+ </View>
375
+ )}
323
376
 
324
- {children}
377
+ {children}
325
378
 
326
- {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
379
+ {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
327
380
  alike) - hideUpdateCheck can hide it there too, for a native app that isn't wired up
328
381
  for EAS/OTA updates at all. */}
329
- {showUpdateCheck && (
330
- <View style={styles.section}>
331
- <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
332
- VERSION {version}
333
- {updateReady ? ' · UPDATE READY' : ''}
334
- </Text>
335
- <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
336
- Check for Updates
337
- </Button>
338
- </View>
339
- )}
340
- </ScrollView>
382
+ {showUpdateCheck && (
383
+ <View style={styles.section}>
384
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
385
+ VERSION {version}
386
+ {updateReady ? ' · UPDATE READY' : ''}
387
+ </Text>
388
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
389
+ Check for Updates
390
+ </Button>
391
+ </View>
392
+ )}
393
+ </View>
394
+ </FadingScrollView>
341
395
  </Dialog.ScrollArea>
396
+ <View style={[overlayActionStyles.actions, styles.footer]}>
397
+ <Button mode='contained' onPress={onDismiss} style={overlayActionStyles.actionButtonSingle} buttonColor={colors.primary} textColor={colors.onPrimary}>
398
+ Done
399
+ </Button>
400
+ </View>
342
401
  </Dialog>
343
402
  <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)} />
344
403
  <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
@@ -347,10 +406,13 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
347
406
  )
348
407
  }
349
408
 
409
+ // The backdrop gutter every overlay card keeps from the screen's edges.
410
+ const SHEET_GUTTER = 24
411
+
350
412
  const styles = StyleSheet.create({
413
+ // The rows' sections, 24pt apart.
351
414
  content: {
352
- gap: 24,
353
- paddingVertical: 20
415
+ gap: 24
354
416
  },
355
417
  // Caps the card so it never grows past the screen - without this the dialog just keeps
356
418
  // growing to fit its content and the overflow gets clipped by the screen edge, which is
@@ -362,6 +424,11 @@ const styles = StyleSheet.create({
362
424
  flexShrink: {
363
425
  flexShrink: 1
364
426
  },
427
+ // The overlay cards' 24pt padding, as the sheet's own insets (its scroll area pads 24 sideways).
428
+ footer: {
429
+ paddingBottom: 24,
430
+ paddingHorizontal: 24
431
+ },
365
432
  iconBadge: {
366
433
  alignItems: 'center',
367
434
  borderRadius: 10,
@@ -369,9 +436,20 @@ const styles = StyleSheet.create({
369
436
  justifyContent: 'center',
370
437
  width: 36
371
438
  },
439
+ // No hairline dividers above and below the scroll area (Paper's MD2-era borders): none of the
440
+ // fleet's other dialogs draws one, so the sheet matches them (Jay, 2026-10-15).
372
441
  scrollArea: {
442
+ borderBottomWidth: 0,
443
+ borderTopWidth: 0,
373
444
  marginBottom: 0
374
445
  },
446
+ // The header and the rows scroll together: the card's 24pt inset above the header, the overlay
447
+ // cards' 16pt gap between it and the rows, and 16pt below the last row before the Done row.
448
+ scrollContent: {
449
+ gap: 16,
450
+ paddingBottom: 16,
451
+ paddingTop: 24
452
+ },
375
453
  section: {
376
454
  gap: 12
377
455
  },