@tastic/shell 0.1.1 → 0.2.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,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
  }
@@ -1,8 +1,7 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
- import { FakeLandscapeView, rotateInsets, useRotation } from '@tastic/core'
3
- import { BaseStatsScreen, PressAwayOverlay, usePopoverHost } from '@tastic/hud'
4
- import { OptionPicker, type OptionPickerOption } from '@tastic/profile'
5
- import { useMemo, useState } from 'react'
2
+ import { BaseStatsScreen, type PopoverHost, PressAwayOverlay, RotatedFrame, usePopoverHost } from '@tastic/hud/chrome'
3
+ import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption } from '@tastic/profile'
4
+ import { type ReactNode, useMemo, useState } from 'react'
6
5
  import { StyleSheet, View } from 'react-native'
7
6
  import { SegmentedButtons, Text } from 'react-native-paper'
8
7
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -11,15 +10,22 @@ import { useDispatch } from 'react-redux'
11
10
  import { useProfiles } from '../hooks/useProfiles'
12
11
  import { safeBack } from '../navigation'
13
12
  import { usePack } from '../PackContext'
14
- import type { GameDefinition } from '../types'
13
+ import { GUEST_PLAYER_ID, type PackDefinition, type PackGameDefinition, type PackStatsTab } from '../types'
15
14
 
16
15
  // Two tabs, as on Solitaire's stats screen: a page with every game's stats above the achievements
17
- // would leave the achievements out of sight at the bottom.
18
- type StatsTab = 'stats' | 'achievements'
16
+ // would leave the achievements out of sight at the bottom. A pack's own tabs (PackDefinition.statsTabs)
17
+ // go between them.
18
+ const STATS = 'stats'
19
+ const ACHIEVEMENTS = 'achievements'
19
20
  const TABS = [
20
- { value: 'stats', label: 'Stats', icon: 'chart-box-outline' },
21
- { value: 'achievements', label: 'Achievements', icon: 'trophy-outline' }
21
+ { value: STATS, label: 'Stats', icon: 'chart-box-outline' },
22
+ { value: ACHIEVEMENTS, label: 'Achievements', icon: 'trophy-outline' }
22
23
  ]
24
+ // With a pack's own tabs, three or more don't fit a phone as equal segments with icons (Paper pads each
25
+ // label 16pt a side, and "Achievements" alone is ~109pt in Solitaire's font), so each segment is as wide
26
+ // as its label and the icons go: Solitaire's three take 309pt of a 375pt phone's 335pt row.
27
+ const LABEL_WIDE_TAB = { flexBasis: 'auto', flexGrow: 1, flexShrink: 1 } as const
28
+ const NO_PACK_TABS: readonly PackStatsTab[] = []
23
29
 
24
30
  // The pickers' values for Everyone and All Games (neither is ever a profile or game id).
25
31
  const EVERYONE = '__everyone__'
@@ -28,76 +34,149 @@ const ALL_GAMES = '__all__'
28
34
  const PLAYER_PICKER_ID = 'stats-player'
29
35
  const GAME_PICKER_ID = 'stats-game'
30
36
 
37
+ // Widest the column gets in the wide layout (a phone held sideways, a desktop window): a list of stat
38
+ // rows stretched across an 874pt-wide screen reads badly. Upright it's never capped.
39
+ const STATS_MAX_WIDTH = 640
40
+
31
41
  // Every game's stats and achievements on one screen, filtered by player (Everyone, or one profile)
32
42
  // and by game (All Games, or one), like Solitaire's. Each game keeps its own stats and its own
33
43
  // achievements; this screen only shows them side by side. With two seats there's no one player to
