@tastic/hud 0.13.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
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",
@@ -80,13 +80,13 @@
80
80
  "@infinitetoken/eslint-config": "^0.2.0",
81
81
  "@infinitetoken/jest-config": "^0.4.0",
82
82
  "@infinitetoken/tsconfig": "^0.5.0",
83
- "@rific/auto-paper": "^0.12.0",
83
+ "@rific/auto-paper": "^0.12.3",
84
84
  "@rific/core": "^0.2.0",
85
85
  "@rific/feedback-press": "^0.12.0",
86
86
  "@rific/updater": "^0.6.0",
87
87
  "@shopify/react-native-skia": "^2.6.2",
88
88
  "@tastic/achievements": "^0.2.0",
89
- "@tastic/core": "^0.8.0",
89
+ "@tastic/core": "^0.12.0",
90
90
  "@tastic/profile": "^0.7.0",
91
91
  "@tastic/split-screen": "^0.5.0",
92
92
  "@testing-library/dom": "^10.4.1",
@@ -109,12 +109,12 @@
109
109
  "typescript": "^6.0.3"
110
110
  },
111
111
  "peerDependencies": {
112
- "@rific/auto-paper": ">=0.9.4",
112
+ "@rific/auto-paper": ">=0.12.2",
113
113
  "@rific/feedback-press": ">=0.10.2",
114
114
  "@rific/updater": ">=0.4.0",
115
115
  "@shopify/react-native-skia": ">=1.5.0",
116
116
  "@tastic/achievements": ">=0.2.0",
117
- "@tastic/core": ">=0.5.0",
117
+ "@tastic/core": ">=0.12.0",
118
118
  "@tastic/profile": ">=0.4.0",
119
119
  "@tastic/split-screen": ">=0.5.0",
120
120
  "react": ">=19.0.0",
@@ -1,12 +1,13 @@
1
1
  import { AutoAppearancePicker, Dialog, useAutoPaperTheme } 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 { toRotationStyle, useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
4
+ import { useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
5
5
  import { ReactNode, useContext, useState } from 'react'
6
6
  import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
- import { Icon, Text } from 'react-native-paper'
7
+ import { Icon, Portal, Text } from 'react-native-paper'
8
8
 
9
9
  import { ConfirmDialog } from './ConfirmDialog'
10
+ import { RotatedFrame } from './RotatedFrame'
10
11
  import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
11
12
 
12
13
  interface SettingIconProps {
@@ -29,11 +30,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
29
30
  export interface BaseSettingsDialogProps {
30
31
  visible: boolean
31
32
  onDismiss: () => void
32
- // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — this is
33
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation). This is
33
34
  // a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
34
- // dialog), so it just rotates its own content in place. Defaults to a live ambient read via
35
- // useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
36
- // ambient one (rare — most callers should simply omit it).
35
+ // dialog), so it lays itself out in its own frame turned by this angle (see the return below).
36
+ // Defaults to a live ambient read via useRotation() when omitted. Pass 0 when the dialog already
37
+ // renders under a Portal.Host inside a turned frame of the caller's own (Solitaire's screens).
37
38
  rotation?: ViewRotation
38
39
  // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
39
40
  // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
@@ -122,6 +123,14 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
122
123
  onInfo: (title, message) => setInfoMessage({ title, message })
123
124
  })
124
125
  const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null
126
+ // The frame below is mounted from the first open on and then kept, so the Dialog's close fade
127
+ // still plays after `visible` turns false. Never mounting it before that keeps a screen that never
128
+ // opens Settings free of the extra (pass-through) layer, mounts the Portal at open time like the
129
+ // Dialog's own Portal always was (so it lands above portals mounted earlier), and means nothing
130
+ // here needs a Paper Portal.Host until the sheet is actually opened. The update and info cards are
131
+ // only reachable from inside the sheet, so they never need it earlier either.
132
+ const [opened, setOpened] = useState(visible)
133
+ if (visible && !opened) setOpened(true)
125
134
 
126
135
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
127
136
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
@@ -130,175 +139,188 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
130
139
  const showAppearance = !hideAppearance
131
140
  const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
132
141
 
142
+ // Laid out in its own turned frame rather than rotated in place. Paper's Portal mounts at the app
143
+ // root, full screen and portrait-shaped, so a sheet that only rotated its card kept resolving its
144
+ // maxHeight against the PORTRAIT height: turned 90deg it ran past the ~402pt short edge, and the top
145
+ // row (Lock Orientation, the one you need to get back out of landscape) was clipped with no way to
146
+ // scroll to it. So the whole output goes through one Portal to the root, into a RotatedFrame sized
147
+ // to the turned footprint, with rotated safe-area insets and its own Portal.Host. The Dialog and the
148
+ // two ConfirmDialogs below portal into THAT host, so they lay out against the turned window with no
149
+ // transform of their own (hence rotation={0} on the ConfirmDialogs). The frame is pass-through at
150
+ // every level and stays mounted once the sheet has opened, which keeps the Dialog's close fade; at
151
+ // rotation 0 it is an identity frame.
152
+ if (!opened) return null
133
153
  return (
134
- <>
135
- <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, toRotationStyle(rotation)]}>
136
- <Dialog.Title>Settings</Dialog.Title>
137
- {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
138
- reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
139
- no actions row, and that gap otherwise reads as unexplained empty footer space. */}
140
- <Dialog.ScrollArea style={styles.scrollArea}>
141
- <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
142
- {/* Grouped tightly together (styles.toggleGroup's small internal gap, not the 24px gap
143
- between sections below) — same bare-row shape for all of them, no segmented control
144
- and no section heading (each row's own label already says what it is). Haptics only
145
- joins on native — there's nothing for it to control on web. */}
146
- <View style={styles.toggleGroup}>
147
- {/* Omitted entirely for a portrait-only game (no lockOrientation/onLockOrientationChange
148
- passed at all — nothing to lock), and hidden (not just disabled) on a desktop/laptop
149
- browser even when they are — see isTouchPrimary's own doc above. Still shown on
150
- native and on a touch-capable mobile browser, where a rotated window really does
151
- reflect a rotated device. */}
152
- {showLockOrientation && (
153
- <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
154
- <View style={styles.toggleContent}>
155
- <SettingIcon source={lockOrientation ? 'lock' : 'lock-open-variant-outline'} color={lockOrientation ? colors.secondary : colors.onSurfaceVariant} containerColor={lockOrientation ? colors.secondaryContainer : colors.surfaceVariant} />
156
- <View style={styles.flexShrink}>
157
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
158
- Lock Orientation
159
- </Text>
160
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
161
- Pins the current layout
162
- </Text>
163
- </View>
164
- </View>
165
- </TouchableRipple>
166
- )}
167
-
168
- {/* iOS only — mirrors into UserDefaults for @tastic/edge-guard's own config plugin
169
- swizzle to read. Guards both edges — the top (Notification Center/Control Center)
170
- and the bottom (home-indicator swipe, incidentally Reachability too), per that
171
- package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
172
- accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
173
- surprise every player gets by default. */}
174
- {showEdgeGuard && (
175
- <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
176
- <View style={styles.toggleContent}>
177
- <SettingIcon source={deferBottomEdgeGestures ? 'shield-check-outline' : 'shield-off-outline'} color={deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant} containerColor={deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant} />
178
- <View style={styles.flexShrink}>
179
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
180
- Edge Guard
181
- </Text>
182
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
183
- Requires a second swipe
184
- </Text>
185
- </View>
186
- </View>
187
- </TouchableRipple>
188
- )}
189
-
190
- {/* Side by side, not stacked — neither needs a full row's width (single-line label,
191
- no description under it the way Lock Orientation has), so sharing one row reads
192
- just as clearly and takes half the vertical space. toggleRow carries the same
193
- edge-alignment negative margin toggleButton normally carries itself; the buttons
194
- inside it use toggleButtonInRow (flex: 1) instead so the two don't double up on it.
195
- The row itself only renders when at least one of the two will — an empty flex row
196
- still eats its own gap/margin otherwise. */}
197
- {(showSound || showHaptics) && (
198
- <View style={styles.toggleRow}>
199
- {showSound && (
200
- <TouchableRipple
201
- soundDisabled
202
- onPress={() => {
203
- const enabled = !soundSettings.enabled
204
- setSoundSettings({ enabled })
205
- if (enabled) sound.selection?.()
206
- }}
207
- style={styles.toggleButtonInRow}
208
- accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
209
- >
210
- <View style={styles.toggleContent}>
211
- <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
154
+ <Portal>
155
+ <RotatedFrame rotation={rotation} rotatedSafeArea portalHost passThrough>
156
+ <Dialog visible={visible} onDismiss={onDismiss} style={styles.dialog}>
157
+ <Dialog.Title>Settings</Dialog.Title>
158
+ {/* 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
160
+ no actions row, and that gap otherwise reads as unexplained empty footer space. */}
161
+ <Dialog.ScrollArea style={styles.scrollArea}>
162
+ <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
163
+ {/* 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
165
+ 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. */}
167
+ <View style={styles.toggleGroup}>
168
+ {/* 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
+ native and on a touch-capable mobile browser, where a rotated window really does
172
+ reflect a rotated device. */}
173
+ {showLockOrientation && (
174
+ <TouchableRipple onPress={() => onLockOrientationChange!(!lockOrientation)} style={styles.toggleButton} accessibilityLabel={`Lock orientation ${lockOrientation ? 'on' : 'off'}`}>
175
+ <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} />
177
+ <View style={styles.flexShrink}>
212
178
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
213
- Sound
179
+ Lock Orientation
180
+ </Text>
181
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
182
+ Pins the current layout
214
183
  </Text>
215
184
  </View>
216
- </TouchableRipple>
217
- )}
185
+ </View>
186
+ </TouchableRipple>
187
+ )}
218
188
 
219
- {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
220
- it there too, for a game that's deliberately silent on vibration. */}
221
- {showHaptics && (
222
- <TouchableRipple
223
- hapticDisabled
224
- onPress={() => {
225
- const vibrate = !hapticSettings.vibrate
226
- setHapticSettings({ vibrate })
227
- if (vibrate) forceHapticFeedback()
228
- }}
229
- style={styles.toggleButtonInRow}
230
- accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
231
- >
232
- <View style={styles.toggleContent}>
233
- <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
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
+ and the bottom (home-indicator swipe, incidentally Reachability too), per that
192
+ package's own doc. Off by default: a deliberate opt-in for whoever's actually hit an
193
+ accidental-swipe-near-an-edge problem mid-game and come looking for a fix, not a
194
+ surprise every player gets by default. */}
195
+ {showEdgeGuard && (
196
+ <TouchableRipple onPress={() => onDeferBottomEdgeGestures!(!deferBottomEdgeGestures)} style={styles.toggleButton} accessibilityLabel={`Edge guard ${deferBottomEdgeGestures ? 'on' : 'off'}`}>
197
+ <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} />
199
+ <View style={styles.flexShrink}>
234
200
  <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
235
- Haptics
201
+ Edge Guard
202
+ </Text>
203
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
204
+ Requires a second swipe
236
205
  </Text>
237
206
  </View>
238
- </TouchableRipple>
239
- )}
240
- </View>
241
- )}
242
-
243
- {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
244
- down in `children` (which sits below Appearance) — this is the "replay the intro"
245
- entry point every game's own how-to-play flow shares. Dismisses this dialog first so
246
- the guide's own overlay never opens on top of a still-visible Settings card. */}
247
- {onShowHowToPlay && (
248
- <TouchableRipple
249
- onPress={() => {
250
- onDismiss()
251
- onShowHowToPlay()
252
- }}
253
- style={styles.toggleButton}
254
- accessibilityLabel='How to Play'
255
- >
256
- <View style={styles.toggleContent}>
257
- <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
258
- <View style={styles.flexShrink}>
259
- <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
260
- How to Play
261
- </Text>
262
- <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
263
- Replay the quick intro
264
- </Text>
265
207
  </View>
208
+ </TouchableRipple>
209
+ )}
210
+
211
+ {/* Side by side, not stacked — neither needs a full row's width (single-line label,
212
+ no description under it the way Lock Orientation has), so sharing one row reads
213
+ just as clearly and takes half the vertical space. toggleRow carries the same
214
+ edge-alignment negative margin toggleButton normally carries itself; the buttons
215
+ 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
217
+ still eats its own gap/margin otherwise. */}
218
+ {(showSound || showHaptics) && (
219
+ <View style={styles.toggleRow}>
220
+ {showSound && (
221
+ <TouchableRipple
222
+ soundDisabled
223
+ onPress={() => {
224
+ const enabled = !soundSettings.enabled
225
+ setSoundSettings({ enabled })
226
+ if (enabled) sound.selection?.()
227
+ }}
228
+ style={styles.toggleButtonInRow}
229
+ accessibilityLabel={`Sound ${soundSettings.enabled ? 'on' : 'off'}`}
230
+ >
231
+ <View style={styles.toggleContent}>
232
+ <SettingIcon source={soundSettings.enabled ? 'volume-high' : 'volume-off'} color={soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant} containerColor={soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant} />
233
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
234
+ Sound
235
+ </Text>
236
+ </View>
237
+ </TouchableRipple>
238
+ )}
239
+
240
+ {/* Already native-only (see showHaptics's own definition) — hideHaptics can hide
241
+ it there too, for a game that's deliberately silent on vibration. */}
242
+ {showHaptics && (
243
+ <TouchableRipple
244
+ hapticDisabled
245
+ onPress={() => {
246
+ const vibrate = !hapticSettings.vibrate
247
+ setHapticSettings({ vibrate })
248
+ if (vibrate) forceHapticFeedback()
249
+ }}
250
+ style={styles.toggleButtonInRow}
251
+ accessibilityLabel={`Haptics ${hapticSettings.vibrate ? 'on' : 'off'}`}
252
+ >
253
+ <View style={styles.toggleContent}>
254
+ <SettingIcon source={hapticSettings.vibrate ? 'vibrate' : 'vibrate-off'} color={hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant} containerColor={hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant} />
255
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
256
+ Haptics
257
+ </Text>
258
+ </View>
259
+ </TouchableRipple>
260
+ )}
266
261
  </View>
267
- </TouchableRipple>
268
- )}
269
- </View>
262
+ )}
270
263
 
