@tastic/shell 0.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The app around a pack of React Native games, two-seat or for one player: Home, the loadout, the stats screen, Profiles and Settings, the pack's Redux store and its root providers, driven by one PackDefinition",
5
5
  "keywords": [
6
6
  "react-native",
@@ -86,7 +86,7 @@
86
86
  "@tastic/achievements": "^0.6.0",
87
87
  "@tastic/core": "^0.14.0",
88
88
  "@tastic/edge-guard": "^0.1.5",
89
- "@tastic/hud": "^0.25.1",
89
+ "@tastic/hud": "^0.26.0",
90
90
  "@tastic/profile": "^0.17.0",
91
91
  "@tastic/split-screen": "^0.5.5",
92
92
  "@testing-library/react-native": "^14.1.0",
@@ -138,17 +138,17 @@
138
138
  "@tastic/achievements": ">=0.6.0",
139
139
  "@tastic/core": ">=0.14.0",
140
140
  "@tastic/edge-guard": ">=0.1.5",
141
- "@tastic/hud": ">=0.25.1",
142
- "@tastic/profile": ">=0.17.0",
141
+ "@tastic/hud": ">=0.26.0",
142
+ "@tastic/profile": ">=0.16.2",
143
143
  "@tastic/split-screen": ">=0.5.5",
144
- "expo-blur": ">=58.0.5",
145
- "expo-constants": ">=58.0.13",
146
- "expo-font": ">=58.0.10",
144
+ "expo-blur": ">=58.0.4",
145
+ "expo-constants": ">=58.0.12",
146
+ "expo-font": ">=58.0.8",
147
147
  "expo-haptics": ">=58.0.7",
148
148
  "expo-keep-awake": ">=58.0.3",
149
- "expo-router": ">=58.0.26",
149
+ "expo-router": ">=58.0.23",
150
150
  "expo-sensors": ">=58.0.4",
151
- "expo-splash-screen": ">=58.0.11",
151
+ "expo-splash-screen": ">=58.0.10",
152
152
  "react": ">=19.3.0",
153
153
  "react-native": ">=0.88.0",
154
154
  "react-native-gesture-handler": ">=3.3.0",
@@ -1,6 +1,6 @@
1
1
  import { createContext, ReactNode, useContext } from 'react'
2
2
 
3
- import type { GameDefinition, PackDefinition } from './types'
3
+ import type { PackDefinition, PackGameDefinition } from './types'
4
4
 
5
5
  // The pack the shell is running: handed in once at the root (see RootLayout), so nothing in shell/
6
6
  // ever imports pack.ts or a game. Games reach the shell only through this registry.
@@ -17,6 +17,6 @@ export function usePack(): PackDefinition {
17
17
  }
18
18
 
19
19
  // A game by id, or undefined for an id the pack doesn't have (a stale link).
20
- export function usePackGame(id: string | undefined): GameDefinition | undefined {
20
+ export function usePackGame(id: string | undefined): PackGameDefinition | undefined {
21
21
  return usePack().games.find((g) => g.id === id)
22
22
  }
@@ -2,12 +2,13 @@ import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { useHapticSettings } from '@rific/feedback-press'
3
3
  import { Toaster, ToastProvider } from '@rific/toaster'
4
4
  import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground } from '@tastic/core'
5
- import { RotatedFrame } from '@tastic/hud'
5
+ import { RotatedFrame } from '@tastic/hud/chrome'
6
+ import { ProfileColorProvider } from '@tastic/profile'
6
7
  import * as Haptics from 'expo-haptics'
7
- import { Stack } from 'expo-router'
8
+ import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
8
9
  import { DeviceMotion } from 'expo-sensors'
9
- import { ReactNode, useCallback } from 'react'
10
- import { StyleSheet } from 'react-native'
10
+ import { ReactNode, useCallback, useMemo } from 'react'
11
+ import { Platform, StyleSheet, View } from 'react-native'
11
12
  import { GestureHandlerRootView } from 'react-native-gesture-handler'
12
13
  import * as RNPaper from 'react-native-paper'
13
14
  import { SafeAreaProvider } from 'react-native-safe-area-context'
@@ -21,10 +22,10 @@ import { UpdateDialog } from './components/UpdateDialog'
21
22
  import { useAppSettings } from './hooks/useAppSettings'
22
23
  import { LastActiveGameProvider } from './hooks/useLastActiveGame'
23
24
  import { ProfilesProvider, useProfiles } from './hooks/useProfiles'
24
- import { PackProvider } from './PackContext'
25
+ import { PackProvider, usePack } from './PackContext'
25
26
  import type { PackStore } from './redux/createPackStore'
26
27
  import { SplashGate } from './splashGate'
27
- import type { GameDefinition, PackDefinition } from './types'
28
+ import type { PackDefinition, PackGameDefinition } from './types'
28
29
 
29
30
  // The pack's root: every provider the shell needs, the pack's games' own providers, and the stack.
30
31
  // src/app/_layout.tsx hands it the pack (pack.ts) and its store, so nothing in shell/ imports a
@@ -45,18 +46,57 @@ import type { GameDefinition, PackDefinition } from './types'
45
46
  // statusBar={false}). No `locked` argument: OrientationBridge already freezes the reading it
46
47
  // follows. A play screen hides the bar for the whole round, whatever the angle (a screen's own
47
48
  // options win over screenOptions); index is listed first so it stays the stack's first route.
49
+ // A game with keepStatusBar (Solitaire's) keeps the screen options' rotation-aware bar instead.
48
50
  const PLAY_OPTIONS = { statusBarHidden: true }
51
+ const KEEP_STATUS_BAR_OPTIONS = {}
52
+
53
+ // A pack's Backdrop (PackDefinition.Backdrop) is drawn once behind the whole stack and has to show
54
+ // through two layers: each screen's contentStyle, and the layers React Navigation paints its theme's
55
+ // `colors.background` on around every screen (the ScreenStack's container on native, each screen's
56
+ // wrapper on the web). contentStyle alone left an opaque light grey slab over Solitaire's felt on the
57
+ // web. Nothing else reads the navigation theme (headers are hidden; Paper has its own). Built only for
58
+ // a pack with a backdrop, as it renders: never at import, where a pack's tests may stand in for
59
+ // expo-router without a DarkTheme.
60
+ const TRANSPARENT_CONTENT = { backgroundColor: 'transparent' }
61
+
62
+ // The pack's own screen options (PackDefinition.useScreenOptions), as a hook every pack calls: an
63
+ // empty object without them.
64
+ const noScreenOptions = () => ({})
49
65
 
50
66
  function AppStack() {
67
+ const pack = usePack()
68
+ const { Backdrop } = pack
51
69
  const { dark } = useAutoPaperTheme()
52
- const rootBackground = useThemedRootBackground(dark, false)
70
+ // Over a backdrop the root window stays dark whatever the mode: a backdrop is a dark table (the
71
+ // felt), and a light root window would flash white around it during a push or pop.
72
+ const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false)
53
73
  const statusBar = useRotationAwareStatusBar()
54
- return (
55
- <Stack screenOptions={{ ...rootBackground, ...statusBar }}>
74
+ const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
75
+ // Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
76
+ // the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
77
+ // straight through a transparent play screen. iOS keeps its own push.
78
+ const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: Platform.select({ android: 'fade' as const }) } : undefined
79
+ const playOptions = useCallback(
80
+ ({ route }: { route: { params?: object } }) => {
81
+ const id = (route.params as { game?: string } | undefined)?.game
82
+ return pack.games.some((g) => g.id === id && g.keepStatusBar) ? KEEP_STATUS_BAR_OPTIONS : PLAY_OPTIONS
83
+ },
84
+ [pack.games]
85
+ )
86
+ const navigationTheme = useMemo(() => (Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: 'transparent' } } : null), [Backdrop])
87
+ const stack = (
88
+ <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
56
89
  <Stack.Screen name='index' />
57
- <Stack.Screen name='play/[game]' options={PLAY_OPTIONS} />
90
+ <Stack.Screen name='play/[game]' options={playOptions} />
58
91
  </Stack>
59
92
  )
