@tastic/hud 0.17.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,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-Z2WUUYTE.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.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, 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
 
@@ -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
@@ -159,186 +174,205 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
159
174
  return (
160
175
  <Portal>
161
176
  <RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
162
- <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
163
- <Dialog.Title>Settings</Dialog.Title>
177
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, { width: sheetWidth }]}>
164
178
  {/* 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. */}
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. */}
167
181
  <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
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
170
191
  between sections below) - same bare-row shape for all of them, no segmented control
171
192
  and no section heading (each row's own label already says what it is). Haptics only
172
193
  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
194
+ <View style={styles.toggleGroup}>
195
+ {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
175
196
  passed at all - nothing to lock), and hidden (not just disabled) on a desktop/laptop
176
197
  browser even when they are - see isTouchPrimary's own doc above. Still shown on
177
198
  native and on a touch-capable mobile browser, where a rotated window really does
178
199
  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>
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>
190
212
  </View>
191
- </View>
192
- </TouchableRipple>
193
- )}
213
+ </TouchableRipple>
214
+ )}
194
215
 
195
- {/* 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
196
217
  swizzle to read. Guards both edges - the top (Notification Center/Control Center)
197
218
  and the bottom (home-indicator swipe, incidentally Reachability too), per that
198
219
  package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
199
220
  accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
200
221
  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>
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>
212
234
  </View>
213
- </View>
214
- </TouchableRipple>
215
- )}
235
+ </TouchableRipple>
236
+ )}
216
237
 
217
- {/* 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,
218
239
  no description under it the way Lock Orientation has), so sharing one row reads
219
240
  just as clearly and takes half the vertical space. toggleRow carries the same
220
241
  edge-alignment negative margin toggleButton normally carries itself; the buttons
221
242
  inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
222
243
  The row itself only renders when at least one of the two will - an empty flex row
223
244
  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
- )}
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
+ )}
245
266
 
246
- {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
267
+ {/* Already native-only (see showHaptics's own definition) - hideHaptics can hide
247
268
  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
- )}
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
+ )}
269
290
 
270
- {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
291
+ {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
271
292
  down in `children` (which sits below Appearance) - this is the "replay the intro"
272
293
  entry point every game's own how-to-play flow shares. Dismisses this dialog first so
273
294
  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>
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}>
307
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
308
+ How to Play
309
+ </Text>
310
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
311
+ Replay quick intro
312
+ </Text>
313
+ </View>
292
314
  </View>
293
- </View>
294
- </TouchableRipple>
295
- )}
296
- </View>
315
+ </TouchableRipple>
316
+ )}
317
+ </View>
297
318
 
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,
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,
305
326
  which every blur-capable surface follows on its own (this sheet, and the
306
327
  OverlayCard behind ConfirmDialog, UpdateDialog and the guide). Off, or wherever
307
328
  blur can't happen, those surfaces paint the theme's raised surface instead. So the
308
329
  row only shows where turning it on would change something: not on Android
309
330
  (auto-paper's BlurView needs a blur target there, which these cards don't have) and
310
331
  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
- )}
332
+ {showBlur && (
333
+ <TouchableRipple onPress={() => setThemeSettings({ blur: !themeSettings.blur })} style={styles.toggleButton} accessibilityLabel={`Blur ${themeSettings.blur ? 'on' : 'off'}`}>
334
+ <View style={styles.toggleContent}>
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>
346
+ </View>
347
+ </TouchableRipple>
348
+ )}
349
+ </View>
350
+ )}
323
351
 
324
- {children}
352
+ {children}
325
353
 
326
- {/* 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
327
355
  alike) - hideUpdateCheck can hide it there too, for a native app that isn't wired up
328
356
  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>
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>
341
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>
342
376
  </Dialog>
343
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)} />
344
378
  <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
@@ -347,10 +381,13 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
347
381
  )
348
382
  }
349
383
 
384
+ // The backdrop gutter every overlay card keeps from the screen's edges.
385
+ const SHEET_GUTTER = 24
386
+
350
387
  const styles = StyleSheet.create({
388
+ // The rows' sections, 24pt apart.
351
389
  content: {
352
- gap: 24,
353
- paddingVertical: 20
390
+ gap: 24
354
391
  },
355
392
  // Caps the card so it never grows past the screen - without this the dialog just keeps
356
393
  // growing to fit its content and the overflow gets clipped by the screen edge, which is
@@ -362,6 +399,11 @@ const styles = StyleSheet.create({
362
399
  flexShrink: {
363
400
  flexShrink: 1
364
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
+ },
365
407
  iconBadge: {
366
408
  alignItems: 'center',
367
409
  borderRadius: 10,
@@ -369,9 +411,20 @@ const styles = StyleSheet.create({
369
411
  justifyContent: 'center',
370
412
  width: 36
371
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).
372
416
  scrollArea: {
417
+ borderBottomWidth: 0,
418
+ borderTopWidth: 0,
373
419
  marginBottom: 0
374
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
+ },
375
428
  section: {
376
429
  gap: 12
377
430
  },
@@ -3,10 +3,12 @@ import { Button } from '@rific/feedback-press'
3
3
  import { toRotationStyle, type ViewRotation } from '@tastic/core'
4
4
  import { type ReactNode } from 'react'
5
5
  import { StyleSheet, View } from 'react-native'
6
- import { Icon, Portal, Text } from 'react-native-paper'
6
+ import { Portal, Text } from 'react-native-paper'
7
7
 
8
+ import { DialogHeader } from './DialogHeader'
8
9
  import { overlayActionStyles } from './overlayCard'
9
10
  import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from './OverlaySurface'
11
+ import { useCompactDialog } from './useCompactDialog'
10
12
 
11
13
  export interface ConfirmDialogProps {
12
14
  visible: boolean
@@ -20,8 +22,8 @@ export interface ConfirmDialogProps {
20
22
  // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
21
23
  // match (a portrait-only or single-player game has nothing to stay consistent with).
22
24
  rotation?: ViewRotation
23
- // Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
24
- // question mark, since this component has no way to know why a caller is asking.
25
+ // Any react-native-paper Icon `source` name, shown in the header (DialogHeader) - defaults to a
26
+ // plain question mark, since this component has no way to know why a caller is asking.
25
27
  icon?: string
26
28
  // Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
27
29
  // instead of the default primary-colored affirmative action.
@@ -40,6 +42,7 @@ export interface ConfirmDialogProps {
40
42
  export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
41
43
  const { colors } = useAutoPaperTheme()
42
44
  const { fg } = useOverlayCardColors()
45
+ const { compact, onLayout } = useCompactDialog(rotation)
43
46
  if (!visible) return null
44
47
 
45
48
  const accent = destructive ? colors.danger : colors.primary
@@ -48,12 +51,9 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
48
51
 
49
52
  return (
50
53
  <Portal>
51
- <OverlayBackdrop style={styles.overlay}>
54
+ <OverlayBackdrop style={styles.overlay} onLayout={onLayout}>
52
55
  <OverlayCard style={[styles.card, toRotationStyle(rotation)]}>
53
- <Icon source={icon} size={64} color={accent} />
54
- <Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
55
- {title}
56
- </Text>
56
+ <DialogHeader icon={icon} title={title} color={accent} compact={compact} />
57
57
  <Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
58
58
  {message}
59
59
  </Text>
@@ -91,12 +91,5 @@ const styles = StyleSheet.create({
91
91
  // screen narrower than maxWidth's own 360.
92
92
  overlay: {
93
93
  paddingHorizontal: 24
94
- },
95
- // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
96
- // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
97
- // line inside that block still defaults to left-aligned without this.
98
- title: {
99
- fontWeight: 'bold',
100
- textAlign: 'center'
101
94
  }
102
95
  })