@tastic/shell 0.0.0-stage → 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -2
  3. package/assets/sounds/notification.wav +0 -0
  4. package/assets/sounds/select.wav +0 -0
  5. package/dist/index.d.mts +487 -0
  6. package/dist/index.d.ts +487 -0
  7. package/dist/index.js +1636 -0
  8. package/dist/index.mjs +1562 -0
  9. package/dist/notification-CGGFPYK7.wav +0 -0
  10. package/dist/select-MLBNNA4N.wav +0 -0
  11. package/package.json +163 -4
  12. package/src/PackContext.tsx +22 -0
  13. package/src/RootLayout.tsx +186 -0
  14. package/src/components/Fonts.tsx +41 -0
  15. package/src/components/GuideHost.tsx +104 -0
  16. package/src/components/Providers.tsx +86 -0
  17. package/src/components/SeatTwoPanel.tsx +132 -0
  18. package/src/components/SettingsDialog.tsx +42 -0
  19. package/src/components/Theme.tsx +75 -0
  20. package/src/components/UpdateDialog.tsx +13 -0
  21. package/src/components/imageGameIcon.tsx +13 -0
  22. package/src/gameStats.tsx +90 -0
  23. package/src/hooks/useActiveRound.ts +16 -0
  24. package/src/hooks/useAppSettings.ts +19 -0
  25. package/src/hooks/useFrozenWhileHidden.ts +33 -0
  26. package/src/hooks/useLastActiveGame.tsx +73 -0
  27. package/src/hooks/useOpponent.ts +14 -0
  28. package/src/hooks/useProfiles.tsx +134 -0
  29. package/src/hooks/useSeatColors.ts +148 -0
  30. package/src/index.ts +39 -0
  31. package/src/navigation.ts +11 -0
  32. package/src/playerSettings.ts +112 -0
  33. package/src/redux/appSettingsSlice.ts +34 -0
  34. package/src/redux/createPackStore.ts +78 -0
  35. package/src/redux/gameGuideSlice.ts +39 -0
  36. package/src/redux/lastActiveGameSlice.ts +12 -0
  37. package/src/redux/liveplaySlice.ts +34 -0
  38. package/src/redux/onProfileRemoved.ts +25 -0
  39. package/src/redux/opponentSlice.ts +21 -0
  40. package/src/redux/profileSelectionSlice.ts +17 -0
  41. package/src/redux/seatColorsSlice.ts +24 -0
  42. package/src/redux/shellState.ts +59 -0
  43. package/src/screens/GameRoute.tsx +48 -0
  44. package/src/screens/HomeScreen.tsx +378 -0
  45. package/src/screens/LoadoutScreen.tsx +271 -0
  46. package/src/screens/ProfilesRoute.tsx +14 -0
  47. package/src/screens/StatsScreen.tsx +133 -0
  48. package/src/screens/homeLayout.ts +129 -0
  49. package/src/splashGate.ts +3 -0
  50. package/src/types.ts +216 -0
