@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,5 +1,5 @@
1
- import { getThirdColor, useAutoPaperTheme, useThemeSettings } from '@rific/auto-paper'
2
- import { avoidSeatColorClash, profileSeatColor } from '@tastic/profile'
1
+ import { getThirdColor, type ThemeSettings, useAutoPaperTheme, useThemeSettings } from '@rific/auto-paper'
2
+ import { avoidSeatColorClash, type Profile, profileSeatColor } from '@tastic/profile'
3
3
  import { useFocusEffect } from 'expo-router'
4
4
  import { useCallback, useEffect, useRef } from 'react'
5
5
  import { useDispatch, useSelector } from 'react-redux'
@@ -11,19 +11,34 @@ import type { ShellState } from '../redux/shellState'
11
11
  import type { Seat } from '../types'
12
12
  import { useProfiles } from './useProfiles'
13
13
 
14
- // Both seats' live colors (the theme's primary and secondary) and the handlers that change them,
15
- // for Home's who's-playing button and the loadout's seat buttons. `p2IsHuman` says whether seat 2
16
- // is a person (a guest or a profile) or the CPU, which decides where seat 2's color comes from.
14
+ type ThemeColors = Pick<ThemeSettings, 'color' | 'harmony'>
15
+
16
+ // Whether a theme already has the colors (and harmony) a seat change would set: skipped, so a re-run
17
+ // with nothing new writes nothing.
18
+ function sameTheme(current: ThemeColors, next: Partial<ThemeColors>): boolean {
19
+ const a = current.color
20
+ const b = next.color
21
+ const sameColor = typeof a === 'string' || typeof b === 'string' ? typeof a === typeof b && String(a).toLowerCase() === String(b).toLowerCase() : !!a && !!b && a.primary.toLowerCase() === b.primary.toLowerCase() && a.secondary.toLowerCase() === b.secondary.toLowerCase()
22
+ return sameColor && (next.harmony === undefined || next.harmony === current.harmony)
23
+ }
24
+
25
+ // Both seats' live colors and the handlers that change them, for Home's who's-playing button and the
26
+ // loadout's seat buttons. `p2IsHuman` says whether seat 2 is a person (a guest or a profile) or the
27
+ // CPU, which decides where seat 2's color comes from.
28
+ //
29
+ // The theme follows the seats. By default it's a triad of the two seat colors, and each seat's live
30
+ // color is read back from it (its primary and secondary). A pack with its own seatTheme (a muted seed
31
+ // from seat 1's color, say) gets that instead, applied again when dark mode flips, and each seat's
32
+ // color comes from where it's remembered (the seatColors key), since the theme no longer holds the
33
+ // raw colors: seat 2 keeps its own color rather than the harmony's.
17
34
  export function useSeatColors(p2IsHuman: boolean) {
18
- const { colors: themeColors } = useAutoPaperTheme()
19
- const { set: setThemeColor } = useThemeSettings()
20
- const p1Color = themeColors.primary
21
- const p2Color = themeColors.secondary
35
+ const { dark, colors: themeColors } = useAutoPaperTheme()
36
+ const { settings: themeSettings, set: setThemeColor } = useThemeSettings()
22
37
  const { profiles, lastSelected } = useProfiles()
23
38
  // What @tastic/profile's avoidSeatColorClash moves seat 2 to when both seats land on one color: a
24
39
  // profile picked for one seat can be saved as the other seat's color, with no swap step behind it.
25
40
  // The pack's own seat colors, which never change while it runs.
26
- const { seatColors } = usePack()
41
+ const { seatColors, seatTheme } = usePack()
27
42
  const seatDefaultColors = [seatColors[1], seatColors[2]] as const
28
43
 
29
44
  // Typed explicitly because @tastic/profile's action creators return a plain `{ payload, type }`
@@ -44,6 +59,20 @@ export function useSeatColors(p2IsHuman: boolean) {
44
59
  const p1Profile = profiles.find((p) => p.id === lastSelected[1]) ?? null
45
60
  const p2Profile = p2IsHuman ? (profiles.find((p) => p.id === lastSelected[2]) ?? null) : null
46
61
 
62
+ // Where each seat's color is remembered: a profile's pick in this pack (or its own color), the CPU's,
63
+ // or a guest's. Seat 2 steps aside when both seats land on one color.
64
+ const rememberedColors = (p1Prof: Profile | null, p2Prof: Profile | null, picks: typeof profileColors, guest: typeof guestColors, cpu: string) => {
65
+ const p1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1]
66
+ const p2 = avoidSeatColorClash(p1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, seatDefaultColors)
67
+ return { p1, p2 }
68
+ }
69
+ const remembered = rememberedColors(p1Profile, p2Profile, profileColors, guestColors, cpuColor)
70
+ const p1Color = seatTheme ? remembered.p1 : themeColors.primary
71
+ const p2Color = seatTheme ? remembered.p2 : themeColors.secondary
72
+
73
+ // The theme two seat colors make: the pack's own, or the triad.
74
+ const themeFor = useCallback((p1: string, p2: string): Partial<ThemeColors> => (seatTheme ? seatTheme({ 1: p1, 2: p2 }, dark) : { color: { primary: p1, secondary: p2, tertiary: getThirdColor(p1, p2) } }), [seatTheme, dark])
75
+
47
76
  // Mirrored for the focus effect below, so it can read each seat's current profile and remembered
48
77
  // colors without depending on them (profiles is a new array on every useProfiles() render, and
49
78
  // useFocusEffect re-runs whenever its callback's identity changes, even while still focused).
@@ -52,6 +81,7 @@ export function useSeatColors(p2IsHuman: boolean) {
52
81
  const guestColorsRef = useRef(guestColors)
53
82
  const cpuColorRef = useRef(cpuColor)
54
83
  const profileColorsRef = useRef(profileColors)
84
+ const themeForRef = useRef(themeFor)
55
85
  useEffect(() => {
56
86
  p1ProfileRef.current = p1Profile
57
87
  }, [p1Profile])
@@ -67,19 +97,22 @@ export function useSeatColors(p2IsHuman: boolean) {
67
97
  useEffect(() => {
68
98
  profileColorsRef.current = profileColors
69
99
  }, [profileColors])
100
+ useEffect(() => {
101
+ themeForRef.current = themeFor
102
+ }, [themeFor])
70
103
 
71
104
  // Keeps each seat's live theme color matching what it's supposed to be right now: on mount, and
72
105
  // when a seat's profile changes (a pick, the swap when a seat picks the other seat's profile, or a
73
106
  // deletion from Manage), when seat 2 changes between the CPU and a person, when a profile's pick
74
107
  // changes, and when the roster resolves. profiles itself is read, not depended on (a new array on
75
108
  // every render); profiles.length stands in as a coarse "profiles finished loading" signal.
109
+ // With a seatTheme, dark mode flipping is a reason too: the theme it makes depends on it.
76
110
  useEffect(() => {
77
- const nextP1 = p1Profile ? profileSeatColor(p1Profile, profileColors) : guestColors[1]
78
- const nextP2 = avoidSeatColorClash(nextP1, p2Profile ? profileSeatColor(p2Profile, profileColors) : p2IsHuman ? guestColors[2] : cpuColor, seatDefaultColors)
79
- if (nextP1.toLowerCase() === p1Color.toLowerCase() && nextP2.toLowerCase() === p2Color.toLowerCase()) return
80
- setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } })
111
+ const next = themeFor(remembered.p1, remembered.p2)
112
+ if (sameTheme(themeSettings, next)) return
113
+ setThemeColor(next)
81
114
  // eslint-disable-next-line react-hooks/exhaustive-deps
82
- }, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length])
115
+ }, [lastSelected[1], lastSelected[2], p2IsHuman, guestColors, cpuColor, profileColors, profiles.length, dark])
83
116
 
