@tastic/shell 0.4.0 → 0.5.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.
@@ -1,12 +1,11 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { useHapticSettings } from '@rific/feedback-press'
3
3
  import { Toaster, ToastProvider } from '@rific/toaster'
4
- import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core'
4
+ import { useThemedRootBackground } from '@tastic/core'
5
5
  import { RotatedFrame } from '@tastic/hud/chrome'
6
6
  import { ProfileColorProvider } from '@tastic/profile'
7
7
  import * as Haptics from 'expo-haptics'
8
8
  import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
9
- import { DeviceMotion } from 'expo-sensors'
10
9
  import { ReactNode, useCallback, useMemo } from 'react'
11
10
  import { Platform, StyleSheet, View } from 'react-native'
12
11
  import { GestureHandlerRootView } from 'react-native-gesture-handler'
@@ -19,7 +18,6 @@ import { GuideHost } from './components/GuideHost'
19
18
  import { Providers } from './components/Providers'
20
19
  import { Theme } from './components/Theme'
21
20
  import { UpdateDialog } from './components/UpdateDialog'
22
- import { useAppSettings } from './hooks/useAppSettings'
23
21
  import { LastActiveGameProvider } from './hooks/useLastActiveGame'
24
22
  import { ProfilesProvider, useProfiles } from './hooks/useProfiles'
25
23
  import { PackProvider, usePack } from './PackContext'
@@ -38,15 +36,13 @@ import type { PackDefinition, PackGameDefinition } from './types'
38
36
  // a play screen and wins (confirmed on-device in LightCycles, where it kicked the player back to the title
39
37
  // screen mid-round instead of turning their cycle). Web has no such OS-level gesture.
40
38
  //
41
- // useRotationAwareStatusBar (@tastic/core) hides the OS status bar whenever the content is rotated
42
- // off portrait. It does it through screen options because that is the only way left: an app linked
43
- // against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar
44
- // (and expo-status-bar) does on iOS, and with @tastic/core's config plugin on, every mounted
45
- // StatusBar logs an error. So nothing in the app mounts one (Theme passes auto-paper's Provider
46
- // statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it
47
- // follows. A play screen hides the bar for the whole round, whatever the angle (a screen's own
48
- // options win over screenOptions); index is listed first so it stays the stack's first route.
49
- // A game with keepStatusBar (Solitaire's) keeps the screen options' rotation-aware bar instead.
39
+ // The status bar is set through screen options because that is the only way left: an app linked
40
+ // against the iOS 27 SDK can't set the status bar app-wide, which is all React Native's StatusBar (and
41
+ // expo-status-bar) does on iOS, and with UIViewControllerBasedStatusBarAppearance on (each app's
42
+ // ios.infoPlist), every mounted StatusBar logs an error. So nothing in the app mounts one (Theme passes
43
+ // auto-paper's Provider statusBar={false}). A play screen hides the bar for the whole round (a screen's
44
+ // own options win over screenOptions); index is listed first so it stays the stack's first route. A
45
+ // game with keepStatusBar (Solitaire's) keeps the stack's bar instead.
50
46
  const PLAY_OPTIONS = { statusBarHidden: true }
51
47
  const KEEP_STATUS_BAR_OPTIONS = {}
52
48
 
@@ -76,7 +72,7 @@ function AppStack() {
76
72
  const rootBackground = useThemedRootBackground(darkRoot, false)
77
73
  // The status bar's text follows the app's theme, not the phone's appearance (react-native-screens'
78
74
  // default `auto`), which drew dark text on a dark theme's black Home with the phone set to light.
79
- const statusBar = useRotationAwareStatusBar({ style: darkRoot ? 'light' : 'dark' })
75
+ const statusBarStyle = darkRoot ? ('light' as const) : ('dark' as const)
80
76
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
81
77
  // Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
82
78
  // the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
@@ -96,7 +92,7 @@ function AppStack() {
96
92
  }, [darkRoot, navigationBackground])
97
93
  const stack = (
98
94
  <NavigationThemeProvider value={navigationTheme}>
99
- <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
95
+ <Stack screenOptions={{ ...rootBackground, statusBarStyle, ...backdropOptions, ...packOptions }}>
100
96
  <Stack.Screen name='index' />
101
97
  <Stack.Screen name='play/[game]' options={playOptions} />
102
98
  </Stack>
@@ -111,38 +107,6 @@ function AppStack() {
111
107
  )
112
108
  }
