@tastic/shell 0.3.0 → 0.4.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 CHANGED
@@ -18,9 +18,11 @@ package ecosystem, on [`@tastic/hud`](https://github.com/jayrdeaton/react-native
18
18
 
19
19
  - **`RootLayout`**: the root of the app (gesture root, safe area, fonts, Redux and its persist gate,
20
20
  the theme, profiles, every game's own provider, the splash gates, the shared orientation reading,
21
- toasts, How to Play, the OTA update dialog) around expo-router's stack.
21
+ toasts, How to Play, the OTA update dialog) around expo-router's stack. The stack follows the app's
22
+ own theme, not the phone's light or dark appearance: the status bar's text, the root window and the
23
+ navigation theme's background (what shows in a sliding screen's corners during a push or pop).
22
24
  - **Screens** for the routes: `HomeScreen` (the pack's hero, seat 1's button, a row per game, or a
23
- grid of preview tiles on a wide screen), `LoadoutRoute` and `PlayRoute` (`loadout/[game]`,
25
+ grid of preview tiles on a wide screen, split into tabs where the pack has them), `LoadoutRoute` and `PlayRoute` (`loadout/[game]`,
24
26
  `play/[game]`), `ProfilesRoute` and `StatsScreen`.
25
27
  - **The store**: `createPackStore(pack, { keyPrefix, migrate, blacklist, serializableIgnoredPaths,
26
28
  middleware })` builds one persisted store from the shell's keys (theme, haptics, sound, Keep Awake,
@@ -112,7 +114,7 @@ PuckTastic and DuelTastic draw what they always did:
112
114
  | `Backdrop?` | A table drawn once behind the whole stack (Solitaire's felt). The stack's two layers go transparent (the screens' `contentStyle` and the navigation theme's `colors.background`, which React Navigation paints around every screen), the root window stays dark so light mode doesn't flash white on a push, Android's push and pop crossfade (its default holds the outgoing screen opaque), and Home, each loadout, the stats screen and Profiles draw transparent, with their buttons and tabs on the theme's surface. |
113
115
  | `foreground?` | The color of what those screens draw straight on the background (the hero, the corner buttons, headers), and at half strength a loadout's muted options. Unset is black or white with the theme. |
114
116
  | `Provider?` | Mounted once around How to Play and the stack, inside the store, the theme, profiles and every game's `Provider` (one stats context across the pack's games). |
115
- | `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a light status bar, Reduce Motion's crossfade). |
117
+ | `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a status bar style of the pack's own, Reduce Motion's crossfade). The shell's already follow the app's theme, and stay dark over a `Backdrop`. |
116
118
  | `useFeedbackSounds?` | A hook returning the two sounds every button plays (`FeedbackSounds`: `selection`, `notification`), in place of the shell's two wavs. |
117
119
  | `Toaster?` | The pack's own toaster, drawn in a frame turned with the phone (with the safe-area insets turned onto its edges) in place of the shell's. The toast provider is given no Paper, so nothing portals it out of the frame. |
118
120
  | `portraitWhileKeyboard?` | Every screen reads upright while the software keyboard is up (`@tastic/core`'s `OrientationProvider`), for naming a profile sideways. |
@@ -128,6 +130,7 @@ PuckTastic and DuelTastic draw what they always did:
128
130
  | `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
129
131
  | `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
130
132
  | `usePickerAlign?` | `(open, contentWidth, contentHeight, options?) => AlignResult` (`PickerAlign`), a hook: where seat 1's menu on Home, both seat buttons' menus in a loadout's one-human row (2 Player keeps each menu in its own player's half), the stats screen's two pickers and a profile's color swatch open. hud's own placement measures a turned button against the upright window, so with the phone held sideways it can run a menu off the screen (seen on the iOS simulator, 2026-10-29); a pack whose screens turn hands in a frame-aware one (Solitaire's `useFrameAutoAlign`). Profiles passes `{ maxContentWidth: 640 }`, its roster's column. Unset is hud's placement. |
133
+ | `homeTabs?` | Tabs splitting Home's games (`HomeTab`: a `label`, and `includes(game)`, which of the pack's games it shows, in the pack's order): a switch under seat 1's button, or over the grid on a wide screen, on the theme's surface over a `Backdrop`, showing one tab's games at a time. It opens on the first tab and keeps the one picked while Home is covered or turns. The other tabs' games stay mounted, hidden, so a switch draws nothing new. A game no tab includes isn't on Home. Unset, or a single tab, shows every game with no switch. Solitaire's: Solitaire (no `seatTwo`) and Two Player. |
131
134
 
132
135
  ### `GameDefinition`
133
136
 
package/dist/index.d.mts CHANGED
@@ -252,6 +252,10 @@ interface PackStatsSectionsProps {
252
252
  profileId: string | null;
253
253
  gameId: string | null;
254
254
  }
255
+ interface HomeTab {
256
+ label: string;
257
+ includes: (game: PackGameDefinition) => boolean;
258
+ }
255
259
  type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>;