271
- {showAppearance && (
272
- <View style={styles.section}>
273
- <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
274
- APPEARANCE
275
- </Text>
276
- <AutoAppearancePicker showLabels={false} />
264
+ {/* 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"
266
+ entry point every game's own how-to-play flow shares. Dismisses this dialog first so
267
+ the guide's own overlay never opens on top of a still-visible Settings card. */}
268
+ {onShowHowToPlay && (
269
+ <TouchableRipple
270
+ onPress={() => {
271
+ onDismiss()
272
+ onShowHowToPlay()
273
+ }}
274
+ style={styles.toggleButton}
275
+ accessibilityLabel='How to Play'
276
+ >
277
+ <View style={styles.toggleContent}>
278
+ <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
279
+ <View style={styles.flexShrink}>
280
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
281
+ How to Play
282
+ </Text>
283
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
284
+ Replay the quick intro
285
+ </Text>
286
+ </View>
287
+ </View>
288
+ </TouchableRipple>
289
+ )}
277
290
  </View>
278
- )}
279
291
 
280
- {children}
292
+ {showAppearance && (
293
+ <View style={styles.section}>
294
+ <Text variant='labelMedium' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
295
+ APPEARANCE
296
+ </Text>
297
+ <AutoAppearancePicker showLabels={false} />
298
+ </View>
299
+ )}
281
300
 