113
109
 
114
- // Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own
115
- // component because RootLayout renders the Redux store (inside Providers) and so can't call
116
- // useAppSettings() itself. Providers' PersistGate holds rendering until the store has rehydrated, so
117
- // lockInitialValue carries the saved lock from the very first render.
118
- //
119
- // freezeWhileLocked makes the lock app-wide: while appSettings.lockOrientation is on, the ONE shared
120
- // orientation reading freezes for every consumer (every screen, every board, hud dialogs, the
121
- // status bar), including anything that mounts after the lock, so no call site ever takes a `locked`
122
- // argument of its own (one would freeze on the pre-sensor default at mount). The Settings dialog
123
- // toggle only writes appSettings.lockOrientation, which reaches the provider as a changed
124
- // lockInitialValue; the provider reports the angle it froze on through onLockChange (`rotation`,
125
- // cleared on unlock), persisted as appSettings.lockedRotation and handed back here so a relaunch reopens
126
- // in the locked orientation instead of portrait.
127
- //
128
- // Mounted once, here, rather than per screen: one app-lifetime sensor subscription is what lets the
129
- // committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed
130
- // in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would
131
- // otherwise need it installed just to bundle).
132
- //
133
- // portraitWhileKeyboard (PackDefinition.portraitWhileKeyboard): the software keyboard can't turn with a
134
- // faked rotation, so a pack that has the player type sideways (a profile's name) reads portrait while
135
- // it's up.
136
- function OrientationBridge({ children }: { children: ReactNode }) {
137
- const { settings, setSettings } = useAppSettings()
138
- const { portraitWhileKeyboard } = usePack()
139
- return (
140
- <OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })} portraitWhileKeyboard={portraitWhileKeyboard}>
141
- {children}
142
- </OrientationProvider>
143
- )
144
- }
145
-
146
110
  // ProfilesProvider reconciles the local roster with the shared App Group one before `loaded` flips,
147
111
  // so a screen mounting earlier would read (and could persist) the wrong profiles. This holds
148
112
  // everything nested inside it, and the splash screen, until that's done, while ProfilesProvider sits
