@tastic/shell 0.1.1 → 0.2.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.
@@ -1,12 +1,12 @@
1
- import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { IconButton, Pressable } from '@rific/feedback-press'
3
3
  import { FakeLandscapeView, useRotation } from '@tastic/core'
4
- import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud'
5
- import { type Profile, ProfilePicker } from '@tastic/profile'
4
+ import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
5
+ import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
6
6
  import { Redirect, router, useIsFocused } from 'expo-router'
7
- import { useCallback, useEffect, useState } from 'react'
8
- import { AppState, type AppStateStatus, ScrollView, StyleSheet, View } from 'react-native'
9
- import { Icon, Text } from 'react-native-paper'
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'
10
10
 
11
11
  import { useGameGuide } from '../components/GuideHost'
12
12
  import { SettingsDialog } from '../components/SettingsDialog'
@@ -16,7 +16,8 @@ import { useOpponent } from '../hooks/useOpponent'
16
16
  import { useProfiles } from '../hooks/useProfiles'
17
17
  import { useSeatColors } from '../hooks/useSeatColors'
18
18
  import { usePack } from '../PackContext'
19
- import type { GameDefinition } from '../types'
19
+ import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
20
+ import type { PackGameDefinition } from '../types'
20
21
  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'
21
22
 
22
23
  // Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
@@ -26,10 +27,13 @@ import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_B
26
27
  // picked. Seat 2 is chosen in the loadout, from seat 2's own button.
27
28
  //
28
29
  // A cold launch that finds the player left the app inside a resumable game's play screen
29
- // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints.
30
+ // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
31
+ // pack's own How to Play (PackDefinition.guide) shows by itself on a first launch, held back while
32
+ // that launch is about to drop into a game: it must never open over a game in progress.
30
33
  export function HomeScreen() {
31
34
  const pack = usePack()
32
35
  const { consumeResumeGame } = useLastActiveGame()
36
+ const { autoShowPack } = useGameGuide()
33
37
  // A one-shot cold-launch decision, read exactly once at this instance's mount (see
34
38
  // hooks/useLastActiveGame.tsx), never a live value this screen re-renders from. Reading it live made
35
39
  // every Home in the stack swap its whole menu for a Redirect while a game was on top and rebuild it
@@ -39,7 +43,10 @@ export function HomeScreen() {
39
43
  const id = consumeResumeGame()
40
44
  return pack.games.find((g) => g.id === id && g.resumable) ?? null
41
45
  })
42
- if (resumeGame) return <Redirect href={{ pathname: '/play/[game]', params: { game: resumeGame.id } }} />
46
+ useEffect(() => {
47
+ if (!resumeGame) autoShowPack()
48
+ }, [resumeGame, autoShowPack])
49
+ if (resumeGame) return <Redirect href={playHref(resumeGame.id)} />
43
50
  return <HomeMenu />
44
51
  }
45
52
 
@@ -48,9 +55,11 @@ function HomeMenu() {
48
55
  const rotation = useRotation()
49
56
  const layout = useHomeLayout()
50
57
  const { insets } = layout
51
- const { dark } = useAutoPaperTheme()
52
- const { openFirstTime, openFor } = useGameGuide()
58
+ const { dark, colors } = useAutoPaperTheme()
59
+ const { openFirstTime, openFor, openPack } = useGameGuide()
53
60
  const [settingsOpen, setSettingsOpen] = useState(false)
61
+ // The game whose rules sheet (GameDefinition.Rules) is open.
62
+ const [rulesGame, setRulesGame] = useState<PackGameDefinition | null>(null)
54
63
  const isFocused = useIsFocused()
55
64
  const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
56
65
  useEffect(() => {
@@ -84,82 +93,135 @@ function HomeMenu() {
84
93
  const id = requestAnimationFrame(() => setPreviewsRevealed(true))
85
94
  return () => cancelAnimationFrame(id)
86
95
  }, [tiled])
87
- // A popover is placed once, as it opens. Switching between the column and the two panes unmounts
88
- // the very button it hangs off, which would leave the press-away overlay up with nothing to dismiss,
89
- // so the switch closes it.
96
+ // 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
98
+ // unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
99
+ // dismiss. So every layout change closes it.
90
100
  const closePicker = pickerHost.close
91
101
  useEffect(() => {
92
102
  closePicker()
93
- }, [isWide, closePicker])
103
+ }, [isWide, rotation, layout.windowWidth, layout.windowHeight, closePicker])
94
104
 