282
- {/* Native only (a web build has no OTA update concept at all, desktop or mobile browser
283
- alike) — hideUpdateCheck can hide it there too, for a native app that isn't wired up
284
- for EAS/OTA updates at all. */}
285
- {showUpdateCheck && (
286
- <View style={styles.section}>
287
- <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
288
- VERSION {version}
289
- {updateReady ? ' · UPDATE READY' : ''}
290
- </Text>
291
- <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
292
- Check for Updates
293
- </Button>
294
- </View>
295
- )}
296
- </ScrollView>
297
- </Dialog.ScrollArea>
298
- </Dialog>
299
- <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={rotation} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
300
- <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={rotation} onConfirm={() => setInfoMessage(null)} />
301
- </>
301
+ {children}
302
+
303
+ {/* 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
305
+ for EAS/OTA updates at all. */}
306
+ {showUpdateCheck && (
307
+ <View style={styles.section}>
308
+ <Text variant='labelSmall' style={[styles.sectionLabel, { color: colors.onSurfaceVariant }]}>
309
+ VERSION {version}
310
+ {updateReady ? ' · UPDATE READY' : ''}
311
+ </Text>
312
+ <Button mode='outlined' onPress={check} loading={checking} disabled={checking}>
313
+ Check for Updates
314
+ </Button>
315
+ </View>
316
+ )}
317
+ </ScrollView>
318
+ </Dialog.ScrollArea>
319
+ </Dialog>
320
+ <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)} />
321
+ <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
322
+ </RotatedFrame>
323
+ </Portal>
302
324
  )