34
- // open on, so it opens on Everyone. Guests count only toward Everyone: two guests in one match can't
35
- // be told apart.
44
+ // open on, so it opens on Everyone, and guests count only toward Everyone: two guests in one match
45
+ // can't be told apart. A pack for one player at a time (PackDefinition.guestStats) keeps a guest's
46
+ // stats under GUEST_PLAYER_ID, offers Guest in the picker and opens on whoever is in seat 1.
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.
36
51
  export function StatsScreen() {
52
+ const pickerHost = usePopoverHost()
53
+ return (
54
+ <StatsPressAway host={pickerHost}>
55
+ <RotatedFrame rotatedSafeArea portalHost maxContentWidth={STATS_MAX_WIDTH}>
56
+ <StatsContent pickerHost={pickerHost} />
57
+ </RotatedFrame>
58
+ </StatsPressAway>
59
+ )
60
+ }
61
+
62
+ // Closes the pickers' open menu on a press anywhere on the screen that nothing else takes: the header
63
+ // (its title and blank space) and, in the wide layout, the frame either side of the capped column. The
64
+ // page's own press-away overlay only covers the scrolling content, which neither is part of. A press
65
+ // only reaches this when no control under the finger claims it, so the back button, the menu's rows and
66
+ // everything else still work.
67
+ function StatsPressAway({ host, children }: { host: PopoverHost; children: ReactNode }) {
68
+ return (
69
+ <View testID='stats-press-away' style={styles.screen} onStartShouldSetResponder={() => host.openId !== null} onResponderRelease={host.close}>
70
+ {children}
71
+ </View>
72
+ )
73
+ }
74
+
75
+ // A pack whose Reset follows the game picker resets each game's own (GameDefinition.resetStats).
76
+ const useNoPackReset = () => null
77
+
78
+ // No placement of the pack's own: hud places the pickers' menus itself.
79
+ const noPickerAlign = () => undefined
80
+
81
+ function StatsContent({ pickerHost }: { pickerHost: PopoverHost }) {
37
82
  const pack = usePack()
38
83
  const { dark, colors } = useAutoPaperTheme()
39
- // Turned with the phone like every other screen: rotateInsets remaps the device's raw safe-area
40
- // reading onto the edges they land on once FakeLandscapeView turns the content.
41
- const rotation = useRotation()
42
- const insets = rotateInsets(useSafeAreaInsets(), rotation)
43
- const { profiles } = useProfiles()
84
+ // Already remapped onto the turned frame's edges by RotatedFrame's rotatedSafeArea.
85
+ const insets = useSafeAreaInsets()
86
+ const { profiles, lastSelected } = useProfiles()
44
87
  const dispatch = useDispatch()
45
- const pickerHost = usePopoverHost()
88
+ const fg = pack.foreground ?? colors.onBackground
46
89
 
47
- const [tab, setTab] = useState<StatsTab>('stats')
90
+ const [tab, setTab] = useState<string>(STATS)
48
91
  const [gameId, setGameId] = useState<string>(ALL_GAMES)
49
- // Self-heals to Everyone if the selected profile is deleted while this screen is mounted.
50
- const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null)
51
- const profileId = selectedProfileId && profiles.some((p) => p.id === selectedProfileId) ? selectedProfileId : null
92
+ // Opens on whoever is in seat 1 (Guest for a guest) where the pack keeps a guest's stats, else on
93
+ // Everyone. Self-heals to Everyone if the selected profile is deleted while this screen is mounted, and
94
+ // from Guest once there is no profile (Everyone is then just the guest).
95
+ const [selectedProfileId, setSelectedProfileId] = useState<string | null>(() => (pack.guestStats ? (lastSelected[1] ?? GUEST_PLAYER_ID) : null))
96
+ const profileId = selectedProfileId && profiles.length > 0 && ((pack.guestStats && selectedProfileId === GUEST_PLAYER_ID) || profiles.some((p) => p.id === selectedProfileId)) ? selectedProfileId : null
52
97
 
53
98
  const games = gameId === ALL_GAMES ? pack.games : pack.games.filter((g) => g.id === gameId)
99
+ const packTabs = pack.statsTabs ?? NO_PACK_TABS
100
+ const packTab = packTabs.find((t) => t.value === tab)
101
+ // One achievement list across the pack's games doesn't narrow to a game, so the game picker goes.
102
+ const showGamePicker = !(tab === ACHIEVEMENTS && pack.AchievementList)
54
103
 