95
- const bg = dark ? '#000000' : '#FFFFFF'
96
- const fg = dark ? '#FFFFFF' : '#000000'
105
+ // Over the pack's Backdrop Home draws nothing of its own behind the menu.
106
+ const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
107
+ const fg = pack.foreground ?? (dark ? '#FFFFFF' : '#000000')
108
+ // Seat 1's color as the pack draws profile colors (PackDefinition.previewColor), and the button on
109
+ // the theme's surface over a backdrop, so neither washes out on it.
110
+ const { previewColor } = pack
111
+ const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
112
+ const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
97
113
 
98
114
  // A game with nothing to set up (no useLoadout) opens straight to its play screen.
99
- const openGame = useCallback((game: GameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? { pathname: '/loadout/[game]', params: { game: game.id } } : { pathname: '/play/[game]', params: { game: game.id } })), [openFirstTime])
115
+ const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
116
+
117
+ // A tile's rules button: the game's rules sheet where it has one, else its How to Play cards.
118
+ const openRules = useCallback((game: PackGameDefinition) => (game.Rules ? setRulesGame(game) : openFor(game.id)), [openFor])
119
+
120
+ const colorSection: ProfilePickerColorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined, previewValue: previewColor ? (hex: string) => previewColor(hex, dark) : undefined }
121
+ // The pack's own placement for the menu (PackDefinition.usePickerAlign), sized from the same rows
122
+ // and color page the picker draws; hud's own without one. The pack never changes while it runs.
123
+ const usePickerAlign = pack.usePickerAlign ?? noPickerAlign
124
+ const pickerSize = getProfilePickerContentSize(profiles.length, true, colorSection)
125
+ const pickerAlign = usePickerAlign(pickerHost.openId === `${SEAT_ONE_PICKER}-profile`, pickerSize.width, pickerSize.height)
126
+
127
+ const picker = <ProfilePicker idPrefix={SEAT_ONE_PICKER} host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Drawn} dark={dark} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push(PROFILES_HREF)} colorSection={colorSection} alignOverride={pickerAlign} triggerStyle={triggerStyle} />
100
128
 
101
- const picker = <ProfilePicker idPrefix='home-p1' host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Color} dark={dark} guestLabel='P1' onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push('/profiles')} colorSection={{ value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined }} />
129
+ // Closes seat 1's menu on a press anywhere but the menu and its button. Where it's mounted matters on
130
+ // a phone, where zIndex only orders the children of one parent: the menu lives inside the scroller in
131
+ // the upright layout, so the overlay goes there too, under the button's wrapper (raised while the menu
132
+ // is open) and over the rows. As the frame's first child it sat under the whole scroller, and a press
133
+ // on a row played the game. In the wide layout the left pane is itself the frame's child, so the
134
+ // overlay stays there, under the pane and over the grid.
135
+ const pressAway = <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
136
+ const RulesSheet = rulesGame?.Rules
102
137
 
103
138
  return (
104
139
  <View style={[styles.container, { backgroundColor: bg }]}>
105
140
  {/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The
106
- Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. */}
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). */}
107
143
  <FakeLandscapeView style={styles.rotatable}>
108
- <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} />
109
- {isWide ? (
110
- <>
111
- {/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
144
+ <RulesHost enabled={pack.games.some((game) => game.Rules)}>
145
+ {isWide ? (
146
+ <>
147
+ {pressAway}
148
+ {/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
112
149
  to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
113
- sits at the window's edge. */}
114
- <ScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
115
- <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
116
- {pack.games.map((game) => (
117
- <GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
118
- ))}
119
- </View>
120
- </ScrollView>
121
- {/* Left pane: the hero, then seat 1's button, centred as one group under the corner
150
+ sits at the window's edge. Both scrollers fade out at an edge while there is more past it
151
+ (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 }}>
153
+ <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
154
+ {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} />
156
+ ))}
157
+ </View>
158
+ </FadingScrollView>
159
+ {/* Left pane: the hero, then seat 1's button, centred as one group under the corner
122
160
  buttons' band. A later sibling of the grid, lifted while seat 1's menu is open so the menu
123
161
  paints over the tiles. box-none: only its controls claim taps. */}
124
- <View style={[styles.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles.pickerOpen]}>
125
- <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
126
- <View style={styles.widePicker}>{picker}</View>
127
- </View>
128
- </>
129
- ) : (
130
- <ScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
131
- <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
162
+ <View style={[styles.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles.pickerOpen]}>
163
+ <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
164
+ <View style={styles.widePicker}>{picker}</View>
165
+ </View>
166
+ </>
167
+ ) : (
168
+ <FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
169
+ {pressAway}
170
+ <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
132
171
 
133
- {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
172
+ {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
134
173
  tiles while its menu is open, so the menu paints over them. */}
135
- <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
174
+ <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
136
175
 
137
- <View style={styles.list}>
138
- {pack.games.map((game) => (
139
- <GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
140
- ))}
141
- </View>
142
- </ScrollView>
143
- )}
176
+ <View style={styles.list}>
177
+ {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} />
179
+ ))}
180
+ </View>
181
+ </FadingScrollView>
182
+ )}
144
183
 
