@tastic/shell 0.3.1 → 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 { 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'
7
- import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
8
- import { AppState, type AppStateStatus, StyleSheet, View } from 'react-native'
9
- import { Icon, Portal, Text } from 'react-native-paper'
6
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import { AppState, type AppStateStatus, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollView, StyleSheet, View } from 'react-native'
8
+ import { Icon, Portal, SegmentedButtons, Text } from 'react-native-paper'
10
9
 
11
10
  import { useGameGuide } from '../components/GuideHost'
12
11
  import { SettingsDialog } from '../components/SettingsDialog'
@@ -17,14 +16,15 @@ import { useProfiles } from '../hooks/useProfiles'
17
16
  import { useSeatColors } from '../hooks/useSeatColors'
18
17
  import { usePack } from '../PackContext'
19
18
  import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
20
- import type { PackGameDefinition } from '../types'
19
+ import type { HomeTab, PackGameDefinition } from '../types'
21
20
  import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_BAND, HOME_WIDE_PANE_GAP, PREVIEW_ASPECT, ROW_PREVIEW_WIDTH, useHomeLayout } from './homeLayout'
22
21
 
23
22
  // Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
24
23
  // pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
25
24
  // (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
26
25
  // loadout, or its play screen in a game without one, after its How to Play cards the first time it's
27
- // picked. Seat 2 is chosen in the loadout, from seat 2's own button.
26
+ // picked. Seat 2 is chosen in the loadout, from seat 2's own button. A pack with tabs
27
+ // (PackDefinition.homeTabs) shows one tab's games at a time, from a switch over the rows.
28
28
  //
29
29
  // A cold launch that finds the player left the app inside a resumable game's play screen
30
30
  // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
@@ -52,7 +52,6 @@ export function HomeScreen() {
52
52
 
53
53
  function HomeMenu() {
54
54
  const pack = usePack()
55
- const rotation = useRotation()
56
55
  const layout = useHomeLayout()
57
56
  const { insets } = layout
58
57
  const { dark, colors } = useAutoPaperTheme()
@@ -78,7 +77,7 @@ function HomeMenu() {
78
77
 
79
78
  // Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
80
79
  // the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
81
- // 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
82
81
  // useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
83
82
  // the panes' numbers follow the window, unseen under the screen covering Home.
84
83
  const tiled = pack.games.some((game) => game.Preview)
@@ -94,13 +93,13 @@ function HomeMenu() {
94
93
  return () => cancelAnimationFrame(id)
95
94
  }, [tiled])
96
95
  // A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
97
- // 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
98
97
  // unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
99
98
  // dismiss. So every layout change closes it.
100
99
  const closePicker = pickerHost.close
101
100
  useEffect(() => {
102
101
  closePicker()
103
- }, [isWide, rotation, layout.windowWidth, layout.windowHeight, closePicker])
102
+ }, [isWide, layout.windowWidth, layout.windowHeight, closePicker])
104
103
 
105
104
  // Over the pack's Backdrop Home draws nothing of its own behind the menu.
106
105
  const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
@@ -111,6 +110,18 @@ function HomeMenu() {
111
110
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
112
111
  const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
113
112
 
113
+ // The pack's tabs (PackDefinition.homeTabs): one tab's games at a time, the same switch as the stats
114
+ // screen's tabs (on the theme's surface over a backdrop). The other tabs' games stay mounted, hidden, so
115
+ // a switch draws nothing new (their previews are already there). The tab is Home's own state, kept while
116
+ // Home is covered, since Home stays mounted under every screen it opens.
117
+ const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null
118
+ const [tabLabel, setTabLabel] = useState(() => tabs?.[0].label ?? null)
119
+ const tab = tabs ? (tabs.find((t) => t.label === tabLabel) ?? tabs[0]) : null
120
+ const tabButtons = useMemo(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs])
121
+ const tabBar = tab && <SegmentedButtons value={tab.label} onValueChange={setTabLabel} buttons={tabButtons} style={[styles.tabs, pack.Backdrop && { backgroundColor: colors.surface }]} />
122
+ const columnClamp = useClampOnShrink()
123
+ const gridClamp = useClampOnShrink()
124
+
114
125
  // A game with nothing to set up (no useLoadout) opens straight to its play screen.
115
126
  const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
116
127
 