55
- const playerOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: EVERYONE, label: 'Everyone', icon: 'account-group' }, ...profiles.map((p) => ({ value: p.id, label: p.name, icon: 'account', color: p.color }))], [profiles])
104
+ const playerOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: EVERYONE, label: 'Everyone', icon: 'account-group' }, ...(pack.guestStats ? [{ value: GUEST_PLAYER_ID, label: 'Guest', icon: 'account-outline' }] : []), ...profiles.map((p) => ({ value: p.id, label: p.name, icon: 'account', color: pack.previewColor ? pack.previewColor(p.color, dark) : p.color }))], [profiles, pack, dark])
56
105
  const gameOptions = useMemo<OptionPickerOption<string>[]>(() => [{ value: ALL_GAMES, label: 'All Games', icon: 'view-grid-outline' }, ...pack.games.map((g) => ({ value: g.id, label: g.title }))], [pack.games])
106
+ const tabButtons = useMemo(() => (packTabs.length > 0 ? [{ value: STATS, label: 'Stats' }, ...packTabs.map(({ value, label }) => ({ value, label })), { value: ACHIEVEMENTS, label: 'Achievements' }].map((button) => ({ ...button, style: LABEL_WIDE_TAB })) : TABS), [packTabs])
107
+ const title = tab === STATS ? 'Stats' : tab === ACHIEVEMENTS ? 'Achievements' : (packTab?.label ?? '')
108
+
109
+ // Reset: the pack's own (PackDefinition.resetStats) follows the player picker, one player's or
110
+ // everyone's; otherwise it clears every player's stats and achievements in the games shown (one game,
111
+ // or all of them). The pack never changes while it runs, so the same hook is called on every render.
112
+ const usePackReset = pack.resetStats?.useReset ?? useNoPackReset
113
+ const packReset = usePackReset()
114
+ const reset = resetFor(pack, packReset, profileId, profiles, games, () => games.forEach((g) => g.resetStats && dispatch(g.resetStats())))
57
115
 
58
- // Reset clears every player's stats and achievements in the games shown (one game, or all of them).
59
- const resetScope = games.length === 1 ? games[0].title : 'every game'
60
- const onReset = () => games.forEach((g) => dispatch(g.resetStats()))
116
+ // The pack's own placement for the pickers' menus (PackDefinition.usePickerAlign), sized from their rows.
117
+ const usePickerAlign = pack.usePickerAlign ?? noPickerAlign
118
+ const playerSize = getOptionPickerContentSize(playerOptions.length)
119
+ const gameSize = getOptionPickerContentSize(gameOptions.length)
120
+ const playerAlign = usePickerAlign(pickerHost.openId === PLAYER_PICKER_ID, playerSize.width, playerSize.height)
121
+ const gameAlign = usePickerAlign(pickerHost.openId === GAME_PICKER_ID, gameSize.width, gameSize.height)
122
+
123
+ // Over the pack's Backdrop the page is transparent, with its tabs and pickers on the theme's surface.
124
+ const surface = pack.Backdrop ? { backgroundColor: colors.surface } : undefined
125
+ const triggerStyle = pack.Backdrop ? [styles.pill, surface] : undefined
126
+ const PackTabContent = packTab?.Content
61
127
 