145
- <IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push('/stats')} accessibilityLabel='Stats & Achievements' />
146
- <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
184
+ <IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push(STATS_HREF)} accessibilityLabel='Stats & Achievements' />
185
+ <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
186
+ {RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
187
+ </RulesHost>
147
188
  </FakeLandscapeView>
148
189
 
149
- <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} />
190
+ {/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
191
+ tile's rules button shows its game's. */}
192
+ <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={pack.guide ? openPack : undefined} />
150
193
  </View>
151
194
  )
152
195
  }
153
196
 
197
+ // Seat 1's button's popover id prefix (its menu is `${SEAT_ONE_PICKER}-profile`).
198
+ const SEAT_ONE_PICKER = 'home-p1'
199
+
200
+ // No placement of the pack's own: hud places the menu itself.
201
+ const noPickerAlign = () => undefined
202
+
203
+ // Whether a tile has a rules button: a rules sheet or How to Play cards to show.
204
+ function hasRules(game: PackGameDefinition): boolean {
205
+ return !!(game.Rules || game.guide)
206
+ }
207
+
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.
211
+ function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
212
+ return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
213
+ }
214
+
154
215
  interface GameRowProps {
155
- game: GameDefinition
216
+ game: PackGameDefinition
156
217
  dark: boolean
157
218
  p1Color: string
158
219
  p2Color: string
159
220
  // Whether Home has painted once, so a preview may draw (see previewsRevealed).
160
221
  previewRevealed: boolean
161
222
  onPress: () => void
162
- onRules: () => void
223
+ // Undefined for a game with no rules sheet and no How to Play cards: no rules button.
224
+ onRules?: () => void
163
225
  }
164
226
 
165
227
  // One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
@@ -173,13 +235,7 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
173
235
  // inside a button is invalid on the web.
174
236
  <View>
175
237
  <Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
176
- {game.Preview ? (
177
- <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
178
- ) : (
179
- <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
180
- <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />
181
- </View>
182
- )}
238
+ {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>}
183
239
  <View style={styles.rowText}>
184
240
  <Text variant='titleLarge' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
185
241
  {game.title}
@@ -189,9 +245,11 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
189
245
  </Text>
190
246
  </View>
191
247
  </Pressable>
192
- <Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
193
- <Icon source='information-outline' size={22} color={well.fgMuted} />
194
- </Pressable>
248
+ {onRules && (
249
+ <Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
250
+ <Icon source='information-outline' size={22} color={well.fgMuted} />
251
+ </Pressable>
252
+ )}
195
253
  </View>
196
254
  )
197
255
  }
@@ -217,15 +275,17 @@ function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRe
217
275
  {game.blurb}
218
276
  </Text>
219
277
  </Pressable>
220
- <Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
221
- <Icon source='information-outline' size={20} color='#FFFFFF' />
222
- </Pressable>
278
+ {onRules && (
279
+ <Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
280
+ <Icon source='information-outline' size={20} color='#FFFFFF' />
281
+ </Pressable>
282
+ )}
223
283
  </View>
224
284
  )
225
285
  }
226
286
 
227
287
  interface PreviewBoxProps {
228
- game: GameDefinition
288
+ game: PackGameDefinition
229
289
  width: number
230
290
  revealed: boolean
231
291
  dark: boolean
@@ -241,13 +301,13 @@ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }: PreviewBo
241
301
  const iconSize = Math.min(height, ROW_ICON_SIZE)