84
117
  // The loadout pushes the play screen and Profiles on top of itself and comes back via
85
118
  // router.back()/safeBack() - a pop that reveals this same, still-mounted screen. On every focus
@@ -89,14 +122,8 @@ export function useSeatColors(p2IsHuman: boolean) {
89
122
  // everything else is read through the refs above.
90
123
  useFocusEffect(
91
124
  useCallback(() => {
92
- const guest = guestColorsRef.current
93
- const cpu = cpuColorRef.current
94
- const p1Prof = p1ProfileRef.current
95
- const p2Prof = p2ProfileRef.current
96
- const picks = profileColorsRef.current
97
- const nextP1 = p1Prof ? profileSeatColor(p1Prof, picks) : guest[1]
98
- const nextP2 = avoidSeatColorClash(nextP1, p2Prof ? profileSeatColor(p2Prof, picks) : p2IsHuman ? guest[2] : cpu, seatDefaultColors)
99
- setThemeColor({ color: { primary: nextP1, secondary: nextP2, tertiary: getThirdColor(nextP1, nextP2) } })
125
+ const next = rememberedColors(p1ProfileRef.current, p2ProfileRef.current, profileColorsRef.current, guestColorsRef.current, cpuColorRef.current)
126
+ setThemeColor(themeForRef.current(next.p1, next.p2))
100
127
  // eslint-disable-next-line react-hooks/exhaustive-deps
101
128
  }, [lastSelected[1], lastSelected[2], p2IsHuman, setThemeColor])
102
129
  )
@@ -127,21 +154,21 @@ export function useSeatColors(p2IsHuman: boolean) {
127
154
  (hex: string) => {
128
155
  const swapped = hex.toLowerCase() === p2Color.toLowerCase()
129
156
  const nextP2 = swapped ? p1Color : p2Color
130
- setThemeColor({ color: { primary: hex, secondary: nextP2, tertiary: getThirdColor(hex, nextP2) } })
157
+ setThemeColor(themeFor(hex, nextP2))
131
158
  persistSeatColor(1, hex)
132
159
  if (swapped) persistSeatColor(2, nextP2)
133
160
  },
134
- [setThemeColor, p1Color, p2Color, persistSeatColor]
161
+ [setThemeColor, themeFor, p1Color, p2Color, persistSeatColor]
135
162
  )
136
163
  const handleP2ColorChange = useCallback(
137
164
  (hex: string) => {
138
165
  const swapped = hex.toLowerCase() === p1Color.toLowerCase()
139
166
  const nextP1 = swapped ? p2Color : p1Color
140
- setThemeColor({ color: { primary: nextP1, secondary: hex, tertiary: getThirdColor(nextP1, hex) } })
167
+ setThemeColor(themeFor(nextP1, hex))
141
168
  persistSeatColor(2, hex)
142
169
  if (swapped) persistSeatColor(1, nextP1)
143
170
  },
144
- [setThemeColor, p1Color, p2Color, persistSeatColor]
171
+ [setThemeColor, themeFor, p1Color, p2Color, persistSeatColor]
145
172
  )
146
173
 
147
174
  return { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // and store, and every screen, game and shared piece reads the pack through usePack().
3
3
  export { PackProvider, usePack, usePackGame } from './PackContext'
4
4
  export { RootLayout } from './RootLayout'
5
- export { type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type HeroTitleProps, humanSeatsFor, type LoadoutOptionsProps, type PackDefinition, type PackStatsSectionsProps, type RoundMode, roundModeFor, type Seat, type SeatControlLook, type SeatTwo, seatTwoFor } from './types'
5
+ export { type FeedbackSounds, type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, GUEST_PLAYER_ID, type GuidelessGameDefinition, type HeroTitleProps, humanSeatsFor, type LoadoutOptionsProps, type PackDefinition, type PackGameDefinition, type PackGuide, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PickerAlign, type RoundMode, roundModeFor, type Seat, type SeatControlLook, type SeatTwo, seatTwoFor, type StackScreenOptions } from './types'
6
6
 
7
7
  // The screens behind an app's routes (src/app/ stays in the app: expo-router only reads routes there).
8
8
  export { LoadoutRoute, PlayRoute } from './screens/GameRoute'
@@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
2
2
  import { combineReducers, configureStore, type Middleware, type Reducer } from '@reduxjs/toolkit'
3
3
  import { FLUSH, PAUSE, PERSIST, type PersistedState, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'
4
4
 
5
- import type { GameDefinition, PackDefinition } from '../types'
5
+ import type { PackDefinition, PackGameDefinition } from '../types'
6
6
  import lastActiveGame from './lastActiveGameSlice'
7
7
  import { createShellReducers } from './shellState'
8
8
 
@@ -22,16 +22,25 @@ export interface PackStoreConfig {
22
22
  keyPrefix: string
23
23
  // redux-persist's migrate, run on the saved store before it rehydrates (see pack.ts).
24
24
  migrate?: (state: PersistedState, version: number) => Promise<PersistedState>
25
+ // Keys left out of the root entry, beside liveplay: keys that persist themselves (a key whose reducer
26
+ // is its own persistReducer, saving each game's board to an entry of its own), so they aren't written
27
+ // into the root entry too.
28
+ blacklist?: readonly string[]
29
+ // State paths the dev serializability check skips (keys too big to walk on every action, such as
30
+ // saved boards).
31
+ serializableIgnoredPaths?: readonly string[]
32
+ // The pack's own middleware, after the shell's.
33
+ middleware?: readonly Middleware[]
25
34
  }
26
35
 
27
36
  // What the store is built from: the pack's games (each mounts its own keys, GameDefinition.reducers,
28
37
  // and whether one is resumable decides the resume key), the pack's own keys (PackDefinition.reducers),
29
38
  // and the seat colors and appearance the shell's own keys start from.
30
- export type PackStoreSource = Pick<PackDefinition, 'seatColors' | 'appearance' | 'reducers'> & { games: readonly Pick<GameDefinition, 'reducers' | 'resumable'>[] }
39
+ export type PackStoreSource = Pick<PackDefinition, 'seatColors' | 'appearance' | 'seatTheme' | 'reducers'> & { games: readonly Pick<PackGameDefinition, 'reducers' | 'resumable'>[] }
31
40
 
32
41
  // The pack's one Redux store, persisted with redux-persist: the shell's keys (shellState.ts), the
33
42
  // pack's own, and every game's own.
34
- export function createPackStore(pack: PackStoreSource, { keyPrefix, migrate }: PackStoreConfig) {
43
+ export function createPackStore(pack: PackStoreSource, { keyPrefix, migrate, blacklist = [], serializableIgnoredPaths = [], middleware = [] }: PackStoreConfig) {
35
44
  const gameReducers: Record<string, Reducer> = Object.assign({}, ...pack.games.map((game) => game.reducers))
36
45
  // Which play screen the player was last on, for a cold launch to resume (GameDefinition.resumable).
37
46
  // Only in a pack with a resumable game, so a pack without one saves exactly what it did before.
@@ -52,7 +61,7 @@ export function createPackStore(pack: PackStoreSource, { keyPrefix, migrate }: P
52
61
  // failing to ever resolve at all isn't a failure mode worth a 5s failsafe for.
53
62
  timeout: 0,
54
63
  // liveplay is per-round state that must never survive a relaunch (see liveplaySlice.ts).
55
- blacklist: ['liveplay'],
64
+ blacklist: ['liveplay', ...blacklist],
56
65
  migrate
57
66
  },
58
67
  rootReducer
@@ -63,10 +72,11 @@ export function createPackStore(pack: PackStoreSource, { keyPrefix, migrate }: P
63
72
  const defaultMiddleware = getDefaultMiddleware({
64
73
  immutableCheck: false,
65
74
  serializableCheck: {
66
- ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
75
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
76
+ ignoredPaths: [...serializableIgnoredPaths]
67
77
  }
68
78
  })
69
- return defaultMiddleware.concat(errorMiddleware)
79
+ return defaultMiddleware.concat(errorMiddleware, ...middleware)
70
80
  },
71
81
  reducer: persistedReducer
72
82
  })
@@ -21,7 +21,7 @@ const appSoundReducer = (state: SoundSettings = initialSoundSettings, action: {
21
21
 
22
22
  // The store keys every pack has, whatever its games, built from the pack's own seat colors and
23
23
  // appearance. Each game adds its own keys beside these (see GameDefinition.reducers).
24
- export function createShellReducers({ seatColors, appearance = 'system' }: Pick<PackDefinition, 'seatColors' | 'appearance'>) {
24
+ export function createShellReducers({ seatColors, appearance = 'system', seatTheme }: Pick<PackDefinition, 'seatColors' | 'appearance' | 'seatTheme'>) {
25
25
  return {
26
26
  // Explicit primary/secondary triad (rather than a single seed expanded via harmony) so the pack's
27
27
  // identity is pinned exactly instead of drifting with whatever hue math a harmony offset would
@@ -30,7 +30,9 @@ export function createShellReducers({ seatColors, appearance = 'system' }: Pick<
30
30
  // derived, not picked, via getThirdColor: the hue maximally distant from both (see
31
31
  // @rific/auto-paper's README "Explicit triad" section). The appearance is the pack's ('system',
32
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]) } }),
33
+ // A pack with its own seatTheme starts from what seat 1's default color makes of it instead (dark
34
+ // only when the pack's appearance is; hooks/useSeatColors.ts re-applies it with the live mode).
35
+ theme: createThemeReducer(seatTheme ? { appearance, ...seatTheme(seatColors, appearance === 'dark') } : { appearance, color: { primary: seatColors[1], secondary: seatColors[2], tertiary: getThirdColor(seatColors[1], seatColors[2]) } }),
34
36
  haptic: hapticReducer,
35
37
  sound: appSoundReducer,
36
38
  // @rific/core's Keep Awake setting (see Providers.tsx's KeepAwakeBridge), under the namespace its
package/src/routes.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Href } from 'expo-router'
2
+
3
+ // The shell's own routes, the ones every pack's src/app/ has (play/[game], loadout/[game], stats,
4
+ // profiles). An app with expo-router's typed routes type-checks this source against its own route tree
5
+ // (its tsc reads the package's src/), and an app moving onto the shell (Solitaire) has these routes only
6
+ // part of the way through. So they're handed to expo-router as plain Hrefs, not checked against it.
7
+ export const playHref = (game: string) => ({ pathname: '/play/[game]', params: { game } }) as unknown as Href
8
+ export const loadoutHref = (game: string) => ({ pathname: '/loadout/[game]', params: { game } }) as unknown as Href
9
+ export const STATS_HREF = '/stats' as unknown as Href
10
+ export const PROFILES_HREF = '/profiles' as unknown as Href
@@ -3,7 +3,8 @@ import { useEffect } from 'react'
3
3
 
4
4
  import { useLastActiveGame } from '../hooks/useLastActiveGame'
5
5
  import { usePackGame } from '../PackContext'
6
- import type { GameDefinition } from '../types'
6
+ import { playHref } from '../routes'
7
+ import type { PackGameDefinition } from '../types'
7
8
  import { type LoadoutGame, LoadoutScreen } from './LoadoutScreen'
8
9
 
9
10
  // The game routes, loadout/[game] and play/[game]: a game's loadout and its play screen, by id. An id
@@ -17,11 +18,11 @@ export function LoadoutRoute() {
17
18
  const game = usePackGame(id)
18
19
  if (!game) return <Redirect href='/' />
19
20
  // 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
+ if (!hasLoadout(game)) return <Redirect href={playHref(game.id)} />
21
22
  return <LoadoutScreen key={game.id} game={game} />
22
23
  }
23
24
 
24
- function hasLoadout(game: GameDefinition): game is LoadoutGame {
25
+ function hasLoadout(game: PackGameDefinition): game is LoadoutGame {
25
26
  return !!game.useLoadout
26
27
  }
27
28
 
@@ -1,12 +1,12 @@
1
- import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { IconButton, Pressable } from '@rific/feedback-press'
3
3
  import { FakeLandscapeView, useRotation } from '@tastic/core'
4
- import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud'
5
- import { type Profile, ProfilePicker } from '@tastic/profile'
4
+ import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
5
+ import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
6
6
  import { Redirect, router, useIsFocused } from 'expo-router'
7
- import { useCallback, useEffect, useState } from 'react'
8
- import { AppState, type AppStateStatus, ScrollView, StyleSheet, View } from 'react-native'
9
- import { Icon, Text } from 'react-native-paper'
7
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
8
+ import { AppState, type AppStateStatus, StyleSheet, View } from 'react-native'
9
+ import { Icon, Portal, Text } from 'react-native-paper'
10
10
 
11
11
  import { useGameGuide } from '../components/GuideHost'
12
12
  import { SettingsDialog } from '../components/SettingsDialog'
@@ -16,7 +16,8 @@ import { useOpponent } from '../hooks/useOpponent'
16
16
  import { useProfiles } from '../hooks/useProfiles'
17
17
  import { useSeatColors } from '../hooks/useSeatColors'
18
18
  import { usePack } from '../PackContext'
19
- import type { GameDefinition } from '../types'
19
+ import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
20
+ import type { PackGameDefinition } from '../types'
20
21
  import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_BAND, HOME_WIDE_PANE_GAP, PREVIEW_ASPECT, ROW_PREVIEW_WIDTH, useHomeLayout } from './homeLayout'
21
22
 
22
23
  // Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
@@ -26,10 +27,13 @@ import { HOME_GRID_GAP, HOME_LEFT_PANE_PICKER_GAP, HOME_TILE_PADDING, HOME_TOP_B
26
27
  // picked. Seat 2 is chosen in the loadout, from seat 2's own button.
27
28
  //
28
29
  // A cold launch that finds the player left the app inside a resumable game's play screen
29
- // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints.
30
+ // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
31
+ // pack's own How to Play (PackDefinition.guide) shows by itself on a first launch, held back while
32
+ // that launch is about to drop into a game: it must never open over a game in progress.
30
33
  export function HomeScreen() {
31
34
  const pack = usePack()
32
35
  const { consumeResumeGame } = useLastActiveGame()
36
+ const { autoShowPack } = useGameGuide()
33
37
  // A one-shot cold-launch decision, read exactly once at this instance's mount (see
34
38
  // hooks/useLastActiveGame.tsx), never a live value this screen re-renders from. Reading it live made
35
39
  // every Home in the stack swap its whole menu for a Redirect while a game was on top and rebuild it
@@ -39,7 +43,10 @@ export function HomeScreen() {
39
43
  const id = consumeResumeGame()
40
44
  return pack.games.find((g) => g.id === id && g.resumable) ?? null
41
45
  })
42
- if (resumeGame) return <Redirect href={{ pathname: '/play/[game]', params: { game: resumeGame.id } }} />
46
+ useEffect(() => {
47
+ if (!resumeGame) autoShowPack()
48
+ }, [resumeGame, autoShowPack])
49
+ if (resumeGame) return <Redirect href={playHref(resumeGame.id)} />
43
50
  return <HomeMenu />
44
51
  }
45
52
 
@@ -48,9 +55,11 @@ function HomeMenu() {
48
55
  const rotation = useRotation()
49
56
  const layout = useHomeLayout()
50
57
  const { insets } = layout
51
- const { dark } = useAutoPaperTheme()
52
- const { openFirstTime, openFor } = useGameGuide()
58
+ const { dark, colors } = useAutoPaperTheme()
59
+ const { openFirstTime, openFor, openPack } = useGameGuide()
53
60
  const [settingsOpen, setSettingsOpen] = useState(false)
61
+ // The game whose rules sheet (GameDefinition.Rules) is open.
62
+ const [rulesGame, setRulesGame] = useState<PackGameDefinition | null>(null)
54
63
  const isFocused = useIsFocused()
55
64
  const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
56
65
  useEffect(() => {
@@ -84,82 +93,135 @@ function HomeMenu() {
84
93
  const id = requestAnimationFrame(() => setPreviewsRevealed(true))
85
94
  return () => cancelAnimationFrame(id)
86
95
  }, [tiled])
87
- // A popover is placed once, as it opens. Switching between the column and the two panes unmounts
88
- // the very button it hangs off, which would leave the press-away overlay up with nothing to dismiss,
89
- // so the switch closes it.
96
+ // A popover is placed once, as it opens, from measurements of the frame it opens in. Turning the
97
+ // phone (or any resize) moves the frame under it, and switching between the column and the two panes
98
+ // unmounts the very button it hangs off, which would leave the press-away overlay up with nothing to
99
+ // dismiss. So every layout change closes it.
90
100
  const closePicker = pickerHost.close
91
101
  useEffect(() => {
92
102
  closePicker()
93
- }, [isWide, closePicker])
103
+ }, [isWide, rotation, layout.windowWidth, layout.windowHeight, closePicker])
94
104
 
95
- const bg = dark ? '#000000' : '#FFFFFF'
96
- const fg = dark ? '#FFFFFF' : '#000000'
105
+ // Over the pack's Backdrop Home draws nothing of its own behind the menu.
106
+ const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
107
+ const fg = pack.foreground ?? (dark ? '#FFFFFF' : '#000000')
108
+ // Seat 1's color as the pack draws profile colors (PackDefinition.previewColor), and the button on
109
+ // the theme's surface over a backdrop, so neither washes out on it.
110
+ const { previewColor } = pack
111
+ const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
112
+ const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
97
113
 
98
114
  // A game with nothing to set up (no useLoadout) opens straight to its play screen.
99
- const openGame = useCallback((game: GameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? { pathname: '/loadout/[game]', params: { game: game.id } } : { pathname: '/play/[game]', params: { game: game.id } })), [openFirstTime])
115
+ const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
116
+
117
+ // A tile's rules button: the game's rules sheet where it has one, else its How to Play cards.
118
+ const openRules = useCallback((game: PackGameDefinition) => (game.Rules ? setRulesGame(game) : openFor(game.id)), [openFor])
119
+
120
+ const colorSection: ProfilePickerColorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined, previewValue: previewColor ? (hex: string) => previewColor(hex, dark) : undefined }
121
+ // The pack's own placement for the menu (PackDefinition.usePickerAlign), sized from the same rows
122
+ // and color page the picker draws; hud's own without one. The pack never changes while it runs.
123
+ const usePickerAlign = pack.usePickerAlign ?? noPickerAlign
124
+ const pickerSize = getProfilePickerContentSize(profiles.length, true, colorSection)
125
+ const pickerAlign = usePickerAlign(pickerHost.openId === `${SEAT_ONE_PICKER}-profile`, pickerSize.width, pickerSize.height)
126
+
127
+ const picker = <ProfilePicker idPrefix={SEAT_ONE_PICKER} host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Drawn} dark={dark} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push(PROFILES_HREF)} colorSection={colorSection} alignOverride={pickerAlign} triggerStyle={triggerStyle} />
100
128
 