256
260
  interface FeedbackSounds {
257
261
  selection: () => void;
@@ -275,6 +279,7 @@ interface PackDefinition {
275
279
  wordmark: readonly [string, string];
276
280
  colorCaption: string;
277
281
  games: readonly PackGameDefinition[];
282
+ homeTabs?: readonly HomeTab[];
278
283
  seatColors: Record<Seat, string>;
279
284
  appearance?: ThemeAppearance;
280
285
  HeroTitle: ComponentType<HeroTitleProps>;
@@ -556,4 +561,4 @@ declare function createPlayerSettings<S extends WithControls<C>, C>({ id, defaul
556
561
  };
557
562
  };
558
563
 
559
- export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
564
+ export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type HomeTab, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
package/dist/index.d.ts CHANGED
@@ -252,6 +252,10 @@ interface PackStatsSectionsProps {
252
252
  profileId: string | null;
253
253
  gameId: string | null;
254
254
  }
255
+ interface HomeTab {
256
+ label: string;
257
+ includes: (game: PackGameDefinition) => boolean;
258
+ }
255
259
  type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>;
256
260
  interface FeedbackSounds {
257
261
  selection: () => void;
@@ -275,6 +279,7 @@ interface PackDefinition {
275
279
  wordmark: readonly [string, string];
276
280
  colorCaption: string;
277
281
  games: readonly PackGameDefinition[];
282
+ homeTabs?: readonly HomeTab[];
278
283
  seatColors: Record<Seat, string>;
279
284
  appearance?: ThemeAppearance;
280
285
  HeroTitle: ComponentType<HeroTitleProps>;
@@ -556,4 +561,4 @@ declare function createPlayerSettings<S extends WithControls<C>, C>({ id, defaul
556
561
  };
557
562
  };
558
563
 
559
- export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
564
+ export { type ActiveRound, type AppSettings, DEFAULT_APP_SETTINGS, DEFAULT_OPPONENT, type FeedbackSounds, GUEST_PLAYER_ID, type GameDefinition, type GameGuide, type GameGuideState, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, type GuidelessGameDefinition, type HeroTitleProps, HomeScreen, type HomeTab, type LiveplaySliceState, type LoadoutOptionsProps, LoadoutRoute, type OpponentState, PREVIEW_ASPECT, type PackDefinition, type PackGameDefinition, type PackGuide, PackProvider, type PackStatsReset, type PackStatsSectionsProps, type PackStatsTab, type PackStore, type PackStoreConfig, type PackStoreSource, type PickerAlign, PlayRoute, type PlayerControlsState, type PlayerSettingsState, type ProfileSelectionState, ProfilesProvider, ProfilesRoute, type RecordResult, RootLayout, type RoundMode, type Seat, type SeatColorsState, type SeatControlLook, type SeatTwo, SettingsDialog, type SettingsDialogProps, type ShellState, type StackScreenOptions, StatsScreen, appSettingsActions, reducer$1 as appSettingsReducer, createGameGuideSlice, createGameStats, createPackStore, createPlayerSettings, createSeatColorsReducer, createShellReducers, defaultProfileSelectionState, defaultSeatColorsState, forgetProfileAchievements, humanSeatsFor, imageGameIcon, liveplayActions, reducer$3 as liveplayReducer, onProfileRemoved, opponentActions, reducer$2 as opponentReducer, profileSelectionActions, reducer as profileSelectionReducer, roundModeFor, seatColorsActions, seatPlayerKey, seatTwoFor, useActiveRound, useAppSettings, useGameGuide, useOpponent, usePack, usePackGame, useProfiles, useSeatColors };
package/dist/index.js CHANGED
@@ -484,8 +484,9 @@ function AppStack() {
484
484
  const pack = usePack();
485
485
  const { Backdrop } = pack;
486
486
  const { dark } = (0, import_auto_paper2.useAutoPaperTheme)();
487
- const rootBackground = (0, import_core6.useThemedRootBackground)(Backdrop ? true : dark, false);
488
- const statusBar = (0, import_core6.useRotationAwareStatusBar)();
487
+ const darkRoot = Backdrop ? true : dark;
488
+ const rootBackground = (0, import_core6.useThemedRootBackground)(darkRoot, false);
489
+ const statusBar = (0, import_core6.useRotationAwareStatusBar)({ style: darkRoot ? "light" : "dark" });
489
490
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
490
491
  const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: import_react_native2.Platform.select({ android: "fade" }) } : void 0;
491
492
  const playOptions = (0, import_react9.useCallback)(
@@ -495,15 +496,19 @@ function AppStack() {
495
496
  },
496
497
  [pack.games]
497
498
  );
498
- const navigationTheme = (0, import_react9.useMemo)(() => Backdrop ? { ...import_expo_router.DarkTheme, colors: { ...import_expo_router.DarkTheme.colors, background: "transparent" } } : null, [Backdrop]);
499
- const stack = /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_expo_router.Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
499
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
500
+ const navigationTheme = (0, import_react9.useMemo)(() => {
501
+ const base = darkRoot ? import_expo_router.DarkTheme : import_expo_router.DefaultTheme;
502
+ return { ...base, colors: { ...base.colors, background: navigationBackground } };
503
+ }, [darkRoot, navigationBackground]);
504
+ const stack = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.ThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_expo_router.Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
500
505
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "index" }),
501
506
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "play/[game]", options: playOptions })
502
- ] });
503
- if (!Backdrop || !navigationTheme) return stack;
507
+ ] }) });
508
+ if (!Backdrop) return stack;
504
509
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_react_native2.View, { style: styles.root, children: [
505
510
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Backdrop, {}),
506
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.ThemeProvider, { value: navigationTheme, children: stack })
511
+ stack
507
512
  ] });
508
513
  }
509
514
  function OrientationBridge({ children }) {
@@ -1172,6 +1177,13 @@ function HomeMenu() {
1172
1177
  const { previewColor } = pack;
1173
1178
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
1174
1179
  const triggerStyle = (0, import_react16.useMemo)(() => pack.Backdrop ? [styles4.pill, { backgroundColor: colors.surface }] : void 0, [pack.Backdrop, colors.surface]);
1180
+ const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null;
1181
+ const [tabLabel, setTabLabel] = (0, import_react16.useState)(() => tabs?.[0].label ?? null);
1182
+ const tab = tabs ? tabs.find((t) => t.label === tabLabel) ?? tabs[0] : null;
1183
+ const tabButtons = (0, import_react16.useMemo)(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs]);
1184
+ const tabBar = tab && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.SegmentedButtons, { value: tab.label, onValueChange: setTabLabel, buttons: tabButtons, style: [styles4.tabs, pack.Backdrop && { backgroundColor: colors.surface }] });
1185
+ const columnClamp = useClampOnShrink();
1186
+ const gridClamp = useClampOnShrink();
1175
1187
  const openGame = (0, import_react16.useCallback)((game) => openFirstTime(game.id, () => import_expo_router6.router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
1176
1188
  const openRules = (0, import_react16.useCallback)((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
1177
1189
  const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: import_auto_paper5.defaultColors, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
@@ -1185,16 +1197,20 @@ function HomeMenu() {
1185
1197
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_core12.FakeLandscapeView, { style: styles4.rotatable, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
1186
1198
  isWide ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
1187
1199
  pressAway,
1188
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_auto_paper5.FadingScrollView, { style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameTile, { game, width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) }) }),
1200
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
1201
+ tabBar && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.wideTabs, { width: layout.rightPaneWidth }], children: tabBar }),
1202
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameTile, { game, hidden: isHidden(tab, game), width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1203
+ ] }),
1189
1204
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
1190
1205
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1191
1206
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.widePicker, children: picker })
1192
1207
  ] })