242
302
  return (
243
303
  <View style={[styles.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
244
- {Preview ? (
245
- revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
246
- ) : (
247
- <View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
248
- <GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
249
- </View>
250
- )}
304
+ {Preview
305
+ ? revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
306
+ : GameIcon && (
307
+ <View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
308
+ <GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
309
+ </View>
310
+ )}
251
311
  </View>
252
312
  )
253
313
  }
@@ -289,6 +349,15 @@ const styles = StyleSheet.create({
289
349
  pickerOpen: {
290
350
  zIndex: 100
291
351
  },
352
+ // Seat 1's button on the theme's surface (over a backdrop), the same pill as the tiles' wells.
353
+ pill: {
354
+ borderRadius: 999
355
+ },
356
+ // Over the rows (and, wide, the grid), whose own stacking is unset, so a press on them reaches it on
357
+ // the web too, and under the raised button and left pane, so the menu's rows stay pressable.
358
+ pressAway: {
359
+ zIndex: 5
360
+ },
292
361
  // The preview's own rounding, clipping whatever it draws to the box.
293
362
  preview: {
294
363
  alignItems: 'center',
@@ -1,11 +1,12 @@
1
1
  import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core'
3
- import { CornerActionButtons, PlayerSetupPanel, PressAwayOverlay, ReadyButton, SharedActionBand, usePopoverHost } from '@tastic/hud'
3
+ import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from '@tastic/hud/chrome'
4
+ import { PlayerSetupPanel, ReadyButton, SharedActionBand } from '@tastic/hud/lobby'
4
5
  import type { Profile } from '@tastic/profile'
5
6
  import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from '@tastic/split-screen'
6
7
  import { router, useFocusEffect } from 'expo-router'
7
8
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
- import { Platform, StyleSheet, View } from 'react-native'
9
+ import { Platform, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
9
10
  import Animated from 'react-native-reanimated'
10
11
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
11
12
  import { useDispatch } from 'react-redux'
@@ -20,7 +21,8 @@ import { useSeatColors } from '../hooks/useSeatColors'
20
21
  import { safeBack } from '../navigation'
21
22
  import { usePack } from '../PackContext'
22
23
  import { liveplayActions } from '../redux/liveplaySlice'
23
- import { type GameDefinition, humanSeatsFor, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
24
+ import { playHref, PROFILES_HREF } from '../routes'
25
+ import { humanSeatsFor, type PackGameDefinition, roundModeFor, type Seat, type SeatTwo, seatTwoFor } from '../types'
24
26
 
25
27
  // The player-panel area: whenever a real device rotation changes orientationMode or which physical
26
28
  // side P1 lands on, the panels fade out, the layout underneath swaps while invisible, then they fade
@@ -31,7 +33,7 @@ const PANEL_SWAP_FADE_MS = 180
31
33
 
32
34
  // A game that has a loadout (GameDefinition.useLoadout): the loadout route sends any other straight
33
35
  // to its play screen.
34
- export type LoadoutGame = GameDefinition & Required<Pick<GameDefinition, 'useLoadout'>>
36
+ export type LoadoutGame = PackGameDefinition & Required<Pick<PackGameDefinition, 'useLoadout'>>
35
37
 
36
38
  // Every game's loadout: seat 1 (the player picked on Home), seat 2 (the CPU, a second person, or
37
39
  // nobody, from seat 2's own button, or no button in a game for one player), the game's own match
@@ -66,6 +68,10 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
66
68
  const fixedSeats = p2IsHuman && (!!game.twoPlayerSeats?.faceToFaceOnWeb || Platform.OS !== 'web')
67
69
  const fixedUpsideDown = !!game.twoPlayerSeats?.followUpsideDown && orientationMode === 'faceToFace' && upsideDown
68
70
  const { panelLayout, panelFadeStyle } = useDualZoneLayout(fixedSeats ? 'faceToFace' : orientationMode, fixedSeats ? true : p1OnRight, fixedSeats || p1OnRightResolved, fixedSeats ? fixedUpsideDown : upsideDown, PANEL_SWAP_FADE_MS)
71
+ // split-screen types the fade as a style for any component (useAnimatedStyle's DefaultStyle). An app
72
+ // whose types widen Text's style (Expo's react-native-web types, which a generated expo-env.d.ts pulls
73
+ // in, make its cursor any string) can't hand that to Animated.View. It only ever sets opacity.
74
+ const playersRowFade = panelFadeStyle as unknown as StyleProp<ViewStyle>
69
75
 
70
76
  const [settingsOpen, setSettingsOpen] = useState(false)
71
77
  // Safe-area insets are relative to the device's fixed portrait frame; rotateInsets remaps them
@@ -113,7 +119,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
113
119
  useEffect(() => {
114
120
  if (!humanSeats.every((s) => ready[s])) return
115
121
  dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }))
116
- router.push({ pathname: '/play/[game]', params: { game: game.id } })
122
+ router.push(playHref(game.id))
117
123
  }, [ready, humanSeats, game.id, mode, dispatch])
118
124
 
119
125
  const bg = dark ? '#000000' : '#FFFFFF'
@@ -166,7 +172,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
166
172
 
167
173
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation }
168
174
  const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color })