93
+ if (!Backdrop || !navigationTheme) return stack
94
+ return (
95
+ <View style={styles.root}>
96
+ <Backdrop />
97
+ <NavigationThemeProvider value={navigationTheme}>{stack}</NavigationThemeProvider>
98
+ </View>
99
+ )
60
100
  }
61
101
 
62
102
  // Wires the persisted Lock Orientation setting into @tastic/core's OrientationProvider. Its own
@@ -77,10 +117,15 @@ function AppStack() {
77
117
  // committed orientation survive navigation. deviceMotion is the app's own expo-sensors import, handed
78
118
  // in because @tastic/core never imports expo-sensors itself (apps without tilt tracking would
79
119
  // otherwise need it installed just to bundle).
120
+ //
121
+ // portraitWhileKeyboard (PackDefinition.portraitWhileKeyboard): the software keyboard can't turn with a
122
+ // faked rotation, so a pack that has the player type sideways (a profile's name) reads portrait while
123
+ // it's up.
80
124
  function OrientationBridge({ children }: { children: ReactNode }) {
81
125
  const { settings, setSettings } = useAppSettings()
126
+ const { portraitWhileKeyboard } = usePack()
82
127
  return (
83
- <OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })}>
128
+ <OrientationProvider deviceMotion={DeviceMotion} freezeWhileLocked lockInitialValue={{ locked: settings.lockOrientation, rotation: settings.lockedRotation }} onLockChange={(next) => setSettings({ lockOrientation: next.locked, lockedRotation: next.rotation })} portraitWhileKeyboard={portraitWhileKeyboard}>
84
129
  {children}