1193
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1208
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_auto_paper5.FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1194
1209
  pressAway,
1195
1210
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1196
1211
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1197
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameRow, { game, dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1212
+ tabBar && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.listWidth, children: tabBar }),
1213
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameRow, { game, hidden: isHidden(tab, game), dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1198
1214
  ] }),
1199
1215
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_feedback_press3.IconButton, { icon: "trophy", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, left: 8 + insets.left }], onPress: () => import_expo_router6.router.push(STATS_HREF), accessibilityLabel: "Stats & Achievements" }),
1200
1216
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_feedback_press3.IconButton, { icon: "cog", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, right: 8 + insets.right }], onPress: () => setSettingsOpen(true), accessibilityLabel: "Settings" }),
@@ -1205,19 +1221,47 @@ function HomeMenu() {
1205
1221
  }
1206
1222
  var SEAT_ONE_PICKER = "home-p1";
1207
1223
  var noPickerAlign = () => void 0;
1224
+ function isHidden(tab, game) {
1225
+ return !!tab && !tab.includes(game);
1226
+ }
1227
+ function useClampOnShrink() {
1228
+ const view = (0, import_react16.useRef)(null);
1229
+ const sizes = (0, import_react16.useRef)({ viewport: 0, offset: 0 });
1230
+ return (0, import_react16.useMemo)(
1231
+ () => ({
1232
+ ref: (instance) => {
1233
+ view.current = instance;
1234
+ sizes.current.offset = 0;
1235
+ },
1236
+ onLayout: (e) => {
1237
+ sizes.current.viewport = e.nativeEvent.layout.height;
1238
+ },
1239
+ onScroll: (e) => {
1240
+ sizes.current.offset = e.nativeEvent.contentOffset.y;
1241
+ },
1242
+ onContentSizeChange: (_width, height) => {
1243
+ const end = Math.max(0, height - sizes.current.viewport);
1244
+ if (sizes.current.offset <= end) return;
1245
+ sizes.current.offset = end;
1246
+ view.current?.scrollTo({ y: end, animated: false });
1247
+ }
1248
+ }),
1249
+ []
1250
+ );
1251
+ }
1208
1252
  function hasRules(game) {
1209
1253
  return !!(game.Rules || game.guide);
1210
1254
  }
1211
1255
  function RulesHost({ enabled, children }) {
1212
1256
  return enabled ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Portal.Host, { children }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_jsx_runtime14.Fragment, { children });
1213
1257
  }
1214
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1258
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1215
1259
  const well = (0, import_chrome5.useWellColors)();
1216
1260
  const GameIcon = game.icon;
1217
1261
  return (
1218
1262
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1219
1263
  // inside a button is invalid on the web.
1220
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { children: [
1264
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: hidden ? styles4.hidden : void 0, children: [
1221
1265
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1222
1266
  game.Preview ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: GameIcon && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
1223
1267
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: styles4.rowText, children: [
@@ -1229,9 +1273,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1229
1273
  ] })
1230
1274
  );
1231
1275
  }
1232
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1276
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1233
1277
  const well = (0, import_chrome5.useWellColors)();
1234
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: { width }, children: [
1278
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: [{ width }, hidden && styles4.hidden], children: [
1235
1279
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1236
1280
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1237
1281
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
@@ -1267,12 +1311,21 @@ var styles4 = import_react_native6.StyleSheet.create({
1267
1311
  flexWrap: "wrap",
1268
1312
  gap: HOME_GRID_GAP
1269
1313
  },
1314
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
1315
+ hidden: {
1316
+ display: "none"
1317
+ },
1270
1318
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
1271
1319
  list: {
1272
1320
  gap: 14,
1273
1321
  maxWidth: 480,
1274
1322
  width: "100%"
1275
1323
  },
1324
+ // The list's width, for the tabs over it.
1325
+ listWidth: {
1326
+ maxWidth: 480,
1327
+ width: "100%"
1328
+ },
1276
1329
  picker: {
1277
1330
  alignItems: "center"
1278
1331
  },
@@ -1335,6 +1388,10 @@ var styles4 = import_react_native6.StyleSheet.create({
1335
1388
  scroll: {
1336
1389
  flex: 1
1337
1390
  },
1391
+ // SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
1392
+ tabs: {
1393
+ borderRadius: 20
1394
+ },
1338
1395
  // The same well as a row, turned on its side.
1339
1396
  tile: {
1340
1397
  borderRadius: 12,
@@ -1373,6 +1430,10 @@ var styles4 = import_react_native6.StyleSheet.create({
1373
1430
  position: "absolute",
1374
1431
  right: 0,
1375
1432
  top: 0
1433
+ },
1434
+ // The tabs over the grid, a little further from it than the tiles are from each other.
1435
+ wideTabs: {
1436
+ marginBottom: 16
1376
1437
  }
1377
1438
  });
1378
1439
 
package/dist/index.mjs CHANGED
@@ -29,7 +29,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
29
29
  import { RotatedFrame } from "@tastic/hud/chrome";
30
30
  import { ProfileColorProvider } from "@tastic/profile";
31
31
  import * as Haptics from "expo-haptics";
32
- import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
32
+ import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
33
33
  import { DeviceMotion } from "expo-sensors";
34
34
  import { useCallback as useCallback6, useMemo as useMemo3 } from "react";
35
35
  import { Platform as Platform2, StyleSheet, View } from "react-native";
@@ -410,8 +410,9 @@ function AppStack() {
410
410
  const pack = usePack();
411
411
  const { Backdrop } = pack;
412
412
  const { dark } = useAutoPaperTheme();
413
- const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false);
414
- const statusBar = useRotationAwareStatusBar();
413
+ const darkRoot = Backdrop ? true : dark;
414
+ const rootBackground = useThemedRootBackground(darkRoot, false);
415
+ const statusBar = useRotationAwareStatusBar({ style: darkRoot ? "light" : "dark" });
415
416
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
416
417
  const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: Platform2.select({ android: "fade" }) } : void 0;
417
418
  const playOptions = useCallback6(
@@ -421,15 +422,19 @@ function AppStack() {
421
422
  },
422
423
  [pack.games]
423
424
  );
424
- const navigationTheme = useMemo3(() => Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "transparent" } } : null, [Backdrop]);
425
- const stack = /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
425
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
426
+ const navigationTheme = useMemo3(() => {
427
+ const base = darkRoot ? DarkTheme : DefaultTheme;
428
+ return { ...base, colors: { ...base.colors, background: navigationBackground } };
429
+ }, [darkRoot, navigationBackground]);
430
+ const stack = /* @__PURE__ */ jsx9(NavigationThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
426
431
  /* @__PURE__ */ jsx9(Stack.Screen, { name: "index" }),
427
432
  /* @__PURE__ */ jsx9(Stack.Screen, { name: "play/[game]", options: playOptions })
428
- ] });
429
- if (!Backdrop || !navigationTheme) return stack;
433
+ ] }) });
434
+ if (!Backdrop) return stack;
430
435
  return /* @__PURE__ */ jsxs2(View, { style: styles.root, children: [
431
436
  /* @__PURE__ */ jsx9(Backdrop, {}),
432
- /* @__PURE__ */ jsx9(NavigationThemeProvider, { value: navigationTheme, children: stack })
437
+ stack
433
438
  ] });