101
- const picker = <ProfilePicker idPrefix='home-p1' host={pickerHost} profiles={profiles} selectedId={lastSelected[1]} color={p1Color} dark={dark} guestLabel='P1' onSelect={(profile: Profile | null) => selectProfile(1, profile?.id ?? null)} onManage={() => router.push('/profiles')} colorSection={{ value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors, caption: selectedProfile ? pack.colorCaption : undefined }} />
129
+ // Closes seat 1's menu on a press anywhere but the menu and its button. Where it's mounted matters on
130
+ // a phone, where zIndex only orders the children of one parent: the menu lives inside the scroller in
131
+ // the upright layout, so the overlay goes there too, under the button's wrapper (raised while the menu
132
+ // is open) and over the rows. As the frame's first child it sat under the whole scroller, and a press
133
+ // on a row played the game. In the wide layout the left pane is itself the frame's child, so the
134
+ // overlay stays there, under the pane and over the grid.
135
+ const pressAway = <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} style={styles.pressAway} />
136
+ const RulesSheet = rulesGame?.Rules
102
137
 
103
138
  return (
104
139
  <View style={[styles.container, { backgroundColor: bg }]}>
105
140
  {/* Reads correctly however the phone is held: see @tastic/core's FakeLandscapeView. The
106
- Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. */}
141
+ Settings dialog stays outside it: it renders through a Portal, turned by its own `rotation`. A
142
+ game's rules sheet renders inside it instead (see RulesHost). */}
107
143
  <FakeLandscapeView style={styles.rotatable}>
108
- <PressAwayOverlay active={pickerHost.openId !== null} onPress={pickerHost.close} />
109
- {isWide ? (
110
- <>
111
- {/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
144
+ <RulesHost enabled={pack.games.some((game) => game.Rules)}>
145
+ {isWide ? (
146
+ <>
147
+ {pressAway}
148
+ {/* Right pane: the game grid, scrolling on its own beside the left pane. The scroller runs
112
149
  to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
113
- sits at the window's edge. */}
114
- <ScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
115
- <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
116
- {pack.games.map((game) => (
117
- <GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
118
- ))}
119
- </View>
120
- </ScrollView>
121
- {/* Left pane: the hero, then seat 1's button, centred as one group under the corner
150
+ sits at the window's edge. Both scrollers fade out at an edge while there is more past it
151
+ (auto-paper's FadingScrollView, a mask, so whatever is behind shows through). */}
152
+ <FadingScrollView style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
153
+ <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
154
+ {pack.games.map((game) => (
155
+ <GameTile key={game.id} game={game} width={layout.tileWidth} previewWidth={layout.previewWidth} previewRevealed={previewsRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
156
+ ))}
157
+ </View>
158
+ </FadingScrollView>
159
+ {/* Left pane: the hero, then seat 1's button, centred as one group under the corner
122
160
  buttons' band. A later sibling of the grid, lifted while seat 1's menu is open so the menu
123
161
  paints over the tiles. box-none: only its controls claim taps. */}
124
- <View style={[styles.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles.pickerOpen]}>
125
- <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
126
- <View style={styles.widePicker}>{picker}</View>
127
- </View>
128
- </>
129
- ) : (
130
- <ScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
131
- <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
162
+ <View style={[styles.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles.pickerOpen]}>
163
+ <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} fit={layout.heroFit} />
164
+ <View style={styles.widePicker}>{picker}</View>
165
+ </View>
166
+ </>
167
+ ) : (
168
+ <FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
169
+ {pressAway}
170
+ <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
132
171
 
133
- {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
172
+ {/* Who's playing: seat 1's button, the same one each loadout starts with. Lifted above the
134
173
  tiles while its menu is open, so the menu paints over them. */}
135
- <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
174
+ <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
136
175
 
137
- <View style={styles.list}>
138
- {pack.games.map((game) => (
139
- <GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={() => openFor(game.id)} />
140
- ))}
141
- </View>
142
- </ScrollView>
143
- )}
176
+ <View style={styles.list}>
177
+ {pack.games.map((game) => (
178
+ <GameRow key={game.id} game={game} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
179
+ ))}
180
+ </View>
181
+ </FadingScrollView>
182
+ )}
144
183
 
145
- <IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push('/stats')} accessibilityLabel='Stats & Achievements' />
146
- <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
184
+ <IconButton icon='trophy' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={() => router.push(STATS_HREF)} accessibilityLabel='Stats & Achievements' />
185
+ <IconButton icon='cog' iconColor={fg} size={24} style={[styles.corner, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={() => setSettingsOpen(true)} accessibilityLabel='Settings' />
186
+ {RulesSheet && <RulesSheet visible onDismiss={() => setRulesGame(null)} />}
187
+ </RulesHost>
147
188
  </FakeLandscapeView>
148
189
 
149
- <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} />
190
+ {/* Home's How to Play row replays the pack's own cards (PackDefinition.guide); without them each
191
+ tile's rules button shows its game's. */}
192
+ <SettingsDialog visible={settingsOpen} onDismiss={() => setSettingsOpen(false)} rotation={rotation} onShowHowToPlay={pack.guide ? openPack : undefined} />
150
193
  </View>
151
194
  )
152
195
  }
153
196
 
197
+ // Seat 1's button's popover id prefix (its menu is `${SEAT_ONE_PICKER}-profile`).
198
+ const SEAT_ONE_PICKER = 'home-p1'
199
+
200
+ // No placement of the pack's own: hud places the menu itself.
201
+ const noPickerAlign = () => undefined
202
+
203
+ // Whether a tile has a rules button: a rules sheet or How to Play cards to show.
204
+ function hasRules(game: PackGameDefinition): boolean {
205
+ return !!(game.Rules || game.guide)
206
+ }
207
+
208
+ // A game's rules sheet (GameDefinition.Rules) portals into a Paper host inside Home's turned frame, so
209
+ // it's turned with the frame and laid out against it (a sheet laid out against the upright window ran
210
+ // off a phone held sideways). Only in a pack with one: a pack without keeps the frame as it was.
211
+ function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNode }) {
212
+ return enabled ? <Portal.Host>{children}</Portal.Host> : <>{children}</>
213
+ }
214
+
154
215
  interface GameRowProps {
155
- game: GameDefinition
216
+ game: PackGameDefinition
156
217
  dark: boolean
157
218
  p1Color: string
158
219
  p2Color: string
159
220
  // Whether Home has painted once, so a preview may draw (see previewsRevealed).
160
221
  previewRevealed: boolean
161
222
  onPress: () => void
162
- onRules: () => void
223
+ // Undefined for a game with no rules sheet and no How to Play cards: no rules button.
224
+ onRules?: () => void
163
225
  }
