@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.
- package/README.md +71 -12
- package/dist/index.d.mts +86 -14
- package/dist/index.d.ts +86 -14
- package/dist/index.js +317 -148
- package/dist/index.mjs +321 -153
- package/package.json +9 -9
- package/src/PackContext.tsx +2 -2
- package/src/RootLayout.tsx +110 -28
- package/src/components/GuideHost.tsx +62 -23
- package/src/components/Providers.tsx +17 -7
- package/src/components/SeatTwoPanel.tsx +2 -1
- package/src/components/SettingsDialog.tsx +6 -4
- package/src/components/UpdateDialog.tsx +1 -1
- package/src/hooks/useSeatColors.ts +54 -27
- package/src/index.ts +1 -1
- package/src/redux/createPackStore.ts +16 -6
- package/src/redux/shellState.ts +4 -2
- package/src/routes.ts +10 -0
- package/src/screens/GameRoute.tsx +4 -3
- package/src/screens/HomeScreen.tsx +145 -76
- package/src/screens/LoadoutScreen.tsx +13 -7
- package/src/screens/ProfilesRoute.tsx +26 -1
- package/src/screens/StatsScreen.tsx +138 -56
- package/src/types.ts +143 -10
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
93
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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<
|
|
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
|
})
|
package/src/redux/shellState.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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={
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
88
|
-
// the
|
|
89
|
-
//
|
|
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
|
-
|
|
96
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
<
|
|
116
|
-
{
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
<View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
|
|
136
175
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
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
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
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:
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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',
|