303
325
  }
304
326
 
@@ -0,0 +1,71 @@
1
+ import { FakeLandscapeView, rotateDimensions, rotateInsets, useRotation, type ViewRotation } from '@tastic/core'
2
+ import { type ReactNode, useMemo } from 'react'
3
+ import { StyleSheet, useWindowDimensions, View } from 'react-native'
4
+ import { Portal } from 'react-native-paper'
5
+ import { SafeAreaInsetsContext, useSafeAreaInsets } from 'react-native-safe-area-context'
6
+
7
+ export interface RotatedFrameProps {
8
+ children: ReactNode
9
+ /** The angle to turn the frame by. Defaults to the live physical hold (@tastic/core's useRotation); pass it
10
+ * when the caller already has the exact angle it wants, e.g. a dialog handed its caller's rotation, or 0 for
11
+ * one that already sits inside a turned frame. */
12
+ rotation?: ViewRotation
13
+ /** Give everything inside the frame the safe-area insets already remapped onto the frame's own edges (see
14
+ * @tastic/core's rotateInsets), so a component that reads `useSafeAreaInsets()` itself (a library screen,
15
+ * Paper's Modal, a toaster) lands on the right edge when the frame is turned. Leave it off for content that
16
+ * rotates the insets itself, which would otherwise rotate them twice. */
17
+ rotatedSafeArea?: boolean
18
+ /** Mount a Paper Portal.Host INSIDE the frame, so a dialog (Paper's Portal otherwise mounts at the app root,
19
+ * outside the frame) is laid out against the frame's footprint and turned with it. The dialog must then be
20
+ * told `rotation={0}`. */
21
+ portalHost?: boolean
22
+ /** Let touches fall through the frame to whatever is beneath it, so only its children claim them. For a frame
23
+ * laid OVER other content (a dialog layer, a root-level overlay); a screen that owns the window leaves it off. */
24
+ passThrough?: boolean
25
+ /** Cap on the content column's width in the WIDE layout only (the rotated footprint is landscape), centred: a
26
+ * stats list or a roster stretched across an 874pt-wide landscape screen reads badly. Portrait is never capped,
27
+ * so a portrait iPad or desktop window keeps its full width. Omit for no cap. */
28
+ maxContentWidth?: number
29
+ }
30
+
31
+ /** A frame turned to follow the phone while the OS stays portrait-locked (see @tastic/core's FakeLandscapeView),
32
+ * sized to the turned footprint so its content lays out against the short edge at +/-90. The tree is IDENTICAL
33
+ * at every angle, so a rotation never remounts what is inside: `FakeLandscapeView > SafeArea provider >
34
+ * [Portal.Host] > column`. `rotation`, `rotatedSafeArea`, `passThrough` and `maxContentWidth` only change styles
35
+ * and values, but `portalHost` adds or removes the Portal.Host element, so it must be STATIC per call site
36
+ * (flipping it at runtime would remount everything inside).
37
+ *
38
+ * Safe for tap-driven content only: react-native-gesture-handler's translation deltas are untransformed, so
39
+ * never put a gesture-tracked game board inside it. */
40
+ export function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea = false, portalHost = false, passThrough = false, maxContentWidth }: RotatedFrameProps) {
41
+ const ambientRotation = useRotation()
42
+ const rotation = rotationOverride ?? ambientRotation
43
+ const window = useWindowDimensions()
44
+ const { width, height } = rotateDimensions(window.width, window.height, rotation)
45
+ const rawInsets = useSafeAreaInsets()
46
+ const insets = useMemo(() => (rotatedSafeArea ? rotateInsets(rawInsets, rotation) : rawInsets), [rotatedSafeArea, rawInsets, rotation])
47
+ const cappedWidth = maxContentWidth !== undefined && width > height ? maxContentWidth : undefined
48
+ const column = (
49
+ <View style={[styles.column, cappedWidth !== undefined && { maxWidth: cappedWidth }]} pointerEvents={passThrough ? 'box-none' : undefined}>
50
+ {children}
51
+ </View>
52
+ )
53
+
54
+ return (
55
+ <FakeLandscapeView rotation={rotation} passThrough={passThrough} style={styles.root}>
56
+ <SafeAreaInsetsContext.Provider value={insets}>{portalHost ? <Portal.Host>{column}</Portal.Host> : column}</SafeAreaInsetsContext.Provider>
57
+ </FakeLandscapeView>
58
+ )
59
+ }
60
+
61
+ const styles = StyleSheet.create({
62
+ // A centred column: full width up to `maxWidth`, so a capped frame sits in the middle of a wider window.
63
+ column: {
64
+ alignSelf: 'center',
65
+ flex: 1,
66
+ width: '100%'
67
+ },
68
+ root: {
69
+ flex: 1
70
+ }
71
+ })
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type Pl
18
18
  export { PopoverBody } from './PopoverBody'
19
19
  export { PressAwayOverlay } from './PressAwayOverlay'
20
20
  export { ReadyButton } from './ReadyButton'
21
+ export { RotatedFrame, type RotatedFrameProps } from './RotatedFrame'
21
22
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
22
23
  export { SharedActionBand } from './SharedActionBand'
23
24
  export { SkiaGate, type SkiaGateProps } from './SkiaGate'