164
226
 
165
227
  // One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
@@ -173,13 +235,7 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
173
235
  // inside a button is invalid on the web.
174
236
  <View>
175
237
  <Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
176
- {game.Preview ? (
177
- <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
178
- ) : (
179
- <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
180
- <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />
181
- </View>
182
- )}
238
+ {game.Preview ? <PreviewBox game={game} width={ROW_PREVIEW_WIDTH} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} /> : <View style={[styles.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>{GameIcon && <GameIcon size={ROW_ICON_SIZE} p1Color={p1Color} p2Color={p2Color} dark={dark} />}</View>}
183
239
  <View style={styles.rowText}>
184
240
  <Text variant='titleLarge' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
185
241
  {game.title}
@@ -189,9 +245,11 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
189
245
  </Text>
190
246
  </View>
191
247
  </Pressable>
192
- <Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
193
- <Icon source='information-outline' size={22} color={well.fgMuted} />
194
- </Pressable>
248
+ {onRules && (
249
+ <Pressable onPress={onRules} style={styles.rulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
250
+ <Icon source='information-outline' size={22} color={well.fgMuted} />
251
+ </Pressable>
252
+ )}
195
253
  </View>
196
254
  )
197
255
  }
@@ -217,15 +275,17 @@ function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRe
217
275
  {game.blurb}