@@ -137,10 +148,9 @@ function HomeMenu() {
137
148
 
138
149
  return (
139
150
  <View style={[styles.container, { backgroundColor: bg }]}>
140
- {/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The
141
- Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. A
142
- game's rules sheet renders inside it instead (see RulesHost). */}
143
- <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}>
144
154
  <RulesHost enabled={pack.games.some((game) => game.Rules)}>
145
155
  {isWide ? (
146
156
  <>
@@ -149,10 +159,11 @@ function HomeMenu() {
149
159
  to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
150
160
  sits at the window's edge. Both scrollers fade out at an edge while there is more past it
151
161
  (auto-paper's FadingScrollView, a mask, so whatever is behind shows through). */}
152
- <FadingScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
162
+ <FadingScrollView {...(tab && gridClamp)} style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
163
+ {tabBar && <View style={[styles.wideTabs, { width: layout.rightPaneWidth }]}>{tabBar}</View>}
153
164
  <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
154
165
  {pack.games.map((game) => (
155
- <GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
166
+ <GameTile key={game.id} game={game} hidden={isHidden(tab, game)} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
156
167
  ))}
157
168
  </View>
158
169
  </FadingScrollView>
@@ -165,7 +176,7 @@ function HomeMenu() {
165
176
  </View>
166
177
  </>
167
178
  ) : (
168
- <FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
179
+ <FadingScrollView {...(tab && columnClamp)} style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
169
180
  {pressAway}
170
181
  <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
171
182
 
@@ -173,9 +184,11 @@ function HomeMenu() {
173
184
  tiles while its menu is open, so the menu paints over them. */}
174
185
  <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
175
186
 
187
+ {tabBar && <View style={styles.listWidth}>{tabBar}</View>}
188
+
176
189
  <View style={styles.list}>
177
190
  {pack.games.map((game) => (
178
- <GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
191
+ <GameRow key={game.id} game={game} hidden={isHidden(tab, game)} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
179
192
  ))}
180
193
  </View>
181
194
  </FadingScrollView>
@@ -185,11 +198,11 @@ function HomeMenu() {
185
198
  <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
186
199
  {RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
187
200
  </RulesHost>
188
- </FakeLandscapeView>
201
+ </View>
189
202
 
190
203
  {/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
191
204
  tile's rules button shows its game's. */}
192
- <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} />
193
206
  </View>
194
207
  )
195
208
  }
@@ -200,20 +213,58 @@ const SEAT_ONE_PICKER = 'home-p1'
200
213
  // No placement of the pack's own: hud places the menu itself.
201
214
  const noPickerAlign = () => undefined
202
215
 
216
+ // A game the tab picked doesn't include: kept mounted, out of the layout and out of a screen reader's way.
217
+ function isHidden(tab: HomeTab | null, game: PackGameDefinition): boolean {
218
+ return !!tab && !tab.includes(game)
219
+ }
220
+
221
+ // iOS leaves a scroller's offset past the end when its content shrinks (React Native's own TODO, T190695447
222
+ // in RCTScrollViewComponentView.mm), so a switch to a shorter tab while scrolled down would leave blank space
223
+ // under the last game until the next drag. This pulls it back in, as Android and the web do by themselves.
224
+ // Spread onto one scroller (its ref and three events) only in a pack with tabs, the one way Home's content
225
+ // shrinks. The ref is a callback so a scroller mounted afresh (a switch between the column and the grid)
226
+ // starts from its real offset, 0, not the last one's.
227
+ function useClampOnShrink() {
228
+ const view = useRef<ScrollView | null>(null)
229
+ const sizes = useRef({ viewport: 0, offset: 0 })
230
+ return useMemo(
231
+ () => ({
232
+ ref: (instance: ScrollView | null) => {
233
+ view.current = instance
234
+ sizes.current.offset = 0
235
+ },
236
+ onLayout: (e: LayoutChangeEvent) => {
237
+ sizes.current.viewport = e.nativeEvent.layout.height
238
+ },
239
+ onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
240
+ sizes.current.offset = e.nativeEvent.contentOffset.y
241
+ },
242
+ onContentSizeChange: (_width: number, height: number) => {
243
+ const end = Math.max(0, height - sizes.current.viewport)
244
+ if (sizes.current.offset <= end) return
245
+ sizes.current.offset = end
246
+ view.current?.scrollTo({ y: end, animated: false })
247
+ }
248
+ }),
249
+ []
250
+ )
251
+ }
252
+
203
253
  // Whether a tile has a rules button: a rules sheet or How to Play cards to show.
204
254
  function hasRules(game: PackGameDefinition): boolean {
205
255
  return !!(game.Rules || game.guide)
206
256
  }
207
257
 
208
- // A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's turned frame, so
209
- // it's turned with the frame and laid out against it (a sheet laid out against the upright window ran
210
- // 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.
211
260
  function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
212
261
  return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
213
262
  }
214
263
 
215
264
  interface GameRowProps {
216
265
  game: PackGameDefinition
266
+ // Not in the tab picked (PackDefinition.homeTabs).
267
+ hidden: boolean
217
268
  dark: boolean
218
269
  p1Color: string
219
270
  p2Color: string
@@ -227,13 +278,13 @@ interface GameRowProps {
227
278
  // One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
228
279
  // preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
229
280
  // How to Play cards without opening the game.
230
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
281
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
231
282
  const well = useWellColors()
232
283
  const GameIcon = game.icon
233
284
  return (
234
285
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
235
286
  // inside a button is invalid on the web.
236
- <View>
287
+ <View style={hidden ? styles.hidden : undefined}>
237
288
  <Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
238
289
  {game.Preview ? <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} /> : <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>{GameIcon && <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />}</View>}
239
290
  <View style={styles.rowText}>
@@ -262,10 +313,10 @@ interface GameTileProps extends GameRowProps {
262
313
  // One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
263
314
  // name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
264
315
  // rules button floats over the preview's corner, beside the tile's button rather than inside it.
265
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
316
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
266
317
  const well = useWellColors()
267
318
  return (
268
- <View style={{ width }}>
319
+ <View style={[{ width }, hidden && styles.hidden]}>
269
320
  <Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
270
321
  <PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
271
322
  <Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
@@ -331,18 +382,30 @@ const styles = StyleSheet.create({
331
382
  corner: {
332
383
  position: 'absolute'
333
384
  },
385
+ frame: {
386
+ flex: 1
387
+ },
334
388
  // The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
335
389
  grid: {
336
390
  flexDirection: 'row',
337
391
  flexWrap: 'wrap',
338
392
  gap: HOME_GRID_GAP
339
393
  },
394
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
395
+ hidden: {
396
+ display: 'none'
397
+ },
340
398
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
341
399
  list: {
342
400
  gap: 14,
343
401
  maxWidth: 480,
344
402
  width: '100%'
345
403
  },
404
+ // The list's width, for the tabs over it.
405
+ listWidth: {
406
+ maxWidth: 480,
407
+ width: '100%'
408
+ },
346
409
  picker: {
347
410
  alignItems: 'center'
348
411
  },
@@ -368,9 +431,6 @@ const styles = StyleSheet.create({
368
431
  previewIcon: {
369
432
  overflow: 'hidden'
370
433
  },
371
- rotatable: {
372
- flex: 1
373
- },
374
434
  // A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
375
435
  // as Solitaire's game rows, the stats screen's sections and every dialog.
376
436
  row: {
@@ -405,6 +465,10 @@ const styles = StyleSheet.create({
405
465
  scroll: {
406
466
  flex: 1
407
467
  },
468
+ // SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
469
+ tabs: {
470
+ borderRadius: 20
471
+ },
408
472
  // The same well as a row, turned on its side.
409
473
  tile: {
410
474
  borderRadius: 12,
@@ -443,5 +507,9 @@ const styles = StyleSheet.create({
443
507
  position: 'absolute',
444
508
  right: 0,
445
509
  top: 0
510
+ },
511
+ // The tabs over the grid, a little further from it than the tiles are from each other.
512
+ wideTabs: {
513
+ marginBottom: 16
446
514
  }
447
515
  })
@@ -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
@@ -34,7 +34,7 @@ const ALL_GAMES = '__all__'
34
34
  const PLAYER_PICKER_ID = 'stats-player'
35
35
  const GAME_PICKER_ID = 'stats-game'
36
36
 
37
- // Widest the column gets in the wide layout (a phone held sideways, a desktop window): a list of stat
37
+ // Widest the column gets in the wide layout (a landscape phone, a desktop window): a list of stat
38
38
  // rows stretched across an 874pt-wide screen reads badly. Upright it's never capped.
39
39
  const STATS_MAX_WIDTH = 640
40
40
 
@@ -45,9 +45,8 @@ const STATS_MAX_WIDTH = 640
45
45
  // can't be told apart. A pack for one player at a time (PackDefinition.guestStats) keeps a guest's
46
46
  // stats under GUEST_PLAYER_ID, offers Guest in the picker and opens on whoever is in seat 1.
47
47
  //
48
- // Turned with the phone in hud's RotatedFrame: its insets remapped onto the frame's edges, the reset
49
- // confirm portaled into a host inside the frame (so it's turned with it, at rotation 0), and the column
50
- // capped in the wide layout.
48
+ // In hud's RotatedFrame, which nothing turns since @tastic/core 0.15: it hands the screen the safe-area
49
+ // insets, hosts the reset confirm's portal inside the frame, and caps the column in the wide layout.
51
50
  export function StatsScreen() {
52
51
  const pickerHost = usePopoverHost()
53
52
  return (
@@ -81,7 +80,7 @@ const noPickerAlign = () => undefined
81
80
  function StatsContent({ pickerHost }: { pickerHost: PopoverHost }) {
82
81
  const pack = usePack()
83
82
  const { dark, colors } = useAutoPaperTheme()
84
- // Already remapped onto the turned frame's edges by RotatedFrame's rotatedSafeArea.
83
+ // Handed over by RotatedFrame's rotatedSafeArea.
85
84
  const insets = useSafeAreaInsets()
86
85
  const { profiles, lastSelected } = useProfiles()
87
86
  const dispatch = useDispatch()
@@ -1,20 +1,19 @@
1
- import { clamp, rotateInsets, useRotatedWindowDimensions, useRotation } from '@tastic/core'
1
+ import { clamp } from '@tastic/core'
2
+ import { useWindowDimensions } from 'react-native'
2
3
  import { type EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context'
3
4
 
4
5
  // Home's geometry for a pack whose games have previews (GameDefinition.Preview), ported from
5
6
  // Solitaire's components/homeLayout.ts. Upright, Home is the one scrolling column every pack has: the
6
- // hero, seat 1's button, then a row per game, each row's preview at its start. Held sideways a phone
7
+ // hero, seat 1's button, then a row per game, each row's preview at its start. A landscape phone
7
8
  // is only ~400pt tall, and that column would spend the whole height on the hero before showing a
8
- // single game, so a "wide" layout (any window whose ROTATED footprint is landscape: a phone held
9
- // sideways, an iPad, a desktop browser) splits it into two panes: the hero and seat 1's button on the
9
+ // single game, so a "wide" layout (any window wider than tall: a landscape phone, a landscape iPad, a
10
+ // desktop browser) splits it into two panes: the hero and seat 1's button on the
10
11
  // left, and a scrolling grid of game tiles on the right. The trophy (top-left) and cog (top-right)
11
12
  // corner buttons stay where they are; the right pane stops short of the cog so tiles never scroll
12
13
  // underneath it. A pack without previews keeps the column at every size.
13
14
  //
14
- // Everything here works in the ROTATED frame's own coordinates (Home's FakeLandscapeView): `width`/
15
- // `height` are the rotated footprint and `insets` are the safe-area insets already remapped onto the
16
- // frame's edges (rotateInsets). The rotated top/bottom insets are ~0 under a portrait-locked OS (a
17
- // phone held sideways only has left/right insets), so nothing here leans on them for breathing room.
15
+ // A landscape phone's top and bottom insets are ~0 (only the sides have them), so nothing here leans
16
+ // on them for breathing room.
18
17
 
19
18
  // Every preview's width / height: Solitaire's preview canvas (360 x 200), so its deals fit as drawn.
20
19
  export const PREVIEW_ASPECT = 360 / 200
@@ -52,15 +51,15 @@ const HOME_LEFT_PANE_BOTTOM_PADDING = 16
52
51
  const HOME_HERO_SIDE_ROOM = 16
53
52
 
54
53
  export interface HomeLayout {
55
- // The rotated footprint is landscape: the two-pane layout is in effect (for a pack with previews).
54
+ // The window is landscape: the two-pane layout is in effect (for a pack with previews).
56
55
  isWide: boolean
57
- // The window's footprint AFTER rotation.
56
+ // The window's size.
58
57
  windowWidth: number
59
58
  windowHeight: number
60
- // Safe-area insets remapped onto the rotated frame's own edges (see @tastic/core's rotateInsets).
59
+ // The safe-area insets.
61
60
  insets: EdgeInsets
62
- // Wide layout: padding on the two panes' row. Each side hugs ITS OWN inset (a phone held sideways
63
- // has a large inset only on the island side), the trailing one also reserves the cog's gutter, and
61
+ // Wide layout: padding on the two panes' row. Each side hugs ITS OWN inset (a landscape phone has a
62
+ // large inset only on the island side), the trailing one also reserves the cog's gutter, and
64
63
  // both carry the equal margin left over once the content hits its cap. 0 upright.
65
64
  paddingLeft: number
66
65
  paddingRight: number
@@ -118,12 +117,9 @@ export function computeHomeLayout(windowWidth: number, windowHeight: number, ins
118
117
  }
119
118
  }
120
119
 
121
- // The live layout for the current window and rotation. Reads useRotatedWindowDimensions()/
122
- // rotateInsets() rather than raw useWindowDimensions()/useSafeAreaInsets(), so it describes the
123
- // footprint a rotated Home actually renders into (both are exact identities at 0deg).
120
+ // The live layout for the current window.
124
121
  export function useHomeLayout(): HomeLayout {
125
- const { width, height } = useRotatedWindowDimensions()
126
- const rotation = useRotation()
127
- const insets = rotateInsets(useSafeAreaInsets(), rotation)
122
+ const { width, height } = useWindowDimensions()
123
+ const insets = useSafeAreaInsets()
128
124
  return computeHomeLayout(width, height, insets)
129
125
  }