@@ -163,7 +127,7 @@ function GatedApp({ children }: { children: ReactNode }) {
163
127
  // either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting.
164
128
  //
165
129
  // A pack with its own Toaster gets no Paper here: with Paper the toaster draws through a Paper Portal,
166
- // which would take its toasts out of the turned frame PackToaster lays them in.
130
+ // which would take its toasts out of the frame PackToaster lays them in.
167
131
  function HapticGatedToastProvider({ children }: { children: ReactNode }) {
168
132
  const { settings } = useHapticSettings()
169
133
  const { Toaster: PackToasterComponent } = usePack()
@@ -186,10 +150,10 @@ function RotatingToaster() {
186
150
  return <Toaster historyButton={null} clearButton={null} limit={3} renderFrame={renderFrame} />
187
151
  }
188
152
 
189
- // The pack's own toaster (PackDefinition.Toaster), laid over the whole app in a frame turned with the
190
- // phone: passThrough, so only a toast claims a touch over the stack, and rotatedSafeArea, so the
191
- // toaster's own safe-area reading lands on the frame's edges. The absoluteFill wrapper takes the frame
192
- // out of the layout beside the stack.
153
+ // The pack's own toaster (PackDefinition.Toaster), laid over the whole app in hud's RotatedFrame, which
154
+ // nothing turns since @tastic/core 0.15: passThrough, so only a toast claims a touch over the stack, and
155
+ // rotatedSafeArea, so the toaster reads the safe area. The absoluteFill wrapper takes the frame out of
156
+ // the layout beside the stack.
193
157
  function PackToaster({ Toaster: PackToasterComponent }: { Toaster: NonNullable<PackDefinition['Toaster']> }) {
194
158
  return (
195
159
  <View style={StyleSheet.absoluteFill} pointerEvents='box-none'>
@@ -243,24 +207,21 @@ export function RootLayout({ pack, store, persistor }: RootLayoutProps) {
243
207
  <GameProviders games={pack.games}>
244
208
  <PackRoot pack={pack}>
245
209
  <GatedApp>
246
- <OrientationBridge>
247
- <HapticGatedToastProvider>
248
- {/* Wraps the stack (rather than sitting beside it like UpdateDialog)
210
+ <HapticGatedToastProvider>
211
+ {/* Wraps the stack (rather than sitting beside it like UpdateDialog)
249
212
  because Home's tiles and the loadout's How to Play row reach it through
250
- context (see GuideHost). Inside the toast provider and OrientationBridge
251
- because the card renders through a Portal and rotates with the shared
252
- reading, and inside the gate so it mounts together with Home rather than
253
- before it. */}
254
- <GuideHost>
255
- <AppStack />
256
- </GuideHost>
257
- {/* historyButton/clearButton off: there's no toast history to open, and a
213
+ context (see GuideHost). Inside the toast provider because the card renders
214
+ through a Portal, and inside the gate so it mounts together with Home rather
215
+ than before it. */}
216
+ <GuideHost>
217
+ <AppStack />
218
+ </GuideHost>
219
+ {/* historyButton/clearButton off: there's no toast history to open, and a
258
220
  control row for it above the toast stack would go nowhere. limit pins the
259
221
  package's current default (3) so a future toaster release can't change it. */}
260
- {pack.Toaster ? <PackToaster Toaster={pack.Toaster} /> : <RotatingToaster />}
261
- <UpdateDialog />
262
- </HapticGatedToastProvider>
263
- </OrientationBridge>
222
+ {pack.Toaster ? <PackToaster Toaster={pack.Toaster} /> : <RotatingToaster />}
223
+ <UpdateDialog />
224
+ </HapticGatedToastProvider>
264
225
  </GatedApp>
265
226
  </PackRoot>
266
227
  </GameProviders>
@@ -1,4 +1,4 @@
1
- import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
1
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
2
  import { GuideProvider, useGuide } from '@tastic/hud/guide'
3
3
  import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux'
@@ -38,9 +38,8 @@ interface Props {
38
38
  // the pack's own (PackDefinition.guide, cards that teach every game at once), or a game's. Each
39
39
  // game's cards come from its own registry entry (GameDefinition.guide), built from the live settings
40
40
  // and seat 2 so a replay after any loadout change describes the next match. Mounted around the stack
41
- // in RootLayout, inside the toast provider and OrientationBridge because the card renders through a
42
- // Portal and turns with the shared reading (`rotation`, the same one Home hands its Settings dialog),
43
- // and inside the profiles gate so it mounts together with Home.
41
+ // in RootLayout, inside the toast provider because the card renders through a Portal, and inside the
42
+ // profiles gate so it mounts together with Home.
44
43
  export function GuideHost({ children }: Props) {
45
44
  const pack = usePack()
46
45
  const [subject, setSubject] = useState(() => (pack.guide ? PACK_GUIDE : (pack.games.find((g) => g.guide)?.id ?? null)))
@@ -48,7 +47,6 @@ export function GuideHost({ children }: Props) {
48
47
  const game = subject === PACK_GUIDE ? undefined : pack.games.find((g) => g.id === subject)
49
48
  const gameGuide = game?.guide
50
49
 
51
- const rotation = useRotation()
52
50
  const touch = useIsTouchPrimaryDevice()
53
51
  const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
54
52
  const input = useSelector((state: ShellState) => (gameGuide ? gameGuide.selectInput(state) : NO_INPUT), shallowEqual)
@@ -68,7 +66,7 @@ export function GuideHost({ children }: Props) {
68
66
  )
69
67
 
70
68
  return (
71
- <GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen} rotation={rotation}>
69
+ <GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen}>
72
70
  <GameGuideBridge onPick={setSubject}>{children}</GameGuideBridge>
73
71
  </GuideProvider>
74
72
  )
@@ -1,5 +1,4 @@
1
1
  import { useUpdateErrorToast } from '@rific/toaster'
2
- import { ViewRotation } from '@tastic/core'
3
2
  import { BaseSettingsDialog } from '@tastic/hud/chrome'
4
3
  import type { ReactNode } from 'react'
5
4
 
@@ -9,11 +8,6 @@ import { usePack } from '../PackContext'
9
8
  export interface SettingsDialogProps {
10
9
  visible: boolean
11
10
  onDismiss: () => void
12
- // Live physical-hold rotation (see @tastic/core's useRotation). BaseSettingsDialog lays the sheet
13
- // out in its own frame turned by this (see @tastic/hud's RotatedFrame), so it reads right-side-up
14
- // and fits the short edge however the phone is held. Defaults to 0 for a call site that doesn't
15
- // track one.
16
- rotation?: ViewRotation
17
11
  // Shows the How to Play row, which opens this game's cards (a loadout passes its game's opener).
18
12
  // Left out on Home, where each tile's rules button does it, and in a match, where opening
19
13
  // Settings pauses play.
@@ -23,12 +17,12 @@ export interface SettingsDialogProps {
23
17
  children?: ReactNode
24
18
  }
25
19
 
26
- // The pack-wide Settings dialog: everything the shell puts in it applies to every game (Lock
27
- // Orientation, Edge Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app
20
+ // The pack-wide Settings dialog: everything the shell puts in it applies to every game (Edge Guard,
21
+ // theme, sound, haptics, Keep Awake), so it reads and writes the app
28
22
  // settings itself. Under those, a play screen's own rows and then the pack's own sections
29
23
  // (PackDefinition.SettingsSections), in every Settings dialog the pack opens. A pack with edgeGuard
30
24
  // false gets no Edge Guard row: hud draws it on iOS whenever its props are passed.
31
- export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }: SettingsDialogProps) {
25
+ export function SettingsDialog({ visible, onDismiss, onShowHowToPlay, children }: SettingsDialogProps) {
32
26
  const onUpdateError = useUpdateErrorToast()
33
27
  const { settings, setSettings } = useAppSettings()
34
28
  // The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
@@ -36,7 +30,7 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPl
36
30
  const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value: boolean) => setSettings({ deferBottomEdgeGestures: value }) } : {}
37
31
 
38
32
  return (
39
- <BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
33
+ <BaseSettingsDialog visible={visible} onDismiss={onDismiss} version={otaVersion} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
40
34
  {children}
41
35
  {SettingsSections && <SettingsSections />}
42
36
  </BaseSettingsDialog>
@@ -61,8 +61,9 @@ export function Theme({ children }: Props) {
61
61
  // iOS/Android (the Settings sheet's and Rules' rows above Done, @tastic/profile's dropdown lists
62
62
  // above Manage); without it they just scroll with a hard edge. Web needs nothing.
63
63
  // statusBar={false}: auto-paper renders no React Native StatusBar (here or in Appbar.Header). The
64
- // status bar is set per screen (_layout.tsx's useRotationAwareStatusBar), and with @tastic/core's
65
- // config plugin on, a mounted StatusBar logs an error (a red box in a dev build).
64
+ // status bar is set through the stack's screen options (RootLayout's AppStack), and with
65
+ // UIViewControllerBasedStatusBarAppearance on (each app's ios.infoPlist), a mounted StatusBar logs an
66
+ // error (a red box in a dev build).
66
67
  // reanimated is what makes Dialog's own animatedStyle prop do anything (Metro won't pick up a
67
68
  // require()-in-try/catch from this package's ESM build, so the module is handed in); nothing else in
68
69
  // auto-paper reads it. fontFamily is the pack's (PackDefinition.fontFamily): set, it goes on every
@@ -1,5 +1,4 @@
1
1
  import { useUpdateErrorToast } from '@rific/toaster'
2
- import { useRotation } from '@tastic/core'
3
2
  import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
4
3
 
5
4
  // Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
@@ -8,6 +7,5 @@ import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
8
7
  // directly in _layout.tsx) purely so this app's own onError -> toast wiring has one place to live.
9
8
  export function UpdateDialog() {
10
9
  const onError = useUpdateErrorToast()
11
- const rotation = useRotation()
12
- return <SharedUpdateDialog onError={onError} rotation={rotation} />
10
+ return <SharedUpdateDialog onError={onError} />
13
11
  }
@@ -5,9 +5,9 @@ import { useState } from 'react'
5
5
  // Solitaire's components/useFrozenWhileHidden.ts as it was.
6
6
  //
7
7
  // Home holds its layout with it while another screen covers it (screens/HomeScreen.tsx): Home stays
8
- // mounted beneath every screen the stack pushes and keeps following the rotation, and crossing the
8
+ // mounted beneath every screen the stack pushes and keeps following the window, and crossing the
9
9
  // upright/wide threshold swaps its whole tree (every tile, the hero, seat 1's button), invisible work
10
- // landing in the same commit as the covering screen's own rotation update. While covered the layout is
10
+ // landing in the same commit as the covering screen's own update for the new window. While covered the layout is
11
11
  // held where it was; it catches up (one swap, visible) when Home is shown again.
12
12
  export function useFrozenWhileHidden<T>(value: T, hidden: boolean): T {
13
13
  // React's own sanctioned "adjust state during render" shape (see react.dev's guide of the same
@@ -1,24 +1,13 @@
1
1
  import { createSettingsSlice } from '@rific/core'
2
- import type { ViewRotation } from '@tastic/core'
3
2
 
4
3
  // Settings that apply to the whole pack rather than one game. A game's own rules (board, score to
5
4
  // win, controls) live in that game's own settings slice.
6
5
  export interface AppSettings {
7
- // Lock Orientation: RootLayout's OrientationBridge hands it to @tastic/core's OrientationProvider
8
- // (freezeWhileLocked), which freezes the one shared orientation reading for every screen, every
9
- // board, dialogs and the status bar alike. Off by default: all of those just follow however the
10
- // device is actually being held.
11
- lockOrientation: boolean
12
- // The angle the lock is holding. Written by OrientationProvider's onLockChange (recorded on lock,
13
- // cleared on unlock) and handed back as lockInitialValue, so a relaunch reopens in the locked
14
- // orientation instead of portrait. Never set by the UI directly; absent while unlocked.
15
- lockedRotation?: ViewRotation
16
6
  // Edge Guard's opt-in (see Providers.tsx's EdgeGuardBridge).
17
7
  deferBottomEdgeGestures: boolean
18
8
  }
19
9
 
20
10
  export const DEFAULT_APP_SETTINGS: AppSettings = {
21
- lockOrientation: false,
22
11
  deferBottomEdgeGestures: false
23
12
  }
24
13
 
@@ -1,6 +1,5 @@
1
1
  import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { IconButton, Pressable } from '@rific/feedback-press'
3
- import { FakeLandscapeView, useRotation } from '@tastic/core'
4
3
  import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
5
4
  import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
6
5
  import { Redirect, router, useIsFocused } from 'expo-router'
@@ -53,7 +52,6 @@ export function HomeScreen() {
53
52
 
54
53
  function HomeMenu() {
55
54
  const pack = usePack()
56
- const rotation = useRotation()
57
55
  const layout = useHomeLayout()
58
56
  const { insets } = layout
59
57
  const { dark, colors } = useAutoPaperTheme()
@@ -79,7 +77,7 @@ function HomeMenu() {
79
77
 
80
78
  // Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
81
79
  // the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
82
- // following the rotation, so while it's covered the layout is held where it was (see
80
+ // following the window, so while it's covered the layout is held where it was (see
83
81
  // useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
84
82
  // the panes' numbers follow the window, unseen under the screen covering Home.
85
83
  const tiled = pack.games.some((game) => game.Preview)
@@ -95,13 +93,13 @@ function HomeMenu() {
95
93
  return () => cancelAnimationFrame(id)
96
94
  }, [tiled])
97
95
  // A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
98
- // phone (or any resize) moves the frame under it, and switching between the column and the two panes
96
+ // device (or any resize) moves the frame under it, and switching between the column and the two panes
99
97
  // unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
100
98
  // dismiss. So every layout change closes it.
101
99
  const closePicker = pickerHost.close
102
100
  useEffect(() => {
103
101
  closePicker()
104
- }, [isWide, rotation, layout.windowWidth, layout.windowHeight, closePicker])
102
+ }, [isWide, layout.windowWidth, layout.windowHeight, closePicker])
105
103
 
106
104
  // Over the pack's Backdrop Home draws nothing of its own behind the menu.
107
105
  const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
@@ -150,10 +148,9 @@ function HomeMenu() {
150
148
 
151
149
  return (
152
150
  <View style={[styles.container, { backgroundColor: bg }]}>
153
- {/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The
154
- Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. A
155
- game's rules sheet renders inside it instead (see RulesHost). */}
156
- <FakeLandscapeView style={styles.rotatable}>
151
+ {/* The Settings dialog stays outside this frame: it renders through a Portal. A game's rules
152
+ sheet renders inside it instead (see RulesHost). */}
153
+ <View style={styles.frame}>
157
154
  <RulesHost enabled={pack.games.some((game) => game.Rules)}>
158
155
  {isWide ? (
159
156
  <>
@@ -201,11 +198,11 @@ function HomeMenu() {
201
198
  <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
202
199
  {RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
203
200
  </RulesHost>
204
- </FakeLandscapeView>
201
+ </View>
205
202
 
206
203
  {/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
207
204
  tile's rules button shows its game's. */}
208
- <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={pack.guide ? openPack : undefined} />
205
+ <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} onShowHowToPlay={pack.guide ? openPack : undefined} />
209
206
  </View>
210
207
  )
211
208
  }
@@ -258,9 +255,8 @@ function hasRules(game: PackGameDefinition): boolean {
258
255
  return !!(game.Rules || game.guide)
259
256
  }
260
257
 
261
- // A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's turned frame, so
262
- // it's turned with the frame and laid out against it (a sheet laid out against the upright window ran
263
- // off a phone held sideways). Only in a pack with one: a pack without keeps the frame as it was.
258
+ // A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's frame, so it's
259
+ // laid out against Home's safe area. Only in a pack with one: a pack without keeps the frame as it was.
264
260
  function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
265
261
  return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
266
262
  }
@@ -386,6 +382,9 @@ const styles = StyleSheet.create({
386
382
  corner: {
387
383
  position: 'absolute'
388
384
  },
385
+ frame: {
386
+ flex: 1
387
+ },
389
388
  // The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
390
389
  grid: {
391
390
  flexDirection: 'row',
@@ -432,9 +431,6 @@ const styles = StyleSheet.create({
432
431
  previewIcon: {
433
432
  overflow: 'hidden'
434
433
  },
435
- rotatable: {
436
- flex: 1
437
- },
438
434
  // A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
439
435
  // as Solitaire's game rows, the stats screen's sections and every dialog.
440
436
  row: {
@@ -1,12 +1,12 @@
1
1
  import { defaultColors, getRgb, useAutoPaperTheme } from '@rific/auto-paper'
2
- import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core'
2
+ import { useOrientationState } from '@tastic/core'
3
3
  import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from '@tastic/hud/chrome'
4
4
  import { PlayerSetupPanel, ReadyButton, SharedActionBand } from '@tastic/hud/lobby'
5
5
  import type { Profile } from '@tastic/profile'
6
6
  import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen'
7
7
  import { router, useFocusEffect } from 'expo-router'
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
9
- import { Platform, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
9
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
10
10
  import Animated from 'react-native-reanimated'
11
11
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
12
12
  import { useDispatch } from 'react-redux'
@@ -24,11 +24,11 @@ import { liveplayActions } from '../redux/liveplaySlice'
24
24
  import { playHref, PROFILES_HREF } from '../routes'
25
25
  import { humanSeatsFor, type PackGameDefinition, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
26
26
 
27
- // The player-panel area: whenever a real device rotation changes orientationMode or which physical
28
- // side P1 lands on, the panels fade out, the layout underneath swaps while invisible, then they fade
29
- // back in - masking what would otherwise be an instant jump-cut as panels reflow or swap sides.
30
- // Short and symmetric on purpose: this is masking a reflow that already happened at the OS's own
31
- // pace, not a deliberate reveal.
27
+ // The player-panel area: whenever the window changes shape (a tablet turning, a browser window
28
+ // resized) and the panels go from face to face to side by side or back, they fade out, the layout
29
+ // underneath swaps while invisible, then they fade back in - masking what would otherwise be an
30
+ // instant jump-cut as panels reflow. Short and symmetric on purpose: this is masking a reflow that
31
+ // already happened at the OS's own pace, not a deliberate reveal.
32
32
  const PANEL_SWAP_FADE_MS = 180
33
33
 
34
34
  // A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
@@ -53,32 +53,24 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
53
53
  const { openFor } = useGameGuide()
54
54
 
55
55
  const { profiles, lastSelected, selectProfile } = useProfiles()
56
- // orientationMode just follows the device's current physical tilt (see @tastic/core's
57
- // useOrientationState), CPU games included - how you're holding the phone right now decides the
58
- // panel arrangement, not a stored per-round choice. Lock Orientation pins it app-wide by freezing
59
- // the shared reading itself (RootLayout's OrientationBridge), so this read takes no `locked`
60
- // argument. The OS is portrait-locked; FakeLandscapeView below fakes the rest in JS.
61
- const { orientationMode, p1OnRight, upsideDown, resolved: p1OnRightResolved } = useOrientationState()
56
+ // The window's shape (@tastic/core's useOrientationState): side by side in a window wider than tall,
57
+ // face to face otherwise. The OS turns the interface; a portrait-locked phone is always face to face.
58
+ const { orientationMode } = useOrientationState()
62
59
 
63
- // The panel area's own layout lags one fade behind the live orientation above - see
64
- // useDualZoneLayout's own comment. 2 Player seats its players the way the game's own board does
65
- // (GameDefinition.twoPlayerSeats): face to face however the phone is held, P1 in the bottom half
66
- // and P2 in the top, flipped for an upside-down hold where the game flips too, so each player gets
67
- // the same half here and in the match. On the web a game may seat them side by side instead.
68
- const fixedSeats = p2IsHuman && (!!game.twoPlayerSeats?.faceToFaceOnWeb || Platform.OS !== 'web')
69
- const fixedUpsideDown = !!game.twoPlayerSeats?.followUpsideDown && orientationMode === 'faceToFace' && upsideDown
70
- const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS)
60
+ // The panel area's own layout lags one fade behind the window above - see useDualZoneLayout's own
61
+ // comment. 2 Player seats its players the way the game's own board does: face to face (P1 in the
62
+ // bottom half, P2 in the top) in any window, unless the game seats them side by side in a wide one
63
+ // (GameDefinition.sideBySideWhenWide), P1 on the left, so each player gets the same half here and
64
+ // in the match. With one person the panels follow the window.
65
+ const panelMode = p2IsHuman && !game.sideBySideWhenWide ? 'faceToFace' : orientationMode
66
+ const { panelLayout, panelFadeStyle } = useDualZoneLayout(panelMode, false, true, false, PANEL_SWAP_FADE_MS)
71
67
  // split-screen types the fade as a style for any component (useAnimatedStyle's DefaultStyle). An app
72
68
  // whose types widen Text's style (Expo's react-native-web types, which a generated expo-env.d.ts pulls
73
69
  // in, make its cursor any string) can't hand that to Animated.View. It only ever sets opacity.
74
70
  const playersRowFade = panelFadeStyle as unknown as StyleProp<ViewStyle>
75
71
 
76
72
  const [settingsOpen, setSettingsOpen] = useState(false)
77
- // Safe-area insets are relative to the device's fixed portrait frame; rotateInsets remaps them
78
- // onto whichever edge they land on once FakeLandscapeView turns the content, so `top`/`left`/
79
- // `right` below always mean the screen's real, visual edges.
80
- const rotation = getViewRotation(panelLayout.orientationMode, panelLayout.p1OnRight, panelLayout.upsideDown)
81
- const insets = rotateInsets(useSafeAreaInsets(), rotation)
73
+ const insets = useSafeAreaInsets()
82
74
  const { colors: themeColors, dark } = useAutoPaperTheme()
83
75
  const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman)
84
76
  const dispatch = useDispatch<Dispatch<Action>>()
@@ -142,9 +134,9 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
142
134
  const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length)
143
135
 
144
136
  // Where P2's own press-away zone lives on screen - matches panelLayout (what's actually painted
145
- // right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on whichever
146
- // side isn't P1's. Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below).
147
- const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : panelLayout.p1OnRight ? styles.p2ZoneLeft : styles.p2ZoneRight) : null
137
+ // right now, mid-fade included). Portrait puts P2's panel in the top half; landscape on the right
138
+ // (P1 sits on the left). Only in 2 Player - otherwise seat 2 shares seat 1's host (see p2Host below).
139
+ const p2ZoneStyle = p2IsHuman ? (isPortrait ? styles.p2ZoneTop : styles.p2ZoneRight) : null
148
140
 
149
141
  // With one human, the one Ready button sits under both panels instead of in each.
150
142
  const showReadyButton = p2IsHuman
@@ -167,7 +159,6 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
167
159
 
168
160
  const optionsRow = loadout.options({
169
161
  host: controlsHost,
170
- rotation,
171
162
  accentColor: themeColors.tertiary,
172
163
  mutedColor: fgMuted,
173
164
  onAccentColor: themeColors.onTertiary,
@@ -186,7 +177,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
186
177
  optionsRow
187
178
  )
188
179
 
189
- const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation }
180
+ const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark }
190
181
  const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn })
191
182
  const p1Panel = <PlayerSetupPanel key={`p1-${layoutKey}`} idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} previewValue={seatPreview} triggerStyle={triggerStyle} useSeatPickerAlign={seatPickerAlign} />
192
183
  const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : undefined
@@ -195,10 +186,8 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
195
186
 
196
187
  return (
197
188
  <View style={[styles.container, { backgroundColor: bg }]}>
198
- {/* Driven by panelLayout (the same committed, fade-masked state DualZoneLayout renders from),
199
- so the rotation change and the panel swap land in the same fade-covered moment. The Settings
200
- dialog stays outside: it renders through a Portal, turned by its own `rotation`. */}
201
- <FakeLandscapeView orientationMode={panelLayout.orientationMode} p1OnRight={panelLayout.p1OnRight} upsideDown={panelLayout.upsideDown} style={styles.rotatable}>
189
+ {/* The Settings dialog stays outside this frame: it renders through a Portal. */}
190
+ <View style={styles.frame}>
202
191
  {/* Press-away overlays for the loadout's own menus. P1's covers the whole screen (their
203
192
  panel plus the match options read as theirs), while P2's covers only their half and, as a
204
193
  later sibling, takes priority there. P2's mounts whenever either host has a menu open, so it
@@ -225,15 +214,15 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
225
214
  <View style={styles.stackedZone}>
226
215
  {sharedControls}
227
216
  <Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, playersRowFade]}>
228
- {!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel}
229
- {!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel}
217
+ {p1Panel}
218
+ {p2Panel}
230
219
  </Animated.View>
231
220
  <ReadyButton color={p1Drawn} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
232
221
  </View>
233
222
  )}
234
- </FakeLandscapeView>
223
+ </View>
235
224
 
236
- <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={() => openFor(game.id)} />
225
+ <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} onShowHowToPlay={() => openFor(game.id)} />
237
226
  </View>
238
227
  )
239
228
  }
@@ -249,12 +238,13 @@ const styles = StyleSheet.create({
249
238
  container: {
250
239
  flex: 1
251
240
  },
252
- p2ZoneLeft: {
253
- bottom: 0,
254
- left: 0,
255
- position: 'absolute',
256
- top: 0,
257
- width: '50%'
241
+ // A real full-bleed box to size the absolutely-positioned children against.
242
+ frame: {
243
+ alignItems: 'center',
244
+ flex: 1,
245
+ gap: 32,
246
+ justifyContent: 'center',
247
+ paddingHorizontal: 16
258
248
  },
259
249
  p2ZoneRight: {
260
250
  bottom: 0,
@@ -289,14 +279,6 @@ const styles = StyleSheet.create({
289
279
  opacity: 0,
290
280
  pointerEvents: 'none'
291
281
  },
292
- // A real full-bleed box for FakeLandscapeView to size its absolutely-positioned children against.
293
- rotatable: {
294
- alignItems: 'center',
295
- flex: 1,
296
- gap: 32,
297
- justifyContent: 'center',
298
- paddingHorizontal: 16
299
- },
300
282
  stackedZone: {
301
283
  alignItems: 'center',
302
284
  gap: 28
@@ -6,7 +6,7 @@ import { useProfiles } from '../hooks/useProfiles'
6
6
  import { safeBack } from '../navigation'
7
7
  import { usePack } from '../PackContext'
8
8
 
9
- // Widest the roster gets in the wide layout (a phone held sideways, a desktop window). Upright it's
9
+ // Widest the roster gets in the wide layout (a landscape phone, a desktop window). Upright it's
10
10
  // never capped.
11
11
  const ROSTER_MAX_WIDTH = 640
12
12
 
@@ -14,10 +14,10 @@ const ROSTER_MAX_WIDTH = 640
14
14
  // stats and achievements in every game: each game's reducers listen for the removal (see
15
15
  // onProfileRemoved.ts).
16
16
  //
17
- // Turned with the phone like every other screen, in hud's RotatedFrame: ProfilesManager reads the
18
- // safe-area insets itself (the header's padding), so they're handed over remapped onto the frame's
19
- // edges (rotatedSafeArea); its delete confirm is a Paper Portal, which lands in a host inside the frame
20
- // and is turned with it (portalHost); and the roster's column is capped in the wide layout. Over a
17
+ // In hud's RotatedFrame, which nothing turns since @tastic/core 0.15: ProfilesManager reads the
18
+ // safe-area insets itself (the header's padding), handed over by rotatedSafeArea; its delete confirm is a
19
+ // Paper Portal, which lands in a host inside the frame (portalHost); and the roster's column is capped in
20
+ // the wide layout. Over a
21
21
  // pack's Backdrop the screen is transparent, drawn in the pack's foreground.
22
22
  // The pack's own placement for a profile's color swatch (PackDefinition.usePickerAlign), kept inside the
23
23
  // roster's capped column: the roster scrolls in it, which clips the menu at its edges. Handed to