169
- const p1Panel = <PlayerSetupPanel 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='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push('/profiles')} />
175
+ const p1Panel = <PlayerSetupPanel 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='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} />
170
176
  const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : undefined
171
177
  // A game for one player (no seatTwo) has no seat 2 button: seat 1's panel stands alone.
172
178
  const p2Panel = game.seatTwo && <SeatTwoPanel idPrefix='p2' host={p2Host} choices={game.seatTwo} seatTwo={seatTwo} onSeatTwoChange={changeSeatTwo} cpuDifficulty={loadout.cpuDifficulty} profiles={profiles} selectedProfileId={lastSelected[2]} onProfileSelect={handleP2ProfileSelect} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} dark={dark} secondPicker={p2Control} secondPickerId={p2Control ? 'p2-control' : undefined} ready={ready[2]} onToggleReady={() => setReady((r) => ({ ...r, 2: !r[2] }))} showReadyButton={showReadyButton} />
@@ -202,7 +208,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
202
208
  ) : (
203
209
  <View style={styles.stackedZone}>
204
210
  {sharedControls}
205
- <Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, panelFadeStyle]}>
211
+ <Animated.View style={[styles.playersRow, anyPlayerPickerOpen && styles.playersRowOpen, playersRowFade]}>
206
212
  {!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel}
207
213
  {!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel}
208
214
  </Animated.View>
@@ -1,14 +1,39 @@
1
1
  import { defaultColors } from '@rific/auto-paper'
2
+ import { type AlignResult, RotatedFrame } from '@tastic/hud/chrome'
2
3
  import { ProfilesScreen } from '@tastic/profile'
3
4
 
4
5
  import { useProfiles } from '../hooks/useProfiles'
5
6
  import { safeBack } from '../navigation'
7
+ import { usePack } from '../PackContext'
8
+
9
+ // Widest the roster gets in the wide layout (a phone held sideways, a desktop window). Upright it's
10
+ // never capped.
11
+ const ROSTER_MAX_WIDTH = 640
6
12
 
7
13
  // The Profiles screen (Manage, from either seat 1 button). Deleting a profile also removes its
8
14
  // stats and achievements in every game: each game's reducers listen for the removal (see
9
15
  // onProfileRemoved.ts).
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
21
+ // pack's Backdrop the screen is transparent, drawn in the pack's foreground.
22
+ // The pack's own placement for a profile's color swatch (PackDefinition.usePickerAlign), kept inside the
23
+ // roster's capped column: the roster scrolls in it, which clips the menu at its edges. Handed to
24
+ // ProfilesScreen only where the pack has one.
25
+ function useRosterAlign(open: boolean, contentWidth: number, contentHeight: number): AlignResult {
26
+ const { usePickerAlign } = usePack()
27
+ return usePickerAlign!(open, contentWidth, contentHeight, { maxContentWidth: ROSTER_MAX_WIDTH })
28
+ }
29
+
10
30
  export function ProfilesRoute() {
11
31
  const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
32
+ const { Backdrop, foreground, newProfileColor, usePickerAlign } = usePack()
12
33
 
13
- return <ProfilesScreen profiles={profiles} defaultColor={defaultColors[0].value} onCreate={(patch) => createProfile(patch)} onSave={(id, patch) => updateProfile(id, patch)} onDelete={deleteProfile} onBack={safeBack} />
34
+ return (
35
+ <RotatedFrame rotatedSafeArea portalHost maxContentWidth={ROSTER_MAX_WIDTH}>
36
+ <ProfilesScreen profiles={profiles} defaultColor={newProfileColor ?? defaultColors[0].value} onCreate={(patch) => createProfile(patch)} onSave={(id, patch) => updateProfile(id, patch)} onDelete={deleteProfile} onBack={safeBack} fg={foreground} bg={Backdrop ? 'transparent' : undefined} useColorPickerAlign={usePickerAlign ? useRosterAlign : undefined} />
37
+ </RotatedFrame>
38
+ )
14
39
  }