434
439
  }
435
440
  function OrientationBridge({ children }) {
@@ -963,9 +968,9 @@ import { FakeLandscapeView as FakeLandscapeView2, useRotation as useRotation4 }
963
968
  import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost2, useWellColors, WELL_SHADOW } from "@tastic/hud/chrome";
964
969
  import { getProfilePickerContentSize, ProfilePicker } from "@tastic/profile";
965
970
  import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
966
- import { useCallback as useCallback10, useEffect as useEffect6, useMemo as useMemo6, useState as useState6 } from "react";
971
+ import { useCallback as useCallback10, useEffect as useEffect6, useMemo as useMemo6, useRef as useRef5, useState as useState6 } from "react";
967
972
  import { AppState as AppState2, StyleSheet as StyleSheet4, View as View4 } from "react-native";
968
- import { Icon, Portal, Text } from "react-native-paper";
973
+ import { Icon, Portal, SegmentedButtons, Text } from "react-native-paper";
969
974
 
970
975
  // src/hooks/useFrozenWhileHidden.ts
971
976
  import { useState as useState5 } from "react";
@@ -1098,6 +1103,13 @@ function HomeMenu() {
1098
1103
  const { previewColor } = pack;
1099
1104
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
1100
1105
  const triggerStyle = useMemo6(() => pack.Backdrop ? [styles4.pill, { backgroundColor: colors.surface }] : void 0, [pack.Backdrop, colors.surface]);
1106
+ const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null;
1107
+ const [tabLabel, setTabLabel] = useState6(() => tabs?.[0].label ?? null);
1108
+ const tab = tabs ? tabs.find((t) => t.label === tabLabel) ?? tabs[0] : null;
1109
+ const tabButtons = useMemo6(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs]);
1110
+ const tabBar = tab && /* @__PURE__ */ jsx14(SegmentedButtons, { value: tab.label, onValueChange: setTabLabel, buttons: tabButtons, style: [styles4.tabs, pack.Backdrop && { backgroundColor: colors.surface }] });
1111
+ const columnClamp = useClampOnShrink();
1112
+ const gridClamp = useClampOnShrink();
1101
1113
  const openGame = useCallback10((game) => openFirstTime(game.id, () => router3.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
1102
1114
  const openRules = useCallback10((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
1103
1115
  const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors2, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
@@ -1111,16 +1123,20 @@ function HomeMenu() {
1111
1123
  /* @__PURE__ */ jsx14(FakeLandscapeView2, { style: styles4.rotatable, children: /* @__PURE__ */ jsxs6(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
1112
1124
  isWide ? /* @__PURE__ */ jsxs6(Fragment4, { children: [
1113
1125
  pressAway,
1114
- /* @__PURE__ */ jsx14(FadingScrollView, { style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: /* @__PURE__ */ jsx14(View4, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameTile, { game, width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) }) }),
1126
+ /* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
1127
+ tabBar && /* @__PURE__ */ jsx14(View4, { style: [styles4.wideTabs, { width: layout.rightPaneWidth }], children: tabBar }),
1128
+ /* @__PURE__ */ jsx14(View4, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameTile, { game, hidden: isHidden(tab, game), width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1129
+ ] }),
1115
1130
  /* @__PURE__ */ jsxs6(View4, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
1116
1131
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1117
1132
  /* @__PURE__ */ jsx14(View4, { style: styles4.widePicker, children: picker })
1118
1133
  ] })
1119
- ] }) : /* @__PURE__ */ jsxs6(FadingScrollView, { style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1134
+ ] }) : /* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1120
1135
  pressAway,
1121
1136
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1122
1137
  /* @__PURE__ */ jsx14(View4, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1123
- /* @__PURE__ */ jsx14(View4, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameRow, { game, dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1138
+ tabBar && /* @__PURE__ */ jsx14(View4, { style: styles4.listWidth, children: tabBar }),
1139
+ /* @__PURE__ */ jsx14(View4, { style: styles4.list, children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameRow, { game, hidden: isHidden(tab, game), dark, p1Color, p2Color, previewRevealed: previewsRevealed, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1124
1140
  ] }),
1125
1141
  /* @__PURE__ */ jsx14(IconButton, { icon: "trophy", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, left: 8 + insets.left }], onPress: () => router3.push(STATS_HREF), accessibilityLabel: "Stats & Achievements" }),
1126
1142
  /* @__PURE__ */ jsx14(IconButton, { icon: "cog", iconColor: fg, size: 24, style: [styles4.corner, { top: 8 + insets.top, right: 8 + insets.right }], onPress: () => setSettingsOpen(true), accessibilityLabel: "Settings" }),
@@ -1131,19 +1147,47 @@ function HomeMenu() {
1131
1147
  }
1132
1148
  var SEAT_ONE_PICKER = "home-p1";
1133
1149
  var noPickerAlign = () => void 0;
1150
+ function isHidden(tab, game) {
1151
+ return !!tab && !tab.includes(game);
1152
+ }
1153
+ function useClampOnShrink() {
1154
+ const view = useRef5(null);
1155
+ const sizes = useRef5({ viewport: 0, offset: 0 });
1156
+ return useMemo6(
1157
+ () => ({
1158
+ ref: (instance) => {
1159
+ view.current = instance;
1160
+ sizes.current.offset = 0;
1161
+ },
1162
+ onLayout: (e) => {
1163
+ sizes.current.viewport = e.nativeEvent.layout.height;
1164
+ },
1165
+ onScroll: (e) => {
1166
+ sizes.current.offset = e.nativeEvent.contentOffset.y;
1167
+ },
1168
+ onContentSizeChange: (_width, height) => {
1169
+ const end = Math.max(0, height - sizes.current.viewport);
1170
+ if (sizes.current.offset <= end) return;
1171
+ sizes.current.offset = end;
1172
+ view.current?.scrollTo({ y: end, animated: false });
1173
+ }
1174
+ }),
1175
+ []
1176
+ );
1177
+ }
1134
1178
  function hasRules(game) {
1135
1179
  return !!(game.Rules || game.guide);
1136
1180
  }
1137
1181
  function RulesHost({ enabled, children }) {
1138
1182
  return enabled ? /* @__PURE__ */ jsx14(Portal.Host, { children }) : /* @__PURE__ */ jsx14(Fragment4, { children });
1139
1183
  }
1140
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1184
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1141
1185
  const well = useWellColors();
1142
1186
  const GameIcon = game.icon;
1143
1187
  return (
1144
1188
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1145
1189
  // inside a button is invalid on the web.
1146
- /* @__PURE__ */ jsxs6(View4, { children: [
1190
+ /* @__PURE__ */ jsxs6(View4, { style: hidden ? styles4.hidden : void 0, children: [
1147
1191
  /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1148
1192
  game.Preview ? /* @__PURE__ */ jsx14(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ jsx14(View4, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: GameIcon && /* @__PURE__ */ jsx14(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
1149
1193
  /* @__PURE__ */ jsxs6(View4, { style: styles4.rowText, children: [
@@ -1155,9 +1199,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1155
1199
  ] })
1156
1200
  );
1157
1201
  }
1158
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1202
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1159
1203
  const well = useWellColors();
1160
- return /* @__PURE__ */ jsxs6(View4, { style: { width }, children: [
1204
+ return /* @__PURE__ */ jsxs6(View4, { style: [{ width }, hidden && styles4.hidden], children: [
1161
1205
  /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1162
1206
  /* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1163
1207
  /* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
@@ -1193,12 +1237,21 @@ var styles4 = StyleSheet4.create({
1193
1237
  flexWrap: "wrap",
1194
1238
  gap: HOME_GRID_GAP
1195
1239
  },
1240
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
1241
+ hidden: {
1242
+ display: "none"
1243
+ },
1196
1244
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
1197
1245
  list: {
1198
1246
  gap: 14,
1199
1247
  maxWidth: 480,
1200
1248
  width: "100%"
1201
1249
  },
1250
+ // The list's width, for the tabs over it.
1251
+ listWidth: {
1252
+ maxWidth: 480,
1253
+ width: "100%"
1254
+ },
1202
1255
  picker: {
1203
1256
  alignItems: "center"
1204
1257
  },
@@ -1261,6 +1314,10 @@ var styles4 = StyleSheet4.create({
1261
1314
  scroll: {
1262
1315
  flex: 1
1263
1316
  },
1317
+ // SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
1318
+ tabs: {
1319
+ borderRadius: 20
1320
+ },
1264
1321
  // The same well as a row, turned on its side.
1265
1322
  tile: {
1266
1323
  borderRadius: 12,
@@ -1299,6 +1356,10 @@ var styles4 = StyleSheet4.create({
1299
1356
  position: "absolute",
1300
1357
  right: 0,
1301
1358
  top: 0
1359
+ },
1360
+ // The tabs over the grid, a little further from it than the tiles are from each other.
1361
+ wideTabs: {
1362
+ marginBottom: 16
1302
1363
  }
1303
1364
  });
1304
1365
 
@@ -1324,7 +1385,7 @@ import { BaseStatsScreen, PressAwayOverlay as PressAwayOverlay3, RotatedFrame as
1324
1385
  import { getOptionPickerContentSize as getOptionPickerContentSize2, OptionPicker as OptionPicker2 } from "@tastic/profile";
1325
1386
  import { useMemo as useMemo7, useState as useState7 } from "react";
1326
1387
  import { StyleSheet as StyleSheet5, View as View5 } from "react-native";
1327
- import { SegmentedButtons, Text as Text2 } from "react-native-paper";
1388
+ import { SegmentedButtons as SegmentedButtons2, Text as Text2 } from "react-native-paper";
1328
1389
  import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
1329
1390
  import { useDispatch as useDispatch10 } from "react-redux";
1330
1391
  import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
@@ -1382,7 +1443,7 @@ function StatsContent({ pickerHost }) {
1382
1443
  const PackTabContent = packTab?.Content;
1383
1444
  return /* @__PURE__ */ jsxs7(BaseStatsScreen, { title, onBack: safeBack, insets, onReset: reset.onReset, resetLabel: reset.label, resetConfirmTitle: reset.title, resetConfirmBody: reset.body, fg: pack.foreground, bg: pack.Backdrop ? "transparent" : void 0, rotation: 0, children: [
1384
1445
  /* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
1385
- /* @__PURE__ */ jsx16(SegmentedButtons, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
1446
+ /* @__PURE__ */ jsx16(SegmentedButtons2, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
1386
1447
  /* @__PURE__ */ jsxs7(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1387
1448
  profiles.length > 0 && /* @__PURE__ */ jsx16(OptionPicker2, { id: PLAYER_PICKER_ID, host: pickerHost, options: playerOptions, value: profileId ?? EVERYONE, onChange: (value) => setSelectedProfileId(value === EVERYONE ? null : value), color: colors.primary, dark, icon: profileId ? "account" : "account-group", alignOverride: playerAlign, triggerStyle }),
1388
1449
  showGamePicker && /* @__PURE__ */ jsx16(OptionPicker2, { id: GAME_PICKER_ID, host: pickerHost, options: gameOptions, value: gameId, onChange: setGameId, color: colors.primary, dark, icon: "view-grid-outline", alignOverride: gameAlign, triggerStyle })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The app around a pack of React Native games, two-seat or for one player: Home, the loadout, the stats screen, Profiles and Settings, the pack's Redux store and its root providers, driven by one PackDefinition",
5
5
  "keywords": [
6
6
  "react-native",
@@ -5,7 +5,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
5
5
  import { RotatedFrame } from '@tastic/hud/chrome'
6
6
  import { ProfileColorProvider } from '@tastic/profile'
7
7
  import * as Haptics from 'expo-haptics'
8
- import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
8
+ import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
9
9
  import { DeviceMotion } from 'expo-sensors'
10
10
  import { ReactNode, useCallback, useMemo } from 'react'
11
11
  import { Platform, StyleSheet, View } from 'react-native'
@@ -50,13 +50,16 @@ import type { PackDefinition, PackGameDefinition } from './types'
50
50
  const PLAY_OPTIONS = { statusBarHidden: true }
51
51
  const KEEP_STATUS_BAR_OPTIONS = {}
52
52
 
53
- // A pack's Backdrop (PackDefinition.Backdrop) is drawn once behind the whole stack and has to show
54
- // through two layers: each screen's contentStyle, and the layers React Navigation paints its theme's
55
- // `colors.background` on around every screen (the ScreenStack's container on native, each screen's
56
- // wrapper on the web). contentStyle alone left an opaque light grey slab over Solitaire's felt on the
57
- // web. Nothing else reads the navigation theme (headers are hidden; Paper has its own). Built only for
58
- // a pack with a backdrop, as it renders: never at import, where a pack's tests may stand in for
59
- // expo-router without a DarkTheme.
53
+ // The navigation theme is the root background too. React Navigation paints its theme's
54
+ // `colors.background` on the layers around every screen (the ScreenStack's container on native, each
55
+ // screen's wrapper on the web), and expo-router hands its container no theme, so that was always
56
+ // DefaultTheme's light grey: on iOS it showed in a sliding screen's rounded corners during every push and
57
+ // pop, whatever the app's theme. So the stack always gets a theme whose background is the root
58
+ // background's own color, and a pack's Backdrop (PackDefinition.Backdrop), drawn once behind the whole
59
+ // stack, gets a transparent one so it shows through both layers (contentStyle alone left an opaque light
60
+ // grey slab over Solitaire's felt on the web). Nothing else reads the navigation theme (headers are
61
+ // hidden; Paper has its own). Built as the stack renders, never at import, where a pack's tests may stand
62
+ // in for expo-router without its themes.
60
63
  const TRANSPARENT_CONTENT = { backgroundColor: 'transparent' }
61
64
 
62
65
  // The pack's own screen options (PackDefinition.useScreenOptions), as a hook every pack calls: an
@@ -67,10 +70,13 @@ function AppStack() {
67
70
  const pack = usePack()
68
71
  const { Backdrop } = pack
69
72
  const { dark } = useAutoPaperTheme()
70
- // Over a backdrop the root window stays dark whatever the mode: a backdrop is a dark table (the
71
- // felt), and a light root window would flash white around it during a push or pop.
72
- const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false)
73
- const statusBar = useRotationAwareStatusBar()
73
+ // Over a backdrop the root stays dark whatever the mode: a backdrop is a dark table (the felt), and a
74
+ // light root window would flash white around it during a push or pop.
75
+ const darkRoot = Backdrop ? true : dark
76
+ const rootBackground = useThemedRootBackground(darkRoot, false)
77
+ // The status bar's text follows the app's theme, not the phone's appearance (react-native-screens'
78
+ // default `auto`), which drew dark text on a dark theme's black Home with the phone set to light.
79
+ const statusBar = useRotationAwareStatusBar({ style: darkRoot ? 'light' : 'dark' })
74
80
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
75
81
  // Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
76
82
  // the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
@@ -83,18 +89,24 @@ function AppStack() {
83
89
  },
84
90
  [pack.games]
85
91
  )
86
- const navigationTheme = useMemo(() => (Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: 'transparent' } } : null), [Backdrop])
92
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor
93
+ const navigationTheme = useMemo(() => {
94
+ const base = darkRoot ? DarkTheme : DefaultTheme
95
+ return { ...base, colors: { ...base.colors, background: navigationBackground } }
96
+ }, [darkRoot, navigationBackground])
87
97
  const stack = (
88
- <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
89
- <Stack.Screen name='index' />
90
- <Stack.Screen name='play/[game]' options={playOptions} />
91
- </Stack>
98
+ <NavigationThemeProvider value={navigationTheme}>
99
+ <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
100
+ <Stack.Screen name='index' />
101
+ <Stack.Screen name='play/[game]' options={playOptions} />
102
+ </Stack>
103
+ </NavigationThemeProvider>
92
104
  )
93
- if (!Backdrop || !navigationTheme) return stack
105
+ if (!Backdrop) return stack
94
106
  return (
95
107
  <View style={styles.root}>
96
108
  <Backdrop />
97
- <NavigationThemeProvider value={navigationTheme}>{stack}</NavigationThemeProvider>
109
+ {stack}
98
110
  </View>
99
111
  )
100
112
  }
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 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'
5
+ export { type FeedbackSounds, type GameDefinition, type GameGuide, type GameIconProps, type GameLoadout, type GamePreviewProps, type GameRulesProps, GUEST_PLAYER_ID, type GuidelessGameDefinition, type HeroTitleProps, type HomeTab, 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'
@@ -4,9 +4,9 @@ import { FakeLandscapeView, useRotation } from '@tastic/core'
4
4
  import { PressAwayOverlay, usePopoverHost, useWellColors, WELL_SHADOW } from '@tastic/hud/chrome'
5
5
  import { getProfilePickerContentSize, type Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
6
6
  import { Redirect, router, useIsFocused } from 'expo-router'
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'
7
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
8
+ import { AppState, type AppStateStatus, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollView, StyleSheet, View } from 'react-native'
9
+ import { Icon, Portal, SegmentedButtons, Text } from 'react-native-paper'
10
10
 
11
11
  import { useGameGuide } from '../components/GuideHost'
12
12
  import { SettingsDialog } from '../components/SettingsDialog'
@@ -17,14 +17,15 @@ import { useProfiles } from '../hooks/useProfiles'
17
17
  import { useSeatColors } from '../hooks/useSeatColors'
18
18
  import { usePack } from '../PackContext'
19
19
  import { loadoutHref, playHref, PROFILES_HREF, STATS_HREF } from '../routes'
20
- import type { PackGameDefinition } from '../types'
20
+ import type { HomeTab, PackGameDefinition } from '../types'
21
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'
22
22
 
23
23
  // Home: who's playing (seat 1, the same seat 1 every loadout starts with), then every game in the
24
24
  // pack as a row in a fixed order, Solitaire's list: each game on its own well, its small app icon
25
25
  // (or its preview, GameDefinition.Preview) beside its name and blurb. A tap opens that game's
26
26
  // loadout, or its play screen in a game without one, after its How to Play cards the first time it's
27
- // picked. Seat 2 is chosen in the loadout, from seat 2's own button.
27
+ // picked. Seat 2 is chosen in the loadout, from seat 2's own button. A pack with tabs
28
+ // (PackDefinition.homeTabs) shows one tab's games at a time, from a switch over the rows.
28
29
  //
29
30
  // A cold launch that finds the player left the app inside a resumable game's play screen
30
31
  // (GameDefinition.resumable) goes straight back there instead, before the menu ever paints. The
@@ -111,6 +112,18 @@ function HomeMenu() {
111
112
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color
112
113
  const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: colors.surface }] : undefined), [pack.Backdrop, colors.surface])
113
114
 
115
+ // The pack's tabs (PackDefinition.homeTabs): one tab's games at a time, the same switch as the stats
116
+ // screen's tabs (on the theme's surface over a backdrop). The other tabs' games stay mounted, hidden, so
117
+ // a switch draws nothing new (their previews are already there). The tab is Home's own state, kept while
118
+ // Home is covered, since Home stays mounted under every screen it opens.
119
+ const tabs = pack.homeTabs && pack.homeTabs.length > 1 ? pack.homeTabs : null
120
+ const [tabLabel, setTabLabel] = useState(() => tabs?.[0].label ?? null)
121
+ const tab = tabs ? (tabs.find((t) => t.label === tabLabel) ?? tabs[0]) : null
122
+ const tabButtons = useMemo(() => tabs?.map(({ label }) => ({ value: label, label })) ?? [], [tabs])
123
+ const tabBar = tab && <SegmentedButtons value={tab.label} onValueChange={setTabLabel} buttons={tabButtons} style={[styles.tabs, pack.Backdrop && { backgroundColor: colors.surface }]} />
124
+ const columnClamp = useClampOnShrink()
125
+ const gridClamp = useClampOnShrink()
126
+
114
127
  // A game with nothing to set up (no useLoadout) opens straight to its play screen.
115
128
  const openGame = useCallback((game: PackGameDefinition) => openFirstTime(game.id, () => router.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime])
116
129
 
@@ -149,10 +162,11 @@ function HomeMenu() {
149
162
  to the frame's right edge and the cog's gutter is its content's padding, so a web scrollbar
150
163
  sits at the window's edge. Both scrollers fade out at an edge while there is more past it
151
164
  (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 }}>
165
+ <FadingScrollView {...(tab && gridClamp)} style={[styles.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }]} contentContainerStyle={{ paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }}>
166
+ {tabBar && <View style={[styles.wideTabs, { width: layout.rightPaneWidth }]}>{tabBar}</View>}
153
167
  <View style={[styles.grid, { width: layout.rightPaneWidth }]}>
154
168
  {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} />
169
+ <GameTile key={game.id} game={game} hidden={isHidden(tab, 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
170
  ))}
157
171
  </View>
158
172
  </FadingScrollView>
@@ -165,7 +179,7 @@ function HomeMenu() {
165
179
  </View>
166
180
  </>
167
181
  ) : (
168
- <FadingScrollView style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
182
+ <FadingScrollView {...(tab && columnClamp)} style={styles.scroll} contentContainerStyle={[styles.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }]}>
169
183
  {pressAway}
170
184
  <pack.HeroTitle words={pack.wordmark} color={fg} p1Color={p1Color} p2Color={p2Color} active={heroActive} />
171
185
 
@@ -173,9 +187,11 @@ function HomeMenu() {
173
187
  tiles while its menu is open, so the menu paints over them. */}
174
188
  <View style={[styles.picker, pickerHost.openId !== null && styles.pickerOpen]}>{picker}</View>
175
189
 
190
+ {tabBar && <View style={styles.listWidth}>{tabBar}</View>}
191
+
176
192
  <View style={styles.list}>
177
193
  {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} />
194
+ <GameRow key={game.id} game={game} hidden={isHidden(tab, game)} dark={dark} p1Color={p1Color} p2Color={p2Color} previewRevealed={previewsRevealed} onPress={() => openGame(game)} onRules={hasRules(game) ? () => openRules(game) : undefined} />
179
195
  ))}
180
196
  </View>
181
197
  </FadingScrollView>
@@ -200,6 +216,43 @@ const SEAT_ONE_PICKER = 'home-p1'
200
216
  // No placement of the pack's own: hud places the menu itself.
201
217
  const noPickerAlign = () => undefined
202
218
 
219
+ // A game the tab picked doesn't include: kept mounted, out of the layout and out of a screen reader's way.
220
+ function isHidden(tab: HomeTab | null, game: PackGameDefinition): boolean {
221
+ return !!tab && !tab.includes(game)
222
+ }
223
+
224
+ // iOS leaves a scroller's offset past the end when its content shrinks (React Native's own TODO, T190695447
225
+ // in RCTScrollViewComponentView.mm), so a switch to a shorter tab while scrolled down would leave blank space
226
+ // under the last game until the next drag. This pulls it back in, as Android and the web do by themselves.
227
+ // Spread onto one scroller (its ref and three events) only in a pack with tabs, the one way Home's content
228
+ // shrinks. The ref is a callback so a scroller mounted afresh (a switch between the column and the grid)
229
+ // starts from its real offset, 0, not the last one's.
230
+ function useClampOnShrink() {
231
+ const view = useRef<ScrollView | null>(null)
232
+ const sizes = useRef({ viewport: 0, offset: 0 })
233
+ return useMemo(
234
+ () => ({
235
+ ref: (instance: ScrollView | null) => {
236
+ view.current = instance
237
+ sizes.current.offset = 0
238
+ },
239
+ onLayout: (e: LayoutChangeEvent) => {
240
+ sizes.current.viewport = e.nativeEvent.layout.height
241
+ },
242
+ onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
243
+ sizes.current.offset = e.nativeEvent.contentOffset.y
244
+ },
245
+ onContentSizeChange: (_width: number, height: number) => {
246
+ const end = Math.max(0, height - sizes.current.viewport)
247
+ if (sizes.current.offset <= end) return
248
+ sizes.current.offset = end
249
+ view.current?.scrollTo({ y: end, animated: false })
250
+ }
251
+ }),
252
+ []
253
+ )
254
+ }
255
+
203
256
  // Whether a tile has a rules button: a rules sheet or How to Play cards to show.
204
257
  function hasRules(game: PackGameDefinition): boolean {
205
258
  return !!(game.Rules || game.guide)
@@ -214,6 +267,8 @@ function RulesHost({ enabled, children }: { enabled: boolean; children: ReactNod
214
267
 
215
268
  interface GameRowProps {
216
269
  game: PackGameDefinition
270
+ // Not in the tab picked (PackDefinition.homeTabs).
271
+ hidden: boolean
217
272
  dark: boolean
218
273
  p1Color: string
219
274
  p2Color: string
@@ -227,13 +282,13 @@ interface GameRowProps {
227
282
  // One game on its own well: its icon at app-icon size (the art was drawn as an app icon), or its
228
283
  // preview where it has one, its name and one-line blurb, and a rules button at the end that shows its
229
284
  // How to Play cards without opening the game.
230
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
285
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameRowProps) {
231
286
  const well = useWellColors()
232
287
  const GameIcon = game.icon
233
288
  return (
234
289
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
235
290
  // inside a button is invalid on the web.
236
- <View>
291
+ <View style={hidden ? styles.hidden : undefined}>
237
292
  <Pressable onPress={onPress} style={[styles.row, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
238
293
  {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>}
239
294
  <View style={styles.rowText}>
@@ -262,10 +317,10 @@ interface GameTileProps extends GameRowProps {
262
317
  // One game in the wide layout's grid: the row turned on its side, its preview (or icon) on top and its
263
318
  // name and blurb under it, since a row needs ~290pt, more than a grid column beside the hero has. The
264
319
  // rules button floats over the preview's corner, beside the tile's button rather than inside it.
265
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
320
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }: GameTileProps) {
266
321
  const well = useWellColors()
267
322
  return (
268
- <View style={{ width }}>
323
+ <View style={[{ width }, hidden && styles.hidden]}>
269
324
  <Pressable onPress={onPress} style={[styles.tile, { backgroundColor: well.bg }]} accessibilityRole='button' accessibilityLabel={game.title}>
270
325
  <PreviewBox game={game} width={previewWidth} revealed={previewRevealed} dark={dark} p1Color={p1Color} p2Color={p2Color} />
271
326
  <Text variant='titleMedium' style={[styles.rowTitle, { color: well.fg }]} numberOfLines={1}>
@@ -337,12 +392,21 @@ const styles = StyleSheet.create({
337
392
  flexWrap: 'wrap',
338
393
  gap: HOME_GRID_GAP
339
394
  },
395
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
396
+ hidden: {
397
+ display: 'none'
398
+ },
340
399
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
341
400
  list: {
342
401
  gap: 14,
343
402
  maxWidth: 480,
344
403
  width: '100%'
345
404
  },
405
+ // The list's width, for the tabs over it.
406
+ listWidth: {
407
+ maxWidth: 480,
408
+ width: '100%'
409
+ },
346
410
  picker: {
347
411
  alignItems: 'center'
348
412
  },
@@ -405,6 +469,10 @@ const styles = StyleSheet.create({
405
469
  scroll: {
406
470
  flex: 1
407
471
  },
472
+ // SegmentedButtons' own rounding, so a surface behind it is the same pill (the stats screen's tabs).
473
+ tabs: {
474
+ borderRadius: 20
475
+ },
408
476
  // The same well as a row, turned on its side.
409
477
  tile: {
410
478
  borderRadius: 12,
@@ -443,5 +511,9 @@ const styles = StyleSheet.create({
443
511
  position: 'absolute',
444
512
  right: 0,
445
513
  top: 0
514
+ },
515
+ // The tabs over the grid, a little further from it than the tiles are from each other.
516
+ wideTabs: {
517
+ marginBottom: 16
446
518
  }
447
519
  })
package/src/types.ts CHANGED
@@ -225,6 +225,13 @@ export interface PackStatsSectionsProps {
225
225
  gameId: string | null
226
226
  }
227
227
 
228
+ // A tab on Home (PackDefinition.homeTabs): its label, and which of the pack's games it shows, in the
229
+ // pack's order (Solitaire's: the games with no seat 2, and the ones with one).
230
+ export interface HomeTab {
231
+ label: string
232
+ includes: (game: PackGameDefinition) => boolean
233
+ }
234
+
228
235
  // The stack's screen options (expo-router's Stack), as an object: what PackDefinition.useScreenOptions
229
236
  // returns.
230
237
  export type StackScreenOptions = Exclude<NonNullable<ComponentProps<typeof Stack>['screenOptions']>, (...args: never[]) => unknown>
@@ -260,6 +267,10 @@ export interface PackDefinition {
260
267
  // A seat's color pick for a profile is kept for this pack only; the seat button says so.
261
268
  colorCaption: string
262
269
  games: readonly PackGameDefinition[]
270
+ // Home splits the games into these tabs, a switch under seat 1's button (over the grid on a wide
271
+ // screen) showing one tab's games at a time. It opens on the first, and keeps the tab picked while Home
272
+ // is covered. Unset, or a single tab, shows every game with no switch.
273
+ homeTabs?: readonly HomeTab[]
263
274
  // Player 1's and player 2's own default colors. They seed the theme's primary and secondary (the
264
275
  // tertiary is derived from them, see shellState.ts) and the guest and CPU colors a fresh install
265
276
  // starts with (seatColorsSlice.ts), so a fresh install's live theme and its remembered colors agree.