@tastic/shell 0.3.1 → 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
@@ -22,7 +22,7 @@ package ecosystem, on [`@tastic/hud`](https://github.com/jayrdeaton/react-native
22
22
  own theme, not the phone's light or dark appearance: the status bar's text, the root window and the
23
23
  navigation theme's background (what shows in a sliding screen's corners during a push or pop).
24
24
  - **Screens** for the routes: `HomeScreen` (the pack's hero, seat 1's button, a row per game, or a
25
- 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]`,
26
26
  `play/[game]`), `ProfilesRoute` and `StatsScreen`.
27
27
  - **The store**: `createPackStore(pack, { keyPrefix, migrate, blacklist, serializableIgnoredPaths,
28
28
  middleware })` builds one persisted store from the shell's keys (theme, haptics, sound, Keep Awake,
@@ -130,6 +130,7 @@ PuckTastic and DuelTastic draw what they always did:
130
130
  | `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
131
131
  | `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
132
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. |
133
134
 
134
135
  ### `GameDefinition`
135
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
@@ -1177,6 +1177,13 @@ function HomeMenu() {
1177
1177
  const { previewColor } = pack;
1178
1178
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
1179
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();
1180
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]);
1181
1188
  const openRules = (0, import_react16.useCallback)((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
1182
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 };
@@ -1190,16 +1197,20 @@ function HomeMenu() {
1190
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: [
1191
1198
  isWide ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
1192
1199
  pressAway,
1193
- /* @__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
+ ] }),
1194
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: [
1195
1205
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1196
1206
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.widePicker, children: picker })
1197
1207
  ] })
1198
- ] }) : /* @__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: [
1199
1209
  pressAway,
1200
1210
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1201
1211
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1202
- /* @__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)) })
1203
1214
  ] }),
1204
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" }),
1205
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" }),
@@ -1210,19 +1221,47 @@ function HomeMenu() {
1210
1221
  }
1211
1222
  var SEAT_ONE_PICKER = "home-p1";
1212
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
+ }
1213
1252
  function hasRules(game) {
1214
1253
  return !!(game.Rules || game.guide);
1215
1254
  }
1216
1255
  function RulesHost({ enabled, children }) {
1217
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 });
1218
1257
  }
1219
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1258
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1220
1259
  const well = (0, import_chrome5.useWellColors)();
1221
1260
  const GameIcon = game.icon;
1222
1261
  return (
1223
1262
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1224
1263
  // inside a button is invalid on the web.
1225
- /* @__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: [
1226
1265
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1227
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 }) }),
1228
1267
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: styles4.rowText, children: [
@@ -1234,9 +1273,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1234
1273
  ] })
1235
1274
  );
1236
1275
  }
1237
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1276
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1238
1277
  const well = (0, import_chrome5.useWellColors)();
1239
- 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: [
1240
1279
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1241
1280
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1242
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 }),
@@ -1272,12 +1311,21 @@ var styles4 = import_react_native6.StyleSheet.create({
1272
1311
  flexWrap: "wrap",
1273
1312
  gap: HOME_GRID_GAP
1274
1313
  },
1314
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
1315
+ hidden: {
1316
+ display: "none"
1317
+ },
1275
1318
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
1276
1319
  list: {
1277
1320
  gap: 14,
1278
1321
  maxWidth: 480,
1279
1322
  width: "100%"
1280
1323
  },
1324
+ // The list's width, for the tabs over it.
1325
+ listWidth: {
1326
+ maxWidth: 480,
1327
+ width: "100%"
1328
+ },
1281
1329
  picker: {
1282
1330
  alignItems: "center"
1283
1331
  },
@@ -1340,6 +1388,10 @@ var styles4 = import_react_native6.StyleSheet.create({
1340
1388
  scroll: {
1341
1389
  flex: 1
1342
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
+ },
1343
1395
  // The same well as a row, turned on its side.
1344
1396
  tile: {
1345
1397
  borderRadius: 12,
@@ -1378,6 +1430,10 @@ var styles4 = import_react_native6.StyleSheet.create({
1378
1430
  position: "absolute",
1379
1431
  right: 0,
1380
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
1381
1437
  }
1382
1438
  });
1383
1439
 
package/dist/index.mjs CHANGED
@@ -968,9 +968,9 @@ import { FakeLandscapeView as FakeLandscapeView2, useRotation as useRotation4 }
968
968
  import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost2, useWellColors, WELL_SHADOW } from "@tastic/hud/chrome";
969
969
  import { getProfilePickerContentSize, ProfilePicker } from "@tastic/profile";
970
970
  import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
971
- 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";
972
972
  import { AppState as AppState2, StyleSheet as StyleSheet4, View as View4 } from "react-native";
973
- import { Icon, Portal, Text } from "react-native-paper";
973
+ import { Icon, Portal, SegmentedButtons, Text } from "react-native-paper";
974
974
 
975
975
  // src/hooks/useFrozenWhileHidden.ts
976
976
  import { useState as useState5 } from "react";
@@ -1103,6 +1103,13 @@ function HomeMenu() {
1103
1103
  const { previewColor } = pack;
1104
1104
  const p1Drawn = previewColor ? previewColor(p1Color, dark) : p1Color;
1105
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();
1106
1113
  const openGame = useCallback10((game) => openFirstTime(game.id, () => router3.push(game.useLoadout ? loadoutHref(game.id) : playHref(game.id))), [openFirstTime]);
1107
1114
  const openRules = useCallback10((game) => game.Rules ? setRulesGame(game) : openFor(game.id), [openFor]);
1108
1115
  const colorSection = { value: p1Color, onChange: handleP1ColorChange, swatches: defaultColors2, caption: selectedProfile ? pack.colorCaption : void 0, previewValue: previewColor ? (hex) => previewColor(hex, dark) : void 0 };
@@ -1116,16 +1123,20 @@ function HomeMenu() {
1116
1123
  /* @__PURE__ */ jsx14(FakeLandscapeView2, { style: styles4.rotatable, children: /* @__PURE__ */ jsxs6(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
1117
1124
  isWide ? /* @__PURE__ */ jsxs6(Fragment4, { children: [
1118
1125
  pressAway,
1119
- /* @__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
+ ] }),
1120
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: [
1121
1131
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1122
1132
  /* @__PURE__ */ jsx14(View4, { style: styles4.widePicker, children: picker })
1123
1133
  ] })
1124
- ] }) : /* @__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: [
1125
1135
  pressAway,
1126
1136
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1127
1137
  /* @__PURE__ */ jsx14(View4, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1128
- /* @__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)) })
1129
1140
  ] }),
1130
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" }),
1131
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" }),
@@ -1136,19 +1147,47 @@ function HomeMenu() {
1136
1147
  }
1137
1148
  var SEAT_ONE_PICKER = "home-p1";
1138
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
+ }
1139
1178
  function hasRules(game) {
1140
1179
  return !!(game.Rules || game.guide);
1141
1180
  }
1142
1181
  function RulesHost({ enabled, children }) {
1143
1182
  return enabled ? /* @__PURE__ */ jsx14(Portal.Host, { children }) : /* @__PURE__ */ jsx14(Fragment4, { children });
1144
1183
  }
1145
- function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1184
+ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1146
1185
  const well = useWellColors();
1147
1186
  const GameIcon = game.icon;
1148
1187
  return (
1149
1188
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1150
1189
  // inside a button is invalid on the web.
1151
- /* @__PURE__ */ jsxs6(View4, { children: [
1190
+ /* @__PURE__ */ jsxs6(View4, { style: hidden ? styles4.hidden : void 0, children: [
1152
1191
  /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1153
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 }) }),
1154
1193
  /* @__PURE__ */ jsxs6(View4, { style: styles4.rowText, children: [
@@ -1160,9 +1199,9 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1160
1199
  ] })
1161
1200
  );
1162
1201
  }
1163
- function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1202
+ function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1164
1203
  const well = useWellColors();
1165
- return /* @__PURE__ */ jsxs6(View4, { style: { width }, children: [
1204
+ return /* @__PURE__ */ jsxs6(View4, { style: [{ width }, hidden && styles4.hidden], children: [
1166
1205
  /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1167
1206
  /* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1168
1207
  /* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
@@ -1198,12 +1237,21 @@ var styles4 = StyleSheet4.create({
1198
1237
  flexWrap: "wrap",
1199
1238
  gap: HOME_GRID_GAP
1200
1239
  },
1240
+ // Out of the layout (a grid closes up around it) and hidden from screen readers.
1241
+ hidden: {
1242
+ display: "none"
1243
+ },
1201
1244
  // Solitaire's list: one column, never wider than a phone's, centred on a wide screen.
1202
1245
  list: {
1203
1246
  gap: 14,
1204
1247
  maxWidth: 480,
1205
1248
  width: "100%"
1206
1249
  },
1250
+ // The list's width, for the tabs over it.
1251
+ listWidth: {
1252
+ maxWidth: 480,
1253
+ width: "100%"
1254
+ },
1207
1255
  picker: {
1208
1256
  alignItems: "center"
1209
1257
  },
@@ -1266,6 +1314,10 @@ var styles4 = StyleSheet4.create({
1266
1314
  scroll: {
1267
1315
  flex: 1
1268
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
+ },
1269
1321
  // The same well as a row, turned on its side.
1270
1322
  tile: {
1271
1323
  borderRadius: 12,
@@ -1304,6 +1356,10 @@ var styles4 = StyleSheet4.create({
1304
1356
  position: "absolute",
1305
1357
  right: 0,
1306
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
1307
1363
  }
1308
1364
  });
1309
1365
 
@@ -1329,7 +1385,7 @@ import { BaseStatsScreen, PressAwayOverlay as PressAwayOverlay3, RotatedFrame as
1329
1385
  import { getOptionPickerContentSize as getOptionPickerContentSize2, OptionPicker as OptionPicker2 } from "@tastic/profile";
1330
1386
  import { useMemo as useMemo7, useState as useState7 } from "react";
1331
1387
  import { StyleSheet as StyleSheet5, View as View5 } from "react-native";
1332
- import { SegmentedButtons, Text as Text2 } from "react-native-paper";
1388
+ import { SegmentedButtons as SegmentedButtons2, Text as Text2 } from "react-native-paper";
1333
1389
  import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
1334
1390
  import { useDispatch as useDispatch10 } from "react-redux";
1335
1391
  import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
@@ -1387,7 +1443,7 @@ function StatsContent({ pickerHost }) {
1387
1443
  const PackTabContent = packTab?.Content;
1388
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: [
1389
1445
  /* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
1390
- /* @__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] }),
1391
1447
  /* @__PURE__ */ jsxs7(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1392
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 }),
1393
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.1",
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",
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.