218
276
  </Text>
219
277
  </Pressable>
220
- <Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
221
- <Icon source='information-outline' size={20} color='#FFFFFF' />
222
- </Pressable>
278
+ {onRules && (
279
+ <Pressable onPress={onRules} style={styles.tileRulesButton} hitSlop={12} accessibilityRole='button' accessibilityLabel={`${game.title} rules`}>
280
+ <Icon source='information-outline' size={20} color='#FFFFFF' />
281
+ </Pressable>
282
+ )}
223
283
  </View>
224
284
  )
225
285
  }
226
286
 
227
287
  interface PreviewBoxProps {
228
- game: GameDefinition
288
+ game: PackGameDefinition
229
289
  width: number
230
290
  revealed: boolean
231
291
  dark: boolean
@@ -241,13 +301,13 @@ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }: PreviewBo
241
301
  const iconSize = Math.min(height, ROW_ICON_SIZE)
242
302
  return (
243
303
  <View style={[styles.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }]}>
244
- {Preview ? (
245
- revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
246
- ) : (
247
- <View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
248
- <GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
249
- </View>
250
- )}
304
+ {Preview
305
+ ? revealed && <Preview width={width} height={height} p1Color={p1Color} p2Color={p2Color} dark={dark} />
306
+ : GameIcon && (
307
+ <View style={[styles.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }]}>
308
+ <GameIcon size={iconSize} p1Color={p1Color} p2Color={p2Color} dark={dark} />
309
+ </View>
310
+ )}
251
311
  </View>
252
312
  )
253
313
  }
@@ -289,6 +349,15 @@ const styles = StyleSheet.create({
289
349
  pickerOpen: {
290
350
  zIndex: 100
291
351
  },
352
+ // Seat 1's button on the theme's surface (over a backdrop), the same pill as the tiles' wells.
353
+ pill: {
354
+ borderRadius: 999
355
+ },
356
+ // Over the rows (and, wide, the grid), whose own stacking is unset, so a press on them reaches it on
357
+ // the web too, and under the raised button and left pane, so the menu's rows stay pressable.
358
+ pressAway: {
359
+ zIndex: 5
360
+ },
292
361
  // The preview's own rounding, clipping whatever it draws to the box.
293
362
  preview: {
294
363
  alignItems: 'center',