85
130
  </OrientationProvider>
86
131
  )
@@ -104,10 +149,14 @@ function GatedApp({ children }: { children: ReactNode }) {
104
149
  // follow the same setting as every other haptic in the app. Today they can't fire anyway (the only
105
150
  // controls that tick, the history and clear buttons, are off below), but this keeps it that way if
106
151
  // either is ever shown. Must render inside Providers, whose FeedbackBridge provides the setting.
152
+ //
153
+ // A pack with its own Toaster gets no Paper here: with Paper the toaster draws through a Paper Portal,
154
+ // which would take its toasts out of the turned frame PackToaster lays them in.
107
155
  function HapticGatedToastProvider({ children }: { children: ReactNode }) {
108
156
  const { settings } = useHapticSettings()
157
+ const { Toaster: PackToasterComponent } = usePack()
109
158
  return (
110
- <ToastProvider haptics={settings.vibrate ? Haptics : undefined} paper={RNPaper}>
159
+ <ToastProvider haptics={settings.vibrate ? Haptics : undefined} paper={PackToasterComponent ? undefined : RNPaper}>
111
160
  {children}
112
161
  </ToastProvider>
113
162
  )
@@ -125,9 +174,40 @@ function RotatingToaster() {
125
174
  return <Toaster historyButton={null} clearButton={null} limit={3} renderFrame={renderFrame} />
126
175
  }
127
176
 
128
- // Every game's own root provider (its stats and achievements context), nested in pack order.
129
- function GameProviders({ games, children }: { games: readonly GameDefinition[]; children: ReactNode }) {
130
- return games.reduceRight<ReactNode>((inner, game) => <game.Provider key={game.id}>{inner}</game.Provider>, children)
177
+ // The pack's own toaster (PackDefinition.Toaster), laid over the whole app in a frame turned with the
178
+ // phone: passThrough, so only a toast claims a touch over the stack, and rotatedSafeArea, so the
179
+ // toaster's own safe-area reading lands on the frame's edges. The absoluteFill wrapper takes the frame
180
+ // out of the layout beside the stack.
181
+ function PackToaster({ Toaster: PackToasterComponent }: { Toaster: NonNullable<PackDefinition['Toaster']> }) {
182
+ return (
183
+ <View style={StyleSheet.absoluteFill} pointerEvents='box-none'>
184
+ <RotatedFrame rotatedSafeArea passThrough>
185
+ <PackToasterComponent />
186
+ </RotatedFrame>
187
+ </View>
188
+ )
189
+ }
190
+
191
+ // Every game's own root provider (its stats and achievements context), nested in pack order. A game
192
+ // without one adds nothing.
193
+ function GameProviders({ games, children }: { games: readonly PackGameDefinition[]; children: ReactNode }) {
194
+ return games.reduceRight<ReactNode>((inner, game) => (game.Provider ? <game.Provider key={game.id}>{inner}</game.Provider> : inner), children)
195
+ }
196
+
197
+ // How every profile color is drawn (PackDefinition.previewColor), in the live mode: inside Theme for
198
+ // the `dark` read. A pack without it draws the colors as they are, with no provider.
199
+ function PreviewColors({ previewColor, children }: { previewColor: NonNullable<PackDefinition['previewColor']>; children: ReactNode }) {
200
+ const { dark } = useAutoPaperTheme()
201
+ const preview = useCallback((hex: string) => previewColor(hex, dark), [previewColor, dark])
202
+ return <ProfileColorProvider preview={preview}>{children}</ProfileColorProvider>
203
+ }
204
+
205
+ // The pack's own root pieces that sit inside the store, Theme and every provider above:
206
+ // its color preview around everything, and its own Provider around How to Play and the stack.
207
+ function PackRoot({ pack, children }: { pack: PackDefinition; children: ReactNode }) {
208
+ const { Provider, previewColor } = pack
209
+ const inner = Provider ? <Provider>{children}</Provider> : children
210
+ return previewColor ? <PreviewColors previewColor={previewColor}>{inner}</PreviewColors> : <>{inner}</>
131
211
  }
132
212
 
133
213
  interface RootLayoutProps {
@@ -149,26 +229,28 @@ export function RootLayout({ pack, store, persistor }: RootLayoutProps) {
149
229
  it mounts) is the saved one. */}
150
230
  <LastActiveGameProvider>
151
231
  <GameProviders games={pack.games}>
152
- <GatedApp>
153
- <OrientationBridge>
154
- <HapticGatedToastProvider>
155
- {/* Wraps the stack (rather than sitting beside it like UpdateDialog)
232
+ <PackRoot pack={pack}>
233
+ <GatedApp>
234
+ <OrientationBridge>
235
+ <HapticGatedToastProvider>
236
+ {/* Wraps the stack (rather than sitting beside it like UpdateDialog)
156
237
  because Home's tiles and the loadout's How to Play row reach it through
157
238
  context (see GuideHost). Inside the toast provider and OrientationBridge
158
239
  because the card renders through a Portal and rotates with the shared
159
240
  reading, and inside the gate so it mounts together with Home rather than
160
241
  before it. */}
161
- <GuideHost>
162
- <AppStack />
163
- </GuideHost>
164
- {/* historyButton/clearButton off: there's no toast history to open, and a
242
+ <GuideHost>
243
+ <AppStack />
244
+ </GuideHost>
245
+ {/* historyButton/clearButton off: there's no toast history to open, and a
165
246
  control row for it above the toast stack would go nowhere. limit pins the
166
247
  package's current default (3) so a future toaster release can't change it. */}
167
- <RotatingToaster />
168
- <UpdateDialog />
169
- </HapticGatedToastProvider>
170
- </OrientationBridge>
171
- </GatedApp>
248
+ {pack.Toaster ? <PackToaster Toaster={pack.Toaster} /> : <RotatingToaster />}
249
+ <UpdateDialog />
250
+ </HapticGatedToastProvider>
251
+ </OrientationBridge>
252
+ </GatedApp>
253
+ </PackRoot>
172
254
  </GameProviders>
173
255
  </LastActiveGameProvider>
174
256
  </ProfilesProvider>
@@ -9,57 +9,82 @@ import { roundModeFor, seatTwoFor } from '../types'
9
9
 
10
10
  interface GameGuideContextValue {
11
11
  // Shows a game's How to Play cards if this player hasn't finished or skipped this version yet,
12
- // then runs `next` once they close; runs `next` at once otherwise. Home calls it on a tile tap, so
13
- // a game's cards show the first time it's picked, before its loadout opens.
12
+ // then runs `next` once they close; runs `next` at once otherwise (and for a game with no cards of
13
+ // its own). Home calls it on a tile tap, so a game's cards show the first time it's picked, before
14
+ // its loadout opens.
14
15
  openFirstTime: (gameId: string, next: () => void) => void
15
- // Shows a game's cards now, seen or not (a tile's rules button, a loadout's How to Play row).
16
+ // Shows a game's cards now, seen or not (a tile's rules button, a loadout's How to Play row). Does
17
+ // nothing for a game with no cards of its own.
16
18
  openFor: (gameId: string) => void
19
+ // Shows the pack's own cards now (PackDefinition.guide: Home's Settings).
20
+ openPack: () => void
21
+ // Shows the pack's own cards once per session, while this player hasn't seen this version (Home's
22
+ // first launch).
23
+ autoShowPack: () => void
17
24
  }
18
25
 
26
+ // What the one GuideProvider is showing: the pack's own cards, or a game's (by id).
27
+ const PACK_GUIDE = '__pack__'
28
+
29
+ const NO_INPUT = {}
30
+
19
31
  const GameGuideContext = createContext<GameGuideContextValue | null>(null)
20
32
 
21
33
  interface Props {
22
34
  children: ReactNode
23
35
  }
24
36
 
25
- // One @tastic/hud GuideProvider for the whole pack, showing whichever game's cards were asked for
26
- // last. Each game's cards come from its own registry entry (GameDefinition.guide), built from the
27
- // live settings and seat 2 so a replay after any loadout change describes the next match. Mounted
28
- // around the stack in RootLayout, inside the toast provider and OrientationBridge because the card
29
- // renders through a Portal and turns with the shared reading (`rotation`, the same one Home hands
30
- // its Settings dialog), and inside the profiles gate so it mounts together with Home.
37
+ // One @tastic/hud GuideProvider for the whole pack, showing whichever cards were asked for last:
38
+ // the pack's own (PackDefinition.guide, cards that teach every game at once), or a game's. Each
39
+ // game's cards come from its own registry entry (GameDefinition.guide), built from the live settings
40
+ // and seat 2 so a replay after any loadout change describes the next match. Mounted around the stack
41
+ // in RootLayout, inside the toast provider and OrientationBridge because the card renders through a
42
+ // Portal and turns with the shared reading (`rotation`, the same one Home hands its Settings dialog),
43
+ // and inside the profiles gate so it mounts together with Home.
31
44
  export function GuideHost({ children }: Props) {
32
45
  const pack = usePack()
33
- const [gameId, setGameId] = useState(pack.games[0].id)
34
- const game = pack.games.find((g) => g.id === gameId) ?? pack.games[0]
46
+ const [subject, setSubject] = useState(() => (pack.guide ? PACK_GUIDE : (pack.games.find((g) => g.guide)?.id ?? null)))
47
+ const packGuide = subject === PACK_GUIDE ? pack.guide : undefined
48
+ const game = subject === PACK_GUIDE ? undefined : pack.games.find((g) => g.id === subject)
49
+ const gameGuide = game?.guide
35
50
 
36
51
  const rotation = useRotation()
37
52
  const touch = useIsTouchPrimaryDevice()
38
53
  const seatTwo = useSelector((state: ShellState) => state.opponent.seatTwo)
39
- const input = useSelector((state: ShellState) => game.guide.selectInput(state), shallowEqual)
40
- const seenVersion = useSelector((state: ShellState) => game.guide.selectVersionSeen(state))
54
+ const input = useSelector((state: ShellState) => (gameGuide ? gameGuide.selectInput(state) : NO_INPUT), shallowEqual)
55
+ const seenVersion = useSelector((state: ShellState) => (packGuide ?? gameGuide)?.selectVersionSeen(state) ?? 0)
41
56
  const dispatch = useDispatch()
42
57
 
43
58
  // The seat-2 choice the cards describe: the one the player last picked, the CPU in a game that has
44
59
  // no room for it (a saved 'nobody' in a game without a solo mode), or nobody in a game for one.
45
- const mode = roundModeFor(seatTwoFor(seatTwo, game.seatTwo))
46
- const steps = useMemo(() => game.guide.getSteps(input, { touch, mode }), [game, input, touch, mode])
47
- const onSeen = useCallback((version: number) => dispatch(game.guide.markSeen(version)), [dispatch, game])
60
+ const mode = roundModeFor(seatTwoFor(seatTwo, game?.seatTwo))
61
+ const steps = useMemo(() => (packGuide ? packGuide.getSteps({ touch }) : gameGuide ? gameGuide.getSteps(input, { touch, mode }) : []), [packGuide, gameGuide, input, touch, mode])
62
+ const onSeen = useCallback(
63
+ (version: number) => {
64
+ const guide = packGuide ?? gameGuide
65
+ if (guide) dispatch(guide.markSeen(version))
66
+ },
67
+ [dispatch, packGuide, gameGuide]
68
+ )
48
69
 
49
70
  return (
50
- <GuideProvider steps={steps} currentVersion={game.guide.version} seenVersion={seenVersion} onSeen={onSeen} rotation={rotation}>
51
- <GameGuideBridge onPick={setGameId}>{children}</GameGuideBridge>
71
+ <GuideProvider steps={steps} currentVersion={(packGuide ?? gameGuide)?.version ?? 0} seenVersion={seenVersion} onSeen={onSeen} rotation={rotation}>
72
+ <GameGuideBridge onPick={setSubject}>{children}</GameGuideBridge>
52
73
  </GuideProvider>
53
74
  )
54
75
  }
55
76
 
56
77
  // Inside GuideProvider, so it can open the dialog and watch it close.
57
- function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => void; children: ReactNode }) {
78
+ function GameGuideBridge({ onPick, children }: { onPick: (subject: string) => void; children: ReactNode }) {
58
79
  const pack = usePack()
59
80
  const store = useStore<ShellState>()
60
81
  const { isOpen, open } = useGuide()
61
82
  const pendingNext = useRef<(() => void) | null>(null)
62
83
  const wasOpen = useRef(false)
84
+ // The pack's cards show by themselves once per session at most: Home mounts again whenever the
85
+ // player comes back to it, and skipping shouldn't have to happen twice before the seen version is
86
+ // written back.
87
+ const packAutoShown = useRef(false)
63
88
 
64
89
  // When cards opened by openFirstTime close (finished, skipped or dismissed), carry on to where the
65
90
  // tap was going.
@@ -74,16 +99,17 @@ function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => voi
74
99
 
75
100
  const openFor = useCallback(
76
101
  (gameId: string) => {
102
+ if (!pack.games.find((g) => g.id === gameId)?.guide) return
77
103
  onPick(gameId)
78
104
  open()
79
105
  },
80
- [onPick, open]
106
+ [pack, onPick, open]
81
107
  )
82
108
 
83
109
  const openFirstTime = useCallback(
84
110
  (gameId: string, next: () => void) => {
85
- const game = pack.games.find((g) => g.id === gameId)
86
- if (!game || game.guide.selectVersionSeen(store.getState()) >= game.guide.version) {
111
+ const guide = pack.games.find((g) => g.id === gameId)?.guide
112
+ if (!guide || guide.selectVersionSeen(store.getState()) >= guide.version) {
87
113
  next()
88
114
  return
89
115
  }
@@ -93,7 +119,20 @@ function GameGuideBridge({ onPick, children }: { onPick: (gameId: string) => voi
93
119
  [pack, store, openFor]
94
120
  )
95
121
 
96
- const value = useMemo(() => ({ openFirstTime, openFor }), [openFirstTime, openFor])
122
+ const openPack = useCallback(() => {
123
+ if (!pack.guide) return
124
+ onPick(PACK_GUIDE)
125
+ open()
126
+ }, [pack, onPick, open])
127
+
128
+ const autoShowPack = useCallback(() => {
129
+ const guide = pack.guide
130
+ if (!guide || packAutoShown.current || guide.selectVersionSeen(store.getState()) >= guide.version) return
131
+ packAutoShown.current = true
132
+ openPack()
133
+ }, [pack, store, openPack])
134
+
135
+ const value = useMemo(() => ({ openFirstTime, openFor, openPack, autoShowPack }), [openFirstTime, openFor, openPack, autoShowPack])
97
136
  return <GameGuideContext.Provider value={value}>{children}</GameGuideContext.Provider>
98
137
  }
99
138
 
@@ -9,16 +9,29 @@ import { Provider as ReduxProvider, useDispatch, useSelector } from 'react-redux
9
9
  import type { Persistor } from 'redux-persist'
10
10
  import { PersistGate } from 'redux-persist/integration/react'
11
11
 
12
+ import { usePack } from '../PackContext'
12
13
  import type { PackStore } from '../redux/createPackStore'
13
14
  import type { ShellState } from '../redux/shellState'
15
+ import type { FeedbackSounds } from '../types'
14
16
 
15
17
  interface Props {
16
18
  children: ReactNode
17
19
  }
18
20
 
21
+ // Generic UI feedback sounds, wired into every Button/IconButton/etc. in the package (see
22
+ // useFeedbackHandlers' `fire`, which already gates these on the sound-enabled setting itself -
23
+ // no need to re-check it here).
24
+ function useShellFeedbackSounds(): FeedbackSounds {
25
+ const selection = useAudioPool(require('../../assets/sounds/select.wav'))
26
+ const notification = useAudioPool(require('../../assets/sounds/notification.wav'))
27
+ return { selection, notification }
28
+ }
29
+
19
30
  // Redux-backed haptic/sound settings, dispatching through @rific/feedback-press's own
20
31
  // hapticActions/soundActions - same shape as Expo-Starter/Snake's FeedbackBridge, converged onto
21
- // fleet-wide rather than this app's old direct-AsyncStorage version.
32
+ // fleet-wide rather than this app's old direct-AsyncStorage version. The two sounds are the pack's own
33
+ // where it has them (PackDefinition.useFeedbackSounds: Solitaire's card sound and invalid-move sound);
34
+ // the pack never changes while it runs, so the same hook is called on every render.
22
35
  const FeedbackBridge = ({ children }: Props) => {
23
36
  const haptic = useSelector((state: ShellState) => state.haptic)
24
37
  const sound = useSelector((state: ShellState) => state.sound)
@@ -26,13 +39,10 @@ const FeedbackBridge = ({ children }: Props) => {
26
39
  const onChange = useCallback((next: HapticSettings) => dispatch(hapticActions.initialize(next)), [dispatch])
27
40
  const onSoundChange = useCallback((next: SoundSettings) => dispatch(soundActions.initialize(next)), [dispatch])
28
41
 
29
- // Generic UI feedback sounds, wired into every Button/IconButton/etc. in the package (see
30
- // useFeedbackHandlers' `fire`, which already gates these on the sound-enabled setting itself -
31
- // no need to re-check it here).
32
- const playSelection = useAudioPool(require('../../assets/sounds/select.wav'))
33
- const playNotification = useAudioPool(require('../../assets/sounds/notification.wav'))
42
+ const { useFeedbackSounds = useShellFeedbackSounds } = usePack()
43
+ const sounds = useFeedbackSounds()
34
44
 
35
- const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: { selection: playSelection, notification: playNotification } })
45
+ const bridgeProps = useFeedbackBridgeProps({ initialValue: haptic, onChange, soundInitialValue: sound, onSoundChange, sound: sounds })
36
46
 
37
47
  return (
38
48
  <FeedbackPressProvider {...bridgeProps} paper={RNPaper}>
@@ -1,5 +1,6 @@
1
1
  import type { SeedColor } from '@rific/auto-paper'
2
- import { type MenuOption, type PopoverHost, ReadyButton, useZoneClampedAlign } from '@tastic/hud'
2
+ import type { PopoverHost } from '@tastic/hud/chrome'
3
+ import { type MenuOption, ReadyButton, useZoneClampedAlign } from '@tastic/hud/lobby'
3
4
  import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
4
5
  import { ReactNode, useMemo } from 'react'
5
6
  import { StyleSheet, View } from 'react-native'
@@ -1,6 +1,6 @@
1
1
  import { useUpdateErrorToast } from '@rific/toaster'
2
2
  import { ViewRotation } from '@tastic/core'
3
- import { BaseSettingsDialog } from '@tastic/hud'
3
+ import { BaseSettingsDialog } from '@tastic/hud/chrome'
4
4
  import type { ReactNode } from 'react'
5
5
 
6
6
  import { useAppSettings } from '../hooks/useAppSettings'
@@ -26,15 +26,17 @@ export interface SettingsDialogProps {
26
26
  // The pack-wide Settings dialog: everything the shell puts in it applies to every game (Lock
27
27
  // Orientation, Edge Guard, theme, sound, haptics, Keep Awake), so it reads and writes the app
28
28
  // settings itself. Under those, a play screen's own rows and then the pack's own sections
29
- // (PackDefinition.SettingsSections), in every Settings dialog the pack opens.
29
+ // (PackDefinition.SettingsSections), in every Settings dialog the pack opens. A pack with edgeGuard
30
+ // false gets no Edge Guard row: hud draws it on iOS whenever its props are passed.
30
31
  export function SettingsDialog({ visible, onDismiss, rotation = 0, onShowHowToPlay, children }: SettingsDialogProps) {
31
32
  const onUpdateError = useUpdateErrorToast()
32
33
  const { settings, setSettings } = useAppSettings()
33
34
  // The pack's otaVersion (src/constants/release.ts, which the OTA tooling bumps).
34
- const { otaVersion, SettingsSections } = usePack()
35
+ const { otaVersion, SettingsSections, edgeGuard = true } = usePack()
36
+ const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value: boolean) => setSettings({ deferBottomEdgeGestures: value }) } : {}
35
37
 
36
38
  return (
37
- <BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} deferBottomEdgeGestures={settings.deferBottomEdgeGestures} onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
39
+ <BaseSettingsDialog visible={visible} onDismiss={onDismiss} rotation={rotation} version={otaVersion} lockOrientation={settings.lockOrientation} onLockOrientationChange={(value) => setSettings({ lockOrientation: value })} {...edgeGuardProps} onUpdateError={onUpdateError} onShowHowToPlay={onShowHowToPlay}>
38
40
  {children}
39
41
  {SettingsSections && <SettingsSections />}
40
42
  </BaseSettingsDialog>
@@ -1,6 +1,6 @@
1
1
  import { useUpdateErrorToast } from '@rific/toaster'
2
2
  import { useRotation } from '@tastic/core'
3
- import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud'
3
+ import { UpdateDialog as SharedUpdateDialog } from '@tastic/hud/chrome'
4
4
 
5
5
  // Thin bridge onto the fleet-shared @tastic/hud UpdateDialog, which now owns the useUpdater()
6
6
  // instance, the manifest/confirm bridging, and the themed ConfirmDialog rendering this file used