@tastic/shell 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
@@ -1,9 +1,11 @@
1
1
  import { Redirect, useLocalSearchParams } from 'expo-router'
2
2
  import { useEffect } from 'react'
3
+ import { AppState } from 'react-native'
3
4
 
4
5
  import { useLastActiveGame } from '../hooks/useLastActiveGame'
5
6
  import { usePackGame } from '../PackContext'
6
- import type { GameDefinition } from '../types'
7
+ import { playHref } from '../routes'
8
+ import type { PackGameDefinition } from '../types'
7
9
  import { type LoadoutGame, LoadoutScreen } from './LoadoutScreen'
8
10
 
9
11
  // The game routes, loadout/[game] and play/[game]: a game's loadout and its play screen, by id. An id
@@ -17,11 +19,11 @@ export function LoadoutRoute() {
17
19
  const game = usePackGame(id)
18
20
  if (!game) return <Redirect href='/' />
19
21
  // 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 } }} />
22
+ if (!hasLoadout(game)) return <Redirect href={playHref(game.id)} />
21
23
  return <LoadoutScreen key={game.id} game={game} />
22
24
  }
23
25
 
24
- function hasLoadout(game: GameDefinition): game is LoadoutGame {
26
+ function hasLoadout(game: PackGameDefinition): game is LoadoutGame {
25
27
  return !!game.useLoadout
26
28
  }
27
29
 
@@ -35,14 +37,20 @@ export function PlayRoute() {
35
37
  }
36
38
 
37
39
  // 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.
40
+ // remembered as it mounts, and forgotten when the player backs out of it (its own back, hardware back
41
+ // or a browser back), which unmounts it while the app is in use. Backgrounding the app leaves the
42
+ // screen mounted, so it stays remembered. An unmount with the app NOT active keeps it too: when iOS
43
+ // terminates an app it hasn't suspended yet (swiped away from the app switcher soon after leaving
44
+ // it), React Native's renderer stops every surface (RCTSurfacePresenter's will-terminate handler),
45
+ // which unmounts the whole tree, and clearing then sent every such relaunch to Home. Runs once per
46
+ // play screen: the context's functions never change.
41
47
  function useResumePoint(gameId: string | null) {
42
48
  const { setLastActiveGame, clearLastActiveGame } = useLastActiveGame()
43
49
  useEffect(() => {
44
50
  if (!gameId) return
45
51
  setLastActiveGame(gameId)
46
- return clearLastActiveGame
52
+ return () => {
53
+ if (AppState.currentState === 'active') clearLastActiveGame()
54
+ }
47
55
  }, [gameId, setLastActiveGame, clearLastActiveGame])
48
56
  }