@@ -0,0 +1,25 @@
1
+ import { removeProfileUnlocks, type UnlockedAchievementsState } from '@tastic/achievements'
2
+ import { profilesActions } from '@tastic/profile'
3
+ import type { Reducer, UnknownAction } from 'redux'
4
+
5
+ // Wraps one of a game's reducers so it forgets a profile the moment the Profiles screen deletes it
6
+ // (the shell's deleteProfile dispatches @tastic/profile's profilesActions.remove), with no need for
7
+ // the shell to know which games keep what per profile. A profile a sibling app deleted (a roster
8
+ // sync) is deliberately not cleaned up this way: a failed shared-store read reports an empty roster,
9
+ // which would erase real players' data for good.
10
+ export function onProfileRemoved<S>(reducer: Reducer<S>, forget: (state: S, profileId: string) => S): Reducer<S> {
11
+ return (state: S | undefined, action: UnknownAction) => {
12
+ const next = reducer(state, action)
13
+ return profilesActions.remove.match(action) ? forget(next, action.payload) : next
14
+ }
15
+ }
16
+
17
+ // For a createAchievementsSlice state: drops the profile's unlocks, and its stats through the game's
18
+ // own `dropStats` (where a game keeps a per-profile bucket).
19
+ export function forgetProfileAchievements<T, S extends { stats: T; unlocked: UnlockedAchievementsState }>(dropStats: (stats: T, profileId: string) => T) {
20
+ return (state: S, profileId: string): S => {
21
+ const stats = dropStats(state.stats, profileId)
22
+ const unlocked = removeProfileUnlocks(state.unlocked, profileId)
23
+ return stats === state.stats && unlocked === state.unlocked ? state : { ...state, stats, unlocked }
24
+ }
25
+ }
@@ -0,0 +1,21 @@
1
+ import { createSettingsSlice } from '@rific/core'
2
+
3
+ import type { SeatTwo } from '../types'
4
+
5
+ // Who sits in seat 2, for the whole pack: picked from seat 2's own button in any game's loadout and
6
+ // kept from one game to the next and across relaunches, since it's usually the same two people.
7
+ // A fresh install starts on the CPU, so one person (App Review included) can tap a game and play.
8
+ // A game without 'nobody' among its seat-2 choices treats a saved 'nobody' as the CPU (see
9
+ // types.ts' seatTwoFor).
10
+ export interface OpponentState {
11
+ seatTwo: SeatTwo
12
+ }
13
+
14
+ export const DEFAULT_OPPONENT: OpponentState = { seatTwo: 'cpu' }
15
+
16
+ const { actions, reducer } = createSettingsSlice<OpponentState>('opponent', {
17
+ initialState: DEFAULT_OPPONENT
18
+ })
19
+
20
+ export const opponentActions = actions
21
+ export default reducer
@@ -0,0 +1,17 @@
1
+ import { createProfileSelectionSlice } from '@tastic/profile'
2
+
3
+ import type { Seat } from '../types'
4
+
5
+ // Which profile each seat last picked, for the whole pack: seat 1 is Home's who's-playing button
6
+ // and seat 1 in every loadout, seat 2 is seat 2 in every loadout. Always local, never part of the
7
+ // shared App Group roster (@tastic/profile's own profiles slice, see createPackStore.ts): who's
8
+ // selected on THIS device is a per-app, per-device thing a sibling @tastic app shouldn't see.
9
+ export type ProfileSelectionState = Record<Seat, string | null>
10
+
11
+ export const defaultProfileSelectionState: ProfileSelectionState = { 1: null, 2: null }
12
+
13
+ // 'pack' only prefixes the action types; the state is stored under profileSelection.
14
+ const { actions, reducer } = createProfileSelectionSlice<Seat>('pack', defaultProfileSelectionState)
15
+
16
+ export const profileSelectionActions = actions
17
+ export default reducer
@@ -0,0 +1,24 @@
1
+ import { createSeatColorsSlice, type SeatColorsState as GenericSeatColorsState } from '@tastic/profile'
2
+
3
+ import type { Seat } from '../types'
4
+
5
+ // The loadout's per-seat color memory: a thin binding over @tastic/profile's createSeatColorsSlice
6
+ // (see its doc for each field: a guest seat's last color, the CPU seat's last color, and each
7
+ // profile's own color pick in this pack, kept by profile id). One memory for every game in the
8
+ // pack, so a player's color follows them from one game to the next.
9
+ export type SeatColorsState = GenericSeatColorsState<Seat>
10
+
11
+ // What a fresh install remembers: each guest seat on its own default color (the pack's seat colors,
12
+ // PackDefinition.seatColors) and the CPU on seat 2's.
13
+ export function defaultSeatColorsState(seatColors: Record<Seat, string>): SeatColorsState {
14
+ return { lastGuestColor: { 1: seatColors[1], 2: seatColors[2] }, lastCpuColor: seatColors[2], profileColors: {} }
15
+ }
16
+
17
+ // 'pack' only prefixes the action types; the state is stored under seatColors.
18
+ export function createSeatColorsReducer(seatColors: Record<Seat, string>) {
19
+ return createSeatColorsSlice<Seat>('pack', defaultSeatColorsState(seatColors)).reducer
20
+ }
21
+
22
+ // The slice's actions match by type alone, so these answer any pack's reducer above. The defaults
23
+ // they're built over are never read.
24
+ export const seatColorsActions = createSeatColorsSlice<Seat>('pack', defaultSeatColorsState({ 1: '', 2: '' })).actions
@@ -0,0 +1,59 @@
1
+ import { createThemeReducer, getThirdColor } from '@rific/auto-paper'
2
+ import { keepAwakeReducer } from '@rific/core'
3
+ import { defaultSoundSettings, hapticReducer, soundReducer, type SoundSettings } from '@rific/feedback-press'
4
+ import { profilesReducer } from '@tastic/profile'
5
+
6
+ import type { PackDefinition } from '../types'
7
+ import appSettings from './appSettingsSlice'
8
+ import type { LastActiveGameState } from './lastActiveGameSlice'
9
+ import liveplay from './liveplaySlice'
10
+ import opponent from './opponentSlice'
11
+ import profileSelection from './profileSelectionSlice'
12
+ import { createSeatColorsReducer } from './seatColorsSlice'
13
+
14
+ // @rific/feedback-press's own soundReducer defaults `enabled` to true unconditionally (that
15
+ // default isn't published with the dev-only override yet). Wrap it so a fresh install with no
16
+ // persisted preference (redux-persist finds nothing in AsyncStorage for the `sound` key) defaults
17
+ // muted in dev/simulator builds so Claude/local testing doesn't blast audio; production builds
18
+ // still default to sound on. All actual action handling still delegates to the package's reducer.
19
+ const initialSoundSettings: SoundSettings = { ...defaultSoundSettings, enabled: !__DEV__ }
20
+ const appSoundReducer = (state: SoundSettings = initialSoundSettings, action: { type: string }): SoundSettings => soundReducer(state, action)
21
+
22
+ // The store keys every pack has, whatever its games, built from the pack's own seat colors and
23
+ // appearance. Each game adds its own keys beside these (see GameDefinition.reducers).
24
+ export function createShellReducers({ seatColors, appearance = 'system' }: Pick<PackDefinition, 'seatColors' | 'appearance'>) {
25
+ return {
26
+ // Explicit primary/secondary triad (rather than a single seed expanded via harmony) so the pack's
27
+ // identity is pinned exactly instead of drifting with whatever hue math a harmony offset would
28
+ // produce. Primary/secondary ARE player 1/player 2's own default colors (PackDefinition.seatColors),
29
+ // so the theme and the default guest colors (seatColorsSlice.ts) can never drift apart. Tertiary is
30
+ // derived, not picked, via getThirdColor: the hue maximally distant from both (see
31
+ // @rific/auto-paper's README "Explicit triad" section). The appearance is the pack's ('system',
32
+ // createThemeReducer's own default, when it sets none).
33
+ theme: createThemeReducer({ appearance, color: { primary: seatColors[1], secondary: seatColors[2], tertiary: getThirdColor(seatColors[1], seatColors[2]) } }),
34
+ haptic: hapticReducer,
35
+ sound: appSoundReducer,
36
+ // @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its
37
+ // REHYDRATE handling expects.
38
+ keepAwake: keepAwakeReducer,
39
+ // The shared App Group roster (see @tastic/profile's resolveInitialProfiles/
40
+ // useSharedProfilesSync, wired in hooks/useProfiles.tsx) is layered on top of this as a separate
41
+ // sync target - this slice persisting normally via redux-persist is what gives it local-fallback
42
+ // behavior where the shared store isn't available, with no separate AsyncStorage path.
43
+ profiles: profilesReducer,
44
+ profileSelection,
45
+ // The loadout's "remember this color for next time" memory (guest, CPU, and each profile's pick
46
+ // in this pack) - separate from `theme` above, which always reflects whatever's live right now.
47
+ seatColors: createSeatColorsReducer(seatColors),
48
+ appSettings,
49
+ opponent,
50
+ // Transient per-round state, the one key blacklisted from persistence (see liveplaySlice.ts).
51
+ liveplay
52
+ }
53
+ }
54
+
55
+ type ShellReducers = ReturnType<typeof createShellReducers>
56
+
57
+ // Plus the one key only a pack with a resumable game mounts (see createPackStore.ts), so a pack
58
+ // without one saves exactly what it did before resume existed.
59
+ export type ShellState = { [K in keyof ShellReducers]: ReturnType<ShellReducers[K]> } & { lastActiveGame?: LastActiveGameState }
@@ -0,0 +1,48 @@
1
+ import { Redirect, useLocalSearchParams } from 'expo-router'
2
+ import { useEffect } from 'react'
3
+
4
+ import { useLastActiveGame } from '../hooks/useLastActiveGame'
5
+ import { usePackGame } from '../PackContext'
6
+ import type { GameDefinition } from '../types'
7
+ import { type LoadoutGame, LoadoutScreen } from './LoadoutScreen'
8
+
9
+ // The game routes, loadout/[game] and play/[game]: a game's loadout and its play screen, by id. An id
10
+ // the pack doesn't have (a stale link, a web URL typed by hand) goes Home. The game id comes second,
11
+ // never first: expo-router strips the web build's base path off the front of an in-app link as well
12
+ // as the address bar, so a route starting with a game id the same as the base path (boxhockey/loadout
13
+ // under /boxhockey, lightcycles/loadout under /lightcycles) would lose that segment and land on
14
+ // /loadout, "Unmatched Route".
15
+ export function LoadoutRoute() {
16
+ const { game: id } = useLocalSearchParams<{ game: string }>()
17
+ const game = usePackGame(id)
18
+ if (!game) return <Redirect href='/' />
19
+ // A game with nothing to set up has no loadout (GameDefinition.useLoadout): a link to one plays it.
20
+ if (!hasLoadout(game)) return <Redirect href={{ pathname: '/play/[game]', params: { game: game.id } }} />
21
+ return <LoadoutScreen key={game.id} game={game} />
22
+ }
23
+
24
+ function hasLoadout(game: GameDefinition): game is LoadoutGame {
25
+ return !!game.useLoadout
26
+ }
27
+
28
+ export function PlayRoute() {
29
+ const { game: id } = useLocalSearchParams<{ game: string }>()
30
+ const game = usePackGame(id)
31
+ useResumePoint(game?.resumable ? game.id : null)
32
+ if (!game) return <Redirect href='/' />
33
+ const { PlayScreen } = game
34
+ return <PlayScreen key={game.id} />
35
+ }
36
+
37
+ // A resumable game's play screen (GameDefinition.resumable) is where a cold launch comes back to:
38
+ // remembered as it mounts, and forgotten when it unmounts, which is the player backing out of it
39
+ // (its own back, hardware back or a browser back). Backgrounding the app leaves the screen mounted,
40
+ // so it stays remembered. Runs once per play screen: the context's functions never change.
41
+ function useResumePoint(gameId: string | null) {
42
+ const { setLastActiveGame, clearLastActiveGame } = useLastActiveGame()
43
+ useEffect(() => {
44
+ if (!gameId) return
45
+ setLastActiveGame(gameId)
46
+ return clearLastActiveGame
47
+ }, [gameId, setLastActiveGame, clearLastActiveGame])
48
+ }
@@ -0,0 +1,378 @@
1
+ import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { IconButton, Pressable } from '@rific/feedback-press'
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'
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'
10
+
11
+ import { useGameGuide } from '../components/GuideHost'
12
+ import { SettingsDialog } from '../components/SettingsDialog'
13
+ import { useFrozenWhileHidden } from '../hooks/useFrozenWhileHidden'
14
+ import { useLastActiveGame } from '../hooks/useLastActiveGame'
15
+ import { useOpponent } from '../hooks/useOpponent'
16
+ import { useProfiles } from '../hooks/useProfiles'
17
+ import { useSeatColors } from '../hooks/useSeatColors'
18
+ import { usePack } from '../PackContext'
19
+ import type { GameDefinition } from '../types'
20
+ import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_BAND, HOME_WIDE_PANE_GAP, PREVIEW_ASPECT, ROW_PREVIEW_WIDTH, useHomeLayout } from './homeLayout'
21
+
22
+ // Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
23
+ // pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
24
+ // (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
25
+ // loadout, or its play screen in a game without one, after its How to Play cards the first time it's
26
+ // picked. Seat 2 is chosen in the loadout, from seat 2's own button.
27
+ //
28
+ // 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
+ export function HomeScreen() {
31
+ const pack = usePack()
32
+ const { consumeResumeGame } = useLastActiveGame()
33
+ // A one-shot cold-launch decision, read exactly once at this instance's mount (see
34
+ // hooks/useLastActiveGame.tsx), never a live value this screen re-renders from. Reading it live made
35
+ // every Home in the stack swap its whole menu for a Redirect while a game was on top and rebuild it
36
+ // the moment Home was shown again (in Solitaire, ~370 preview cards: going Home took seconds). An id
37
+ // the pack no longer has, or of a game that no longer resumes, is dropped.
38
+ const [resumeGame] = useState(() => {
39
+ const id = consumeResumeGame()
40
+ return pack.games.find((g) => g.id === id && g.resumable) ?? null
41
+ })
42
+ if (resumeGame) return <Redirect href={{ pathname: '/play/[game]', params: { game: resumeGame.id } }} />
43
+ return <HomeMenu />
44
+ }
45
+
46
+ function HomeMenu() {
47
+ const pack = usePack()
48
+ const rotation = useRotation()
49
+ const layout = useHomeLayout()
50
+ const { insets } = layout
51
+ const { dark } = useAutoPaperTheme()
52
+ const { openFirstTime, openFor } = useGameGuide()
53
+ const [settingsOpen, setSettingsOpen] = useState(false)
54
+ const isFocused = useIsFocused()
55
+ const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
56
+ useEffect(() => {
57
+ const subscription = AppState.addEventListener('change', setAppState)
58
+ return () => subscription.remove()
59
+ }, [])
60
+ // The hero pauses (HeroTitleProps.active) while a pushed screen covers Home or the app is in the
61
+ // background, so it never animates where nobody can see it.
62
+ const heroActive = isFocused && appState === 'active'
63
+
64
+ const { profiles, lastSelected, selectProfile } = useProfiles()
65
+ const { seatTwo } = useOpponent()
66
+ const { p1Color, p2Color, handleP1ColorChange } = useSeatColors(seatTwo === 'person')
67
+ const pickerHost = usePopoverHost()
68
+ const selectedProfile = profiles.find((p) => p.id === lastSelected[1]) ?? null
69
+
70
+ // Tiles: while any game in the pack has a preview, a wide screen lays the games out as a grid beside
71
+ // the hero (homeLayout.ts). Home stays mounted beneath every screen the stack pushes and keeps
72
+ // following the rotation, so while it's covered the layout is held where it was (see
73
+ // useFrozenWhileHidden); it catches up when Home is shown again. Only the choice of layout is held:
74
+ // the panes' numbers follow the window, unseen under the screen covering Home.
75
+ const tiled = pack.games.some((game) => game.Preview)
76
+ const isWide = useFrozenWhileHidden(tiled && layout.isWide, !isFocused)
77
+ // Every preview is held back a frame rather than drawn in Home's first commit: a game's preview is
78
+ // far more to draw than an icon, and drawing them all in the commit a navigation lands on stalled
79
+ // the transition itself on a device in Solitaire, reading as "pressing Home hangs". For one frame
80
+ // each preview's box is empty. Home stays mounted under the screens it opens, so this happens once.
81
+ const [previewsRevealed, setPreviewsRevealed] = useState(false)
82
+ useEffect(() => {
83
+ if (!tiled) return
84
+ const id = requestAnimationFrame(() => setPreviewsRevealed(true))
85
+ return () => cancelAnimationFrame(id)
86
+ }, [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.
90
+ const closePicker = pickerHost.close
91
+ useEffect(() => {
92
+ closePicker()
93
+ }, [isWide, closePicker])
94
+
95
+ const bg = dark ? '#000000' : '#FFFFFF'
96
+ const fg = dark ? '#FFFFFF' : '#000000'
97
+
98
+ // 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])
100
+
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 }} />
102
+
103
+ return (
104
+ <View style={[styles.container, { backgroundColor: bg }]}>
105
+ {/* 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`. */}
107
+ <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
112
+ 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
122
+ buttons' band. A later sibling of the grid, lifted while seat 1's menu is open so the menu
123
+ 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} />
132
+
133
+ {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
134
+ tiles while its menu is open, so the menu paints over them. */}
135
+ <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
136
+
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
+ )}
144
+
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' />
147
+ </FakeLandscapeView>
148
+
149
+ <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} />
150
+ </View>
151
+ )
152
+ }
153
+
154
+ interface GameRowProps {
155
+ game: GameDefinition
156
+ dark: boolean
157
+ p1Color: string
158
+ p2Color: string
159
+ // Whether Home has painted once, so a preview may draw (see previewsRevealed).
160
+ previewRevealed: boolean
161
+ onPress: () => void
162
+ onRules: () => void
163
+ }
164
+
165
+ // One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
166
+ // preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
167
+ // How to Play cards without opening the game.
168
+ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
169
+ const well = useWellColors()
170
+ const GameIcon = game.icon
171
+ return (
172
+ // The rules button sits beside the row's own button, laid over its end, not inside it: a button
173
+ // inside a button is invalid on the web.
174
+ <View>
175
+ <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
+ )}
183
+ <View style={styles.rowText}>
184
+ <Text variant='titleLarge' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
185
+ {game.title}
186
+ </Text>
187
+ <Text variant='bodyMedium' style={{ color: well.fgMuted }} numberOfLines={2}>
188
+ {game.blurb}
189
+ </Text>
190
+ </View>
191
+ </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>
195
+ </View>
196
+ )
197
+ }
198
+
199
+ interface GameTileProps extends GameRowProps {
200
+ width: number
201
+ previewWidth: number
202
+ }
203
+
204
+ // One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
205
+ // name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
206
+ // rules button floats over the preview's corner, beside the tile's button rather than inside it.
207
+ function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
208
+ const well = useWellColors()
209
+ return (
210
+ <View style={{ width }}>
211
+ <Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
212
+ <PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
213
+ <Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
214
+ {game.title}
215
+ </Text>
216
+ <Text variant='bodySmall' style={{ color: well.fgMuted }} numberOfLines={3}>
217
+ {game.blurb}
218
+ </Text>
219
+ </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>
223
+ </View>
224
+ )
225
+ }
226
+
227
+ interface PreviewBoxProps {
228
+ game: GameDefinition
229
+ width: number
230
+ revealed: boolean
231
+ dark: boolean
232
+ p1Color: string
233
+ p2Color: string
234
+ }
235
+
236
+ // A box of the preview's shape (PREVIEW_ASPECT) holding the game's preview, once Home has painted, or
237
+ // its icon centred where it has none (a tile in a pack where only some games have one).
238
+ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }: PreviewBoxProps) {
239
+ const height = Math.round(width / PREVIEW_ASPECT)
240
+ const { Preview, icon: GameIcon } = game
241
+ const iconSize = Math.min(height, ROW_ICON_SIZE)
242
+ return (
243
+ <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
+ )}
251
+ </View>
252
+ )
253
+ }
254
+
255
+ // The icon's size on a row: an app icon's, with its rounding.
256
+ const ROW_ICON_SIZE = 56
257
+ // The app icon's backgrounds (assets/images/icon-light.png and icon-dark.png), behind every game's art.
258
+ const ICON_LIGHT_BG = '#FAFAFA'
259
+ const ICON_DARK_BG = '#050505'
260
+ // Room at a row's end for the rules button laid over it.
261
+ const RULES_BUTTON_SPACE = 44
262
+
263
+ const styles = StyleSheet.create({
264
+ container: {
265
+ flex: 1
266
+ },
267
+ content: {
268
+ alignItems: 'center',
269
+ gap: 24
270
+ },
271
+ corner: {
272
+ position: 'absolute'
273
+ },
274
+ // The wide layout's tiles: wrapped rows of tiles the layout sizes to fill the pane's width.
275
+ grid: {
276
+ flexDirection: 'row',
277
+ flexWrap: 'wrap',
278
+ gap: HOME_GRID_GAP
279
+ },
280
+ // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
281
+ list: {
282
+ gap: 14,
283
+ maxWidth: 480,
284
+ width: '100%'
285
+ },
286
+ picker: {
287
+ alignItems: 'center'
288
+ },
289
+ pickerOpen: {
290
+ zIndex: 100
291
+ },
292
+ // The preview's own rounding, clipping whatever it draws to the box.
293
+ preview: {
294
+ alignItems: 'center',
295
+ borderRadius: 8,
296
+ justifyContent: 'center',
297
+ overflow: 'hidden'
298
+ },
299
+ previewIcon: {
300
+ overflow: 'hidden'
301
+ },
302
+ rotatable: {
303
+ flex: 1
304
+ },
305
+ // A well (@tastic/hud's wellSurface): the theme's surface on MD3's level-1 shadow, the same surface
306
+ // as Solitaire's game rows, the stats screen's sections and every dialog.
307
+ row: {
308
+ alignItems: 'center',
309
+ borderRadius: 12,
310
+ boxShadow: WELL_SHADOW,
311
+ flexDirection: 'row',
312
+ gap: 14,
313
+ padding: 14,
314
+ paddingRight: RULES_BUTTON_SPACE
315
+ },
316
+ rowIcon: {
317
+ borderRadius: ROW_ICON_SIZE * 0.22,
318
+ height: ROW_ICON_SIZE,
319
+ overflow: 'hidden',
320
+ width: ROW_ICON_SIZE
321
+ },
322
+ rowText: {
323
+ flex: 1,
324
+ gap: 2
325
+ },
326
+ rowTitle: {
327
+ fontWeight: 'bold'
328
+ },
329
+ rulesButton: {
330
+ bottom: 0,
331
+ justifyContent: 'center',
332
+ position: 'absolute',
333
+ right: 14,
334
+ top: 0
335
+ },
336
+ scroll: {
337
+ flex: 1
338
+ },
339
+ // The same well as a row, turned on its side.
340
+ tile: {
341
+ borderRadius: 12,
342
+ boxShadow: WELL_SHADOW,
343
+ gap: 6,
344
+ padding: HOME_TILE_PADDING
345
+ },
346
+ // A small translucent disc over the preview's top-right corner, so the white icon reads on whatever
347
+ // the preview draws. hitSlop (on the Pressable) grows its touch area.
348
+ tileRulesButton: {
349
+ alignItems: 'center',
350
+ backgroundColor: 'rgba(0, 0, 0, 0.35)',
351
+ borderRadius: 14,
352
+ height: 28,
353
+ justifyContent: 'center',
354
+ position: 'absolute',
355
+ right: HOME_TILE_PADDING + 4,
356
+ top: HOME_TILE_PADDING + 4,
357
+ width: 28
358
+ },
359
+ // The two panes are absolutely positioned siblings of the press-away overlay, each placed by the
360
+ // offsets homeLayout.ts computes.
361
+ wideLeftPane: {
362
+ alignItems: 'center',
363
+ bottom: 0,
364
+ justifyContent: 'center',
365
+ pointerEvents: 'box-none',
366
+ position: 'absolute',
367
+ top: 0
368
+ },
369
+ widePicker: {
370
+ marginTop: HOME_LEFT_PANE_PICKER_GAP
371
+ },
372
+ wideRightPane: {
373
+ bottom: 0,
374
+ position: 'absolute',
375
+ right: 0,
376
+ top: 0
377
+ }
378
+ })