62
128
  return (
63
- <FakeLandscapeView style={styles.rotatable}>
64
- <BaseStatsScreen title={tab === 'stats' ? 'Stats' : 'Achievements'} onBack={safeBack} insets={insets} onReset={onReset} resetLabel={games.length === 1 ? `Reset ${resetScope} Stats` : 'Reset All Stats'} resetConfirmTitle='Reset Stats?' resetConfirmBody={`Erases every player's stats and achievements in ${resetScope}. This cannot be undone.`} rotation={rotation}>
65
- {/* Inside the page, not before it: BaseStatsScreen paints an opaque full-screen container that
66
- would hide a tap catcher placed underneath it. Over the stat sections and under the open
67
- filters (pressAway's zIndex sits below filtersOpen's), so a tap anywhere on the page but a
68
- menu closes it. */}
69
- <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
70
- <SegmentedButtons value={tab} onValueChange={(value) => setTab(value as StatsTab)} buttons={TABS} style={styles.tabs} />
71
-
72
- <View style={[styles.filters, pickerHost.openId !== null && styles.filtersOpen]}>
73
- {profiles.length > 0 && <OptionPicker id={PLAYER_PICKER_ID} host={pickerHost} options={playerOptions} value={profileId ?? EVERYONE} onChange={(value) => setSelectedProfileId(value === EVERYONE ? null : value)} color={colors.primary} dark={dark} icon={profileId ? 'account' : 'account-group'} />}
74
- <OptionPicker id={GAME_PICKER_ID} host={pickerHost} options={gameOptions} value={gameId} onChange={setGameId} color={colors.primary} dark={dark} icon='view-grid-outline' />
75
- </View>
76
-
77
- {/* The pack's own section across its games (a calendar of play), first on the Stats tab: it
78
- follows both filters, so narrowed to one game it shows that game's part. */}
79
- {tab === 'stats' && pack.StatsSections && <pack.StatsSections profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
80
-
81
- {games.map((game) => (
82
- <GameStats key={game.id} game={game} tab={tab} profileId={profileId} showTitle={games.length > 1} />
83
- ))}
84
- </BaseStatsScreen>
85
- </FakeLandscapeView>
129
+ <BaseStatsScreen title={title} onBack={safeBack} insets={insets} onReset={reset.onReset} resetLabel={reset.label} resetConfirmTitle={reset.title} resetConfirmBody={reset.body} fg={pack.foreground} bg={pack.Backdrop ? 'transparent' : undefined} rotation={0}>
130
+ {/* Inside the page, not before it: BaseStatsScreen paints an opaque full-screen container that
131
+ would hide a tap catcher placed underneath it. Over the stat sections and under the open
132
+ filters (pressAway's zIndex sits below filtersOpen's), so a tap anywhere on the page but a
133
+ menu closes it. Off the page (the header, the margins) StatsPressAway closes it. */}
134
+ <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
135
+ <SegmentedButtons value={tab} onValueChange={setTab} buttons={tabButtons} style={[styles.tabs, surface]} />
136
+
137
+ {/* Takes a press on its own blank parts (the gap, an open menu's padding) while a menu is open, so
138
+ it never reaches StatsPressAway. */}
139
+ <View style={[styles.filters, pickerHost.openId !== null && styles.filtersOpen]} onStartShouldSetResponder={() => pickerHost.openId !== null}>
140
+ {profiles.length > 0 && <OptionPicker id={PLAYER_PICKER_ID} host={pickerHost} options={playerOptions} value={profileId ?? EVERYONE} onChange={(value) => setSelectedProfileId(value === EVERYONE ? null : value)} color={colors.primary} dark={dark} icon={profileId ? 'account' : 'account-group'} alignOverride={playerAlign} triggerStyle={triggerStyle} />}
141
+ {showGamePicker && <OptionPicker id={GAME_PICKER_ID} host={pickerHost} options={gameOptions} value={gameId} onChange={setGameId} color={colors.primary} dark={dark} icon='view-grid-outline' alignOverride={gameAlign} triggerStyle={triggerStyle} />}
142
+ </View>
143
+
144
+ {/* The pack's own section across its games (a calendar of play), first on the Stats tab: it
145
+ follows both filters, so narrowed to one game it shows that game's part. */}
146
+ {tab === STATS && pack.StatsSections && <pack.StatsSections profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
147
+
148
+ {PackTabContent && <PackTabContent profileId={profileId} gameId={gameId === ALL_GAMES ? null : gameId} />}
149
+
150
+ {tab === ACHIEVEMENTS && pack.AchievementList ? <pack.AchievementList profileId={profileId} /> : (tab === STATS || tab === ACHIEVEMENTS) && games.map((game) => <GameStats key={game.id} game={game} tab={tab} profileId={profileId} showTitle={games.length > 1 && pack.statsGameTitles !== false} titleColor={fg} />)}
151
+ </BaseStatsScreen>
86
152
  )
87
153
  }
88
154
 
89
- // One game's part of the page: its own sections, under its name when more than one game is shown.
90
- function GameStats({ game, tab, profileId, showTitle }: { game: GameDefinition; tab: StatsTab; profileId: string | null; showTitle: boolean }) {
91
- const { colors } = useAutoPaperTheme()
155
+ // What the screen's Reset does and says: the pack's own, for the player picked (by name; Guest for a
156
+ // guest) or everyone, or each shown game's own.
157
+ function resetFor(pack: PackDefinition, packReset: ((profileId: string | null) => void) | null, profileId: string | null, profiles: readonly { id: string; name: string }[], games: readonly PackGameDefinition[], resetGames: () => void) {
158
+ if (pack.resetStats && packReset) {
159
+ const name = profileId === GUEST_PLAYER_ID ? 'Guest' : profileId ? (profiles.find((p) => p.id === profileId)?.name ?? null) : null
160
+ return { onReset: () => packReset(profileId), ...pack.resetStats.labels(name) }
161
+ }
162
+ const scope = games.length === 1 ? games[0].title : 'every game'
163
+ return { onReset: resetGames, label: games.length === 1 ? `Reset ${scope} Stats` : 'Reset All Stats', title: 'Reset Stats?', body: `Erases every player's stats and achievements in ${scope}. This cannot be undone.` }
164
+ }
165
+
166
+ // One game's part of the page: its own sections, under its name when more than one game is shown. A
167
+ // game with no achievement list of its own shows nothing on Achievements.
168
+ function GameStats({ game, tab, profileId, showTitle, titleColor }: { game: PackGameDefinition; tab: string; profileId: string | null; showTitle: boolean; titleColor: string }) {
92
169
  const { StatsSections, AchievementList } = game
170
+ const Sections = tab === STATS ? StatsSections : AchievementList
171
+ if (!Sections) return null
93
172
  return (
94
173
  <View style={styles.game}>
95
174
  {showTitle && (
96
- <Text variant='titleLarge' style={[styles.gameTitle, { color: colors.onBackground }]}>
175
+ <Text variant='titleLarge' style={[styles.gameTitle, { color: titleColor }]}>
97
176
  {game.title}
98
177
  </Text>
99
178
  )}
100
- {tab === 'stats' ? <StatsSections profileId={profileId} /> : <AchievementList profileId={profileId} />}
179
+ <Sections profileId={profileId} />
101
180
  </View>
102
181
  )
103
182
  }
@@ -119,14 +198,17 @@ const styles = StyleSheet.create({
119
198
  fontWeight: 'bold',
120
199
  marginTop: 8
121
200
  },
201
+ // A picker's button on the theme's surface (over a backdrop), the same pill as Home's.
202
+ pill: {
203
+ borderRadius: 999
204
+ },
122
205
  pressAway: {
123
206
  zIndex: 50
124
207
  },
125
- // A real full-bleed box for FakeLandscapeView to size its turned content against.
126
- rotatable: {
208
+ screen: {
127
209
  flex: 1
128
210
  },
129
- // SegmentedButtons' own rounding.
211
+ // SegmentedButtons' own rounding, so a surface behind it is the same pill.
130
212
  tabs: {
131
213
  borderRadius: 20
132
214
  }
package/src/types.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { ThemeAppearance } from '@rific/auto-paper'
1
+ import type { ThemeAppearance, ThemeSettings } from '@rific/auto-paper'
2
2
  import type { ViewRotation } from '@tastic/core'
3
- import type { MenuOption, PopoverHost } from '@tastic/hud'
3
+ import type { AlignResult, PopoverHost } from '@tastic/hud/chrome'
4
4
  import type { GuideStep } from '@tastic/hud/guide'
5
+ import type { MenuOption } from '@tastic/hud/lobby'
5
6
  import type { FontSource } from 'expo-font'
6
- import type { ComponentType, ReactNode } from 'react'
7
+ import type { Stack } from 'expo-router'
8
+ import type { ComponentProps, ComponentType, ReactNode } from 'react'
7
9
  import type { Action, Reducer } from 'redux'
8
10
 
9
11
  import type { ShellState } from './redux/shellState'
@@ -79,6 +81,11 @@ export interface GameLoadout {
79
81
  cpuDifficulty?: { value: string; options: MenuOption<string>[]; onChange: (value: string) => void }
80
82
  }
81
83
 
84
+ // The id a guest's stats are kept under where a pack keeps them (PackDefinition.guestStats): the one
85
+ // player who isn't a profile, in a pack for one player at a time. Not seatPlayerKey's `guest:1`, which
86
+ // tells two guests at one table apart.
87
+ export const GUEST_PLAYER_ID = 'guest'
88
+
82
89
  // What a game's How to Play cards are built from: the live settings (through the game's own
83
90
  // selector, so the guide host never has to know their shape), how seat 2 is set up, and whether
84
91
  // the device is touch-first.
@@ -113,12 +120,30 @@ export interface GamePreviewProps {
113
120
  dark: boolean
114
121
  }
115
122
 
123
+ // The pack's own How to Play (PackDefinition.guide): cards that teach every game in the pack at once,
124
+ // shown on Home's first launch and replayed from Home's Settings. Its seen version is a key of the
125
+ // pack's own (hud's createGuideSlice), so a pack keeps whatever it already saved.
126
+ export interface PackGuide {
127
+ version: number
128
+ selectVersionSeen: (state: ShellState) => number
129
+ markSeen: (version: number) => Action
130
+ getSteps: (context: { touch: boolean }) => GuideStep[]
131
+ }
132
+
133
+ // A game's rules sheet (GameDefinition.Rules), opened from its tile's rules button on Home. It renders
134
+ // through a Paper Portal into a host Home mounts inside its turned frame, so it's already turned with it.
135
+ export interface GameRulesProps {
136
+ visible: boolean
137
+ onDismiss: () => void
138
+ }
139
+
116
140
  export interface GameDefinition {
117
141
  id: string
118
142
  title: string
119
143
  // One line under the tile's name on Home.
120
144
  blurb: string
121
- icon: ComponentType<GameIconProps>
145
+ // Left out only by a game with a Preview, which Home draws in its place.
146
+ icon?: ComponentType<GameIconProps>
122
147
  // Drawn on Home in place of the icon, a frame after Home first paints (a preview is far more to
123
148
  // draw than an icon). While any game in the pack has one, Home lays its games out as tiles: rows
124
149
  // with the preview at their start, and a grid beside the hero on a wide screen. A game without one
@@ -134,8 +159,9 @@ export interface GameDefinition {
134
159
  twoPlayerSeats?: { faceToFaceOnWeb: boolean; followUpsideDown: boolean }
135
160
  // The store keys this game mounts (settings, achievements, guide), merged into the pack's store.
136
161
  reducers: Record<string, Reducer>
137
- // Mounted at the app root for the game's lifetime (its stats and achievements context).
138
- Provider: ComponentType<{ children: ReactNode }>
162
+ // Mounted at the app root for the game's lifetime (its stats and achievements context). Left out by
163
+ // a game whose stats live in the pack's own (PackDefinition.Provider).
164
+ Provider?: ComponentType<{ children: ReactNode }>
139
165
  // Left out by a game with nothing to set up before it starts: Home opens its play screen
140
166
  // straight away (after its How to Play the first time), and its rule variants are rows in its
141
167
  // play screen's own Settings (SettingsDialog's children).
@@ -146,15 +172,34 @@ export interface GameDefinition {
146
172
  // board: the shell only remembers which game it was (redux/lastActiveGameSlice.ts). A match that
147
173
  // isn't saved (liveplay) shouldn't opt in, or a relaunch would open a fresh one.
148
174
  resumable?: boolean
175
+ // The play screen keeps the status bar while the phone is held upright. Unset, the bar is hidden on
176
+ // every play screen for the whole round, whatever the angle; set, it's hidden only while the
177
+ // content is turned, as on every other screen.
178
+ keepStatusBar?: boolean
149
179
  // The game's stats sections and its achievement list for the stats screen, for one profile
150
180
  // (null is Everyone).
151
181
  StatsSections: ComponentType<{ profileId: string | null }>
152
- AchievementList: ComponentType<{ profileId: string | null }>
153
- // Clears the game's stats and achievements (the stats screen's Reset).
154
- resetStats: () => Action
182
+ // Left out by a game whose achievements are in the pack's one list (PackDefinition.AchievementList).
183
+ AchievementList?: ComponentType<{ profileId: string | null }>
184
+ // Clears the game's stats and achievements (the stats screen's Reset). Left out by a game whose stats
185
+ // the pack resets itself (PackDefinition.resetStats).
186
+ resetStats?: () => Action
187
+ // The game's own How to Play cards: shown the first time its tile is picked, and from its rules
188
+ // button and its loadout's Settings.
155
189
  guide: GameGuide
190
+ // The rules sheet its tile's rules button opens, in place of its How to Play cards.
191
+ Rules?: ComponentType<GameRulesProps>
156
192
  }
157
193
 
194
+ // A game with no How to Play cards of its own, taught by the pack's own (PackDefinition.guide): its
195
+ // tile opens without cards the first time, and its rules button shows its Rules or nothing. Its own type
196
+ // rather than an optional `guide` on GameDefinition, so a pack's code that reads its own games' cards
197
+ // (DuelTastic's packGuides test) keeps compiling.
198
+ export type GuidelessGameDefinition = Omit<GameDefinition, 'guide'> & { guide?: undefined }
199
+
200
+ // Any game a pack holds: one with How to Play cards of its own, or one without.
201
+ export type PackGameDefinition = GameDefinition | GuidelessGameDefinition
202
+
158
203
  // What Home hands the pack's hero (PackDefinition.HeroTitle).
159
204
  export interface HeroTitleProps {
160
205
  // The pack's wordmark (see PackDefinition.wordmark).
@@ -180,13 +225,41 @@ export interface PackStatsSectionsProps {
180
225
  gameId: string | null
181
226
  }
182
227
 
228
+ // The stack's screen options (expo-router's Stack), as an object: what PackDefinition.useScreenOptions
229
+ // returns.
230
+ export type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>
231
+
232
+ // The two sounds feedback-press plays on every button press (PackDefinition.useFeedbackSounds): a tap,
233
+ // and a refused or notifying press.
234
+ export interface FeedbackSounds {
235
+ selection: () => void
236
+ notification: () => void
237
+ }
238
+
239
+ // A tab of the pack's own on the stats screen (PackDefinition.statsTabs), between Stats and
240
+ // Achievements, handed the screen's two filters.
241
+ export interface PackStatsTab {
242
+ value: string
243
+ label: string
244
+ Content: ComponentType<PackStatsSectionsProps>
245
+ }
246
+
247
+ // The stats screen's Reset, where the pack resets stats itself (PackDefinition.resetStats). It follows
248
+ // the player picker: one player's (a profile id, or GUEST_PLAYER_ID), or everyone's (null).
249
+ export interface PackStatsReset {
250
+ useReset: () => (profileId: string | null) => void
251
+ // The button, the confirm's title and its body, for a player's name (Guest for a guest) or null for
252
+ // Everyone.
253
+ labels: (name: string | null) => { label: string; title: string; body: string }
254
+ }
255
+
183
256
  export interface PackDefinition {
184
257
  name: string
185
258
  // The Home wordmark's two lines.
186
259
  wordmark: readonly [string, string]
187
260
  // A seat's color pick for a profile is kept for this pack only; the seat button says so.
188
261
  colorCaption: string
189
- games: readonly GameDefinition[]
262
+ games: readonly PackGameDefinition[]
190
263
  // Player 1's and player 2's own default colors. They seed the theme's primary and secondary (the
191
264
  // tertiary is derived from them, see shellState.ts) and the guest and CPU colors a fresh install
192
265
  // starts with (seatColorsSlice.ts), so a fresh install's live theme and its remembered colors agree.
@@ -213,4 +286,64 @@ export interface PackDefinition {
213
286
  // A section of the pack's own at the top of the stats screen's Stats tab, across its games (a
214
287
  // calendar of play), above each game's own StatsSections. It follows the screen's two filters.
215
288
  StatsSections?: ComponentType<PackStatsSectionsProps>
289
+
290
+ // A table drawn once behind the whole stack (a felt), seen through every screen: the stack's own
291
+ // layers go transparent, and Home, the stats screen and Profiles draw on it in `foreground`.
292
+ Backdrop?: ComponentType
293
+ // The text and icon color of what Home, the stats screen and Profiles draw straight on the screen's
294
+ // background (the hero, the corner buttons, headers). Unset is black or white with the theme.
295
+ foreground?: string
296
+ // Mounted once around How to Play and the stack, inside the store, Theme, ProfilesProvider and every
297
+ // game's Provider: a context of the pack's own (one stats context across its games).
298
+ Provider?: ComponentType<{ children: ReactNode }>
299
+ // Merged into the stack's screen options after the shell's own (a status bar style, a crossfade).
300
+ useScreenOptions?: () => StackScreenOptions
301
+ // The two sounds every button plays, in place of the shell's own.
302
+ useFeedbackSounds?: () => FeedbackSounds
303
+ // The pack's own toaster, drawn in a frame turned with the phone (with the safe-area insets turned
304
+ // onto the frame's edges) in place of the shell's. It renders inline: the toast provider is given no
305
+ // Paper, so nothing portals it out of the frame.
306
+ Toaster?: ComponentType
307
+ // Every screen reads upright while the software keyboard is up (@tastic/core's OrientationProvider),
308
+ // for typing a profile's name with the phone sideways.
309
+ portraitWhileKeyboard?: boolean
310
+ // false leaves Edge Guard out of Settings (a pack with no edge controls and no Edge Guard plugin).
311
+ edgeGuard?: boolean
312
+ // The theme seat 1's color makes (a muted seed with a harmony), applied whenever the seats' colors or
313
+ // dark mode change and as a fresh install's theme, in place of a triad of the raw seat colors. With it,
314
+ // each seat's color is read from the seat colors rather than from the theme.
315
+ seatTheme?: (colors: Record<Seat, string>, dark: boolean) => Pick<ThemeSettings, 'color' | 'harmony'>
316
+ // How every profile color is drawn (the button's chip, the swatches, each ProfileChip): mounted as
317
+ // @tastic/profile's ProfileColorProvider. The saved colors stay as picked.
318
+ previewColor?: (hex: string, dark: boolean) => string
319
+ // The pack's own How to Play: shown on Home's first launch (held back while a cold launch resumes a
320
+ // game) and from Home's Settings, never in a play screen.
321
+ guide?: PackGuide
322
+ // Seat 1's button: the chip's label for a guest (default 'P1') and the guest row's in its menu
323
+ // (default @tastic/profile's).
324
+ guestLabel?: string
325
+ nullLabel?: string
326
+ // The stats screen keeps a guest's stats under GUEST_PLAYER_ID: a Guest entry in the player picker
327
+ // once a profile exists, and the screen opens on whoever is in seat 1.
328
+ guestStats?: boolean
329
+ // One achievement list across the pack's games, on the Achievements tab (the game picker hidden there).
330
+ AchievementList?: ComponentType<{ profileId: string | null }>
331
+ // Tabs of the pack's own between Stats and Achievements.
332
+ statsTabs?: readonly PackStatsTab[]
333
+ // The stats screen's Reset, following the player picker rather than the game picker.
334
+ resetStats?: PackStatsReset
335
+ // false leaves out each game's name over its sections on All Games, where its sections name it.
336
+ statsGameTitles?: boolean
337
+ // A new profile's starting color on Profiles. Unset is auto-paper's first swatch.
338
+ newProfileColor?: string
339
+ // Where a picker's menu opens (its alignOverride) on Home (seat 1's button), the stats screen (its two
340
+ // pickers) and Profiles (a profile's color swatch). hud's own placement measures a turned button
341
+ // against the upright window, so with the phone held sideways it can pick the side with no room and
342
+ // run the menu off the screen (Solitaire's useFrameAutoAlign places it in the turned frame instead).
343
+ // `open` is whether this picker's menu is open, the size is its content's, and `maxContentWidth` is a
344
+ // capped column's width the menu stays inside (Profiles' roster). Unset is hud's own placement.
345
+ usePickerAlign?: PickerAlign
216
346
  }
347
+
348
+ // What PackDefinition.usePickerAlign is: a hook, called on every render of the screen it serves.
349
+ export type PickerAlign = (open: boolean, contentWidth: number, contentHeight: number, options?: { maxContentWidth?: number }) => AlignResult