@tastic/shell 0.2.0 → 0.3.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
@@ -109,8 +109,8 @@ PuckTastic and DuelTastic draw what they always did:
109
109
 
110
110
  | Field | |
111
111
  | --- | --- |
112
- | `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, the stats screen and Profiles draw transparent, with their buttons and tabs on the theme's surface. |
113
- | `foreground?` | The color of what those three screens draw straight on the background (the hero, the corner buttons, headers). Unset is black or white with the theme. |
112
+ | `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
+ | `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
114
  | `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
115
  | `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a light status bar, Reduce Motion's crossfade). |
116
116
  | `useFeedbackSounds?` | A hook returning the two sounds every button plays (`FeedbackSounds`: `selection`, `notification`), in place of the shell's two wavs. |
@@ -118,16 +118,16 @@ PuckTastic and DuelTastic draw what they always did:
118
118
  | `portraitWhileKeyboard?` | Every screen reads upright while the software keyboard is up (`@tastic/core`'s `OrientationProvider`), for naming a profile sideways. |
119
119
  | `edgeGuard?` | `false` leaves Edge Guard out of Settings (no edge controls, no Edge Guard plugin). |
120
120
  | `seatTheme?` | `(colors, dark) => ({ color, harmony })`: the theme the seats' colors make (a muted seed from seat 1's color), applied whenever they or dark mode change and as a fresh install's theme, in place of a triad of the raw colors. With it, `useSeatColors` reads each seat's color from where it's remembered (`seatColors`) rather than from the theme. |
121
- | `previewColor?` | `(hex, dark) => string`: how every profile color is drawn (seat 1's button and swatches, each `ProfileChip`), mounted as `@tastic/profile`'s `ProfileColorProvider`. Saved colors stay as picked. |
121
+ | `previewColor?` | `(hex, dark) => string`: how every profile color is drawn (the seat buttons and their swatches on Home and in the loadouts, a loadout's Ready button and seat controls, each `ProfileChip`), mounted as `@tastic/profile`'s `ProfileColorProvider`. Saved colors stay as picked. |
122
122
  | `guide?` | The pack's own How to Play (`PackGuide`: `version`, `selectVersionSeen`, `markSeen`, `getSteps({ touch })`), for cards that teach every game at once: shown on Home's first launch (never while a cold launch resumes a game), replayed from Home's Settings, never in a play screen. Its seen version is a key of the pack's own. |
123
- | `guestLabel?`, `nullLabel?` | Seat 1's button: the chip's label for a guest (default `'P1'`) and the guest row's in its menu. |
123
+ | `guestLabel?`, `nullLabel?` | Seat 1's button on Home and in every loadout: the chip's label for a guest (default `'P1'`) and the guest row's in its menu. |
124
124
  | `guestStats?` | The stats screen keeps a guest's stats under `GUEST_PLAYER_ID` (`'guest'`): a Guest entry between Everyone and the profiles once a profile exists, and the screen opens on seat 1's player. Without it the screen opens on Everyone, where guests count. |
125
125
  | `AchievementList?` | One achievement list across the pack's games, on the Achievements tab, with the game picker hidden there. |
126
126
  | `statsTabs?` | Tabs of the pack's own between Stats and Achievements (`PackStatsTab`: `value`, `label`, `Content`, handed both filters). With them every tab is as wide as its label, with no icons, so three fit a phone. |
127
127
  | `resetStats?` | The stats screen's Reset where the pack resets stats itself (`PackStatsReset`: a `useReset` hook returning `(profileId) => void`, and the `labels` for a player's name, Guest, or `null` for Everyone). It follows the player picker, not the game picker. |
128
128
  | `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
129
129
  | `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
130
- | `usePickerAlign?` | `(open, contentWidth, contentHeight, options?) => AlignResult` (`PickerAlign`), a hook: where seat 1's menu on Home, 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. |
130
+ | `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. |
131
131
 
132
132
  ### `GameDefinition`
133
133
 
@@ -204,7 +204,7 @@ became a package (the lower of the two where they differ).
204
204
  | `@rific/feedback-press` | >=0.13.1 | haptic and sound settings, the UI sounds |
205
205
  | `@rific/toaster` | >=0.10.1 | toasts (DuelTastic runs 0.12, PuckTastic 0.10) |
206
206
  | `@tastic/core` | >=0.14.0 | orientation, rotation, the status bar and root background |
207
- | `@tastic/hud` | >=0.26.0 | Settings, the stats screen, the loadout's panels (its Skia-free `/lobby` entry, new in 0.26.0), How to Play |
207
+ | `@tastic/hud` | >=0.27.1 | Settings, the stats screen, the loadout's panels (its Skia-free `/lobby` entry, new in 0.26.0; 0.27.1's options for a pack drawn on its own backdrop), How to Play |
208
208
  | `@tastic/profile` | >=0.16.2 | profiles, the seat buttons, seat colors |
209
209
  | `@tastic/achievements` | >=0.6.0 | `createGameStats`, `forgetProfileAchievements` |
210
210
  | `@tastic/split-screen` | >=0.5.5 | the 2 Player loadout's two halves |
package/dist/index.js CHANGED
@@ -557,6 +557,7 @@ var styles = import_react_native2.StyleSheet.create({
557
557
  // src/screens/GameRoute.tsx
558
558
  var import_expo_router5 = require("expo-router");
559
559
  var import_react14 = require("react");
560
+ var import_react_native5 = require("react-native");
560
561
 
561
562
  // src/routes.ts
562
563
  var playHref = (game) => ({ pathname: "/play/[game]", params: { game } });
@@ -593,8 +594,9 @@ var CPU_ICON = "robot";
593
594
  function getSeatTwoPopoverId(idPrefix) {
594
595
  return `${idPrefix}-seat`;
595
596
  }
596
- function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }) {
597
+ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true, previewValue, triggerStyle, usePickerAlign }) {
597
598
  const isPerson = seatTwo === "person";
599
+ const drawn = previewValue ? previewValue(color) : color;
598
600
  const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
599
601
  const cpuOptions = cpuDifficulty?.options;
600
602
  const options = (0, import_react10.useMemo)(() => {
@@ -603,10 +605,10 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
603
605
  if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
604
606
  if (choices.includes("person")) {
605
607
  list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
606
- list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: "account", color: p.color })));
608
+ list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: "account", color: previewValue ? previewValue(p.color) : p.color })));
607
609
  }
608
610
  return list;
609
- }, [choices, cpuOptions, profiles]);
611
+ }, [choices, cpuOptions, profiles, previewValue]);
610
612
  const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
611
613
  const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
612
614
  const handleChange = (next) => {
@@ -620,16 +622,17 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
620
622
  onSeatTwoChange("person");
621
623
  }
622
624
  };
623
- const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0 };
625
+ const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0, previewValue };
624
626
  const pickerId = getSeatTwoPopoverId(idPrefix);
625
627
  const size = (0, import_profile4.getOptionPickerContentSize)(options.length, colorSection);
626
- const align = (0, import_lobby.useZoneClampedAlign)(host.openId === pickerId, size.width, size.height);
628
+ const useAlign = usePickerAlign ?? import_lobby.useZoneClampedAlign;
629
+ const align = useAlign(host.openId === pickerId, size.width, size.height);
627
630
  const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
628
631
  const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
629
632
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_react_native3.View, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
630
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_profile4.OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color, dark, icon, colorSection, alignOverride: align }),
633
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_profile4.OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }),
631
634
  isPerson && secondPicker && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native3.View, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
632
- isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lobby.ReadyButton, { color, ready: ready ?? false, onToggleReady })
635
+ isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lobby.ReadyButton, { color: drawn, ready: ready ?? false, onToggleReady })
633
636
  ] });
634
637
  }
635
638
  var styles2 = import_react_native3.StyleSheet.create({
@@ -869,13 +872,20 @@ function LoadoutScreen({ game }) {
869
872
  dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }));
870
873
  import_expo_router4.router.push(playHref(game.id));
871
874
  }, [ready, humanSeats, game.id, mode, dispatch]);
872
- const bg = dark ? "#000000" : "#FFFFFF";
873
- const fg = dark ? "#FFFFFF" : "#000000";
874
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
875
+ const bg = pack.Backdrop ? "transparent" : dark ? "#000000" : "#FFFFFF";
876
+ const fg = pack.foreground ?? (dark ? "#FFFFFF" : "#000000");
877
+ const fgMuted = (pack.foreground && halfOf(pack.foreground)) ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
878
+ const { previewColor } = pack;
879
+ const seatPreview = (0, import_react13.useMemo)(() => previewColor ? (hex) => previewColor(hex, dark) : void 0, [previewColor, dark]);
880
+ const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color;
881
+ const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color;
882
+ const triggerStyle = (0, import_react13.useMemo)(() => pack.Backdrop ? [styles3.pill, { backgroundColor: themeColors.surface }] : void 0, [pack.Backdrop, themeColors.surface]);
875
883
  const isPortrait = panelLayout.orientationMode === "faceToFace";
876
884
  const showMetaInSharedBand = (0, import_split_screen.needsSharedNeutralZone)(panelLayout.orientationMode, humanSeats.length);
877
885
  const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : panelLayout.p1OnRight ? styles3.p2ZoneLeft : styles3.p2ZoneRight : null;
878
886
  const showReadyButton = p2IsHuman;
887
+ const seatPickerAlign = p2IsHuman ? void 0 : pack.usePickerAlign;
888
+ const layoutKey = p2IsHuman ? "zones" : "row";
879
889
  const controlsHost = (0, import_chrome4.usePopoverHost)();
880
890
  const p2PanelHost = (0, import_chrome4.usePopoverHost)();
881
891
  const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
@@ -894,10 +904,10 @@ function LoadoutScreen({ game }) {
894
904
  const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId);
895
905
  const sharedControls = showMetaInSharedBand ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lobby2.SharedActionBand, { onBack: import_core8.safeBack, onSettings: () => setSettingsOpen(true), onRandomize: loadout.randomize, onReset: loadout.reset, fg, popoverOpen: optionsPopoverOpen, children: optionsRow }) : optionsRow;
896
906
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation };
897
- const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color });
898
- const p1Panel = /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lobby2.PlayerSetupPanel, { idPrefix: "p1", host: controlsHost, color: p1Color, onColorChange: handleP1ColorChange, swatches: import_auto_paper4.defaultColors, takenColor: p2Exists ? p2Color : void 0, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, isHuman: true, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), dark, showReadyButton, secondPicker: p1Control, secondPickerId: p1Control ? "p1-control" : void 0, profiles, selectedProfileId: lastSelected[1], takenProfileId: p2IsHuman ? lastSelected[2] : null, guestLabel: "P1", onProfileSelect: handleP1ProfileSelect, onManageProfiles: () => import_expo_router4.router.push(PROFILES_HREF) });
899
- const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : void 0;
900
- const p2Panel = game.seatTwo && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(SeatTwoPanel, { idPrefix: "p2", host: p2Host, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, profiles, selectedProfileId: lastSelected[2], onProfileSelect: handleP2ProfileSelect, color: p2Color, onColorChange: handleP2ColorChange, swatches: import_auto_paper4.defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, dark, secondPicker: p2Control, secondPickerId: p2Control ? "p2-control" : void 0, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), showReadyButton });
907
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn });
908
+ const p1Panel = /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lobby2.PlayerSetupPanel, { idPrefix: "p1", host: controlsHost, color: p1Color, onColorChange: handleP1ColorChange, swatches: import_auto_paper4.defaultColors, takenColor: p2Exists ? p2Color : void 0, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, isHuman: true, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), dark, showReadyButton, secondPicker: p1Control, secondPickerId: p1Control ? "p1-control" : void 0, profiles, selectedProfileId: lastSelected[1], takenProfileId: p2IsHuman ? lastSelected[2] : null, guestLabel: pack.guestLabel ?? "P1", nullLabel: pack.nullLabel, onProfileSelect: handleP1ProfileSelect, onManageProfiles: () => import_expo_router4.router.push(PROFILES_HREF), previewValue: seatPreview, triggerStyle, useSeatPickerAlign: seatPickerAlign }, `p1-${layoutKey}`);
909
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : void 0;
910
+ const p2Panel = game.seatTwo && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(SeatTwoPanel, { idPrefix: "p2", host: p2Host, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, profiles, selectedProfileId: lastSelected[2], onProfileSelect: handleP2ProfileSelect, color: p2Color, onColorChange: handleP2ColorChange, swatches: import_auto_paper4.defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, dark, secondPicker: p2Control, secondPickerId: p2Control ? "p2-control" : void 0, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), showReadyButton, previewValue: seatPreview, triggerStyle, usePickerAlign: seatPickerAlign }, `p2-${layoutKey}`);
901
911
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native4.View, { style: [styles3.container, { backgroundColor: bg }], children: [
902
912
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_core10.FakeLandscapeView, { orientationMode: panelLayout.orientationMode, p1OnRight: panelLayout.p1OnRight, upsideDown: panelLayout.upsideDown, style: styles3.rotatable, children: [
903
913
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_chrome4.PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
@@ -921,12 +931,16 @@ function LoadoutScreen({ game }) {
921
931
  !isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel,
922
932
  !isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel
923
933
  ] }),
924
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lobby2.ReadyButton, { color: p1Color, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
934
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_lobby2.ReadyButton, { color: p1Drawn, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
925
935
  ] })
926
936
  ] }),
927
937
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation, onShowHowToPlay: () => openFor(game.id) })
928
938
  ] });
929
939
  }
940
+ function halfOf(color) {
941
+ const rgb = (0, import_auto_paper4.getRgb)(color);
942
+ return rgb ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.5)` : void 0;
943
+ }
930
944
  var styles3 = import_react_native4.StyleSheet.create({
931
945
  container: {
932
946
  flex: 1
@@ -952,6 +966,10 @@ var styles3 = import_react_native4.StyleSheet.create({
952
966
  right: 0,
953
967
  top: 0
954
968
  },
969
+ // A seat button's trigger on a surface over the pack's backdrop, as Home's.
970
+ pill: {
971
+ borderRadius: 999
972
+ },
955
973
  // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
956
974
  // at PlayerSetupPanel's own pickerRow gap (12).
957
975
  playersRow: {
@@ -1006,7 +1024,9 @@ function useResumePoint(gameId) {
1006
1024
  (0, import_react14.useEffect)(() => {
1007
1025
  if (!gameId) return;
1008
1026
  setLastActiveGame(gameId);
1009
- return clearLastActiveGame;
1027
+ return () => {
1028
+ if (import_react_native5.AppState.currentState === "active") clearLastActiveGame();
1029
+ };
1010
1030
  }, [gameId, setLastActiveGame, clearLastActiveGame]);
1011
1031
  }
1012
1032
 
@@ -1018,7 +1038,7 @@ var import_chrome5 = require("@tastic/hud/chrome");
1018
1038
  var import_profile7 = require("@tastic/profile");
1019
1039
  var import_expo_router6 = require("expo-router");
1020
1040
  var import_react16 = require("react");
1021
- var import_react_native5 = require("react-native");
1041
+ var import_react_native6 = require("react-native");
1022
1042
  var import_react_native_paper = require("react-native-paper");
1023
1043
 
1024
1044
  // src/hooks/useFrozenWhileHidden.ts
@@ -1124,9 +1144,9 @@ function HomeMenu() {
1124
1144
  const [settingsOpen, setSettingsOpen] = (0, import_react16.useState)(false);
1125
1145
  const [rulesGame, setRulesGame] = (0, import_react16.useState)(null);
1126
1146
  const isFocused = (0, import_expo_router6.useIsFocused)();
1127
- const [appState, setAppState] = (0, import_react16.useState)(import_react_native5.AppState.currentState);
1147
+ const [appState, setAppState] = (0, import_react16.useState)(import_react_native6.AppState.currentState);
1128
1148
  (0, import_react16.useEffect)(() => {
1129
- const subscription = import_react_native5.AppState.addEventListener("change", setAppState);
1149
+ const subscription = import_react_native6.AppState.addEventListener("change", setAppState);
1130
1150
  return () => subscription.remove();
1131
1151
  }, []);
1132
1152
  const heroActive = isFocused && appState === "active";
@@ -1161,20 +1181,20 @@ function HomeMenu() {
1161
1181
  const picker = /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_profile7.ProfilePicker, { idPrefix: SEAT_ONE_PICKER, host: pickerHost, profiles, selectedId: lastSelected[1], color: p1Drawn, dark, guestLabel: pack.guestLabel ?? "P1", nullLabel: pack.nullLabel, onSelect: (profile) => selectProfile(1, profile?.id ?? null), onManage: () => import_expo_router6.router.push(PROFILES_HREF), colorSection, alignOverride: pickerAlign, triggerStyle });
1162
1182
  const pressAway = /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_chrome5.PressAwayOverlay, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles4.pressAway });
1163
1183
  const RulesSheet = rulesGame?.Rules;
1164
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native5.View, { style: [styles4.container, { backgroundColor: bg }], children: [
1184
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: [styles4.container, { backgroundColor: bg }], children: [
1165
1185
  /* @__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: [
1166
1186
  isWide ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
1167
1187
  pressAway,
1168
- /* @__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_native5.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)) }) }),
1169
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native5.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: [
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)) }) }),
1189
+ /* @__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: [
1170
1190
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1171
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native5.View, { style: styles4.widePicker, children: picker })
1191
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: styles4.widePicker, children: picker })
1172
1192
  ] })
1173
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: [
1174
1194
  pressAway,
1175
1195
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1176
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native5.View, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
1177
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native5.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)) })
1196
+ /* @__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)) })
1178
1198
  ] }),
1179
1199
  /* @__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" }),
1180
1200
  /* @__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" }),
@@ -1197,10 +1217,10 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1197
1217
  return (
1198
1218
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1199
1219
  // inside a button is invalid on the web.
1200
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native5.View, { children: [
1220
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { children: [
1201
1221
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1202
- 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_native5.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 }) }),
1203
- /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native5.View, { style: styles4.rowText, children: [
1222
+ 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
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: styles4.rowText, children: [
1204
1224
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Text, { variant: "titleLarge", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
1205
1225
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Text, { variant: "bodyMedium", style: { color: well.fgMuted }, numberOfLines: 2, children: game.blurb })
1206
1226
  ] })
@@ -1211,7 +1231,7 @@ function GameRow({ game, dark, p1Color, p2Color, previewRevealed, onPress, onRul
1211
1231
  }
1212
1232
  function GameTile({ game, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1213
1233
  const well = (0, import_chrome5.useWellColors)();
1214
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native5.View, { style: { width }, children: [
1234
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native6.View, { style: { width }, children: [
1215
1235
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_feedback_press3.Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1216
1236
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1217
1237
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper.Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
@@ -1224,13 +1244,13 @@ function PreviewBox({ game, width, revealed, dark, p1Color, p2Color }) {
1224
1244
  const height = Math.round(width / PREVIEW_ASPECT);
1225
1245
  const { Preview, icon: GameIcon } = game;
1226
1246
  const iconSize = Math.min(height, ROW_ICON_SIZE);
1227
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native5.View, { style: [styles4.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: Preview ? revealed && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Preview, { width, height, p1Color, p2Color, dark }) : GameIcon && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native5.View, { style: [styles4.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameIcon, { size: iconSize, p1Color, p2Color, dark }) }) });
1247
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.preview, { width, height, backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: Preview ? revealed && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Preview, { width, height, p1Color, p2Color, dark }) : GameIcon && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native6.View, { style: [styles4.previewIcon, { width: iconSize, height: iconSize, borderRadius: iconSize * 0.22 }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(GameIcon, { size: iconSize, p1Color, p2Color, dark }) }) });
1228
1248
  }
1229
1249
  var ROW_ICON_SIZE = 56;
1230
1250
  var ICON_LIGHT_BG = "#FAFAFA";
1231
1251
  var ICON_DARK_BG = "#050505";
1232
1252
  var RULES_BUTTON_SPACE = 44;
1233
- var styles4 = import_react_native5.StyleSheet.create({
1253
+ var styles4 = import_react_native6.StyleSheet.create({
1234
1254
  container: {
1235
1255
  flex: 1
1236
1256
  },
@@ -1377,7 +1397,7 @@ var import_auto_paper7 = require("@rific/auto-paper");
1377
1397
  var import_chrome7 = require("@tastic/hud/chrome");
1378
1398
  var import_profile9 = require("@tastic/profile");
1379
1399
  var import_react17 = require("react");
1380
- var import_react_native6 = require("react-native");
1400
+ var import_react_native7 = require("react-native");
1381
1401
  var import_react_native_paper2 = require("react-native-paper");
1382
1402
  var import_react_native_safe_area_context4 = require("react-native-safe-area-context");
1383
1403
  var import_react_redux10 = require("react-redux");
@@ -1400,7 +1420,7 @@ function StatsScreen() {
1400
1420
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(StatsPressAway, { host: pickerHost, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_chrome7.RotatedFrame, { rotatedSafeArea: true, portalHost: true, maxContentWidth: STATS_MAX_WIDTH, children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(StatsContent, { pickerHost }) }) });
1401
1421
  }
1402
1422
  function StatsPressAway({ host, children }) {
1403
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native6.View, { testID: "stats-press-away", style: styles5.screen, onStartShouldSetResponder: () => host.openId !== null, onResponderRelease: host.close, children });
1423
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native7.View, { testID: "stats-press-away", style: styles5.screen, onStartShouldSetResponder: () => host.openId !== null, onResponderRelease: host.close, children });
1404
1424
  }
1405
1425
  var useNoPackReset = () => null;
1406
1426
  var noPickerAlign2 = () => void 0;
@@ -1437,7 +1457,7 @@ function StatsContent({ pickerHost }) {
1437
1457
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_chrome7.BaseStatsScreen, { title, onBack: import_core8.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: [
1438
1458
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_chrome7.PressAwayOverlay, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
1439
1459
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native_paper2.SegmentedButtons, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
1440
- /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_react_native6.View, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1460
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_react_native7.View, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1441
1461
  profiles.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_profile9.OptionPicker, { 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 }),
1442
1462
  showGamePicker && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_profile9.OptionPicker, { id: GAME_PICKER_ID, host: pickerHost, options: gameOptions, value: gameId, onChange: setGameId, color: colors.primary, dark, icon: "view-grid-outline", alignOverride: gameAlign, triggerStyle })
1443
1463
  ] }),
@@ -1458,12 +1478,12 @@ function GameStats({ game, tab, profileId, showTitle, titleColor }) {
1458
1478
  const { StatsSections, AchievementList } = game;
1459
1479
  const Sections = tab === STATS ? StatsSections : AchievementList;
1460
1480
  if (!Sections) return null;
1461
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_react_native6.View, { style: styles5.game, children: [
1481
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(import_react_native7.View, { style: styles5.game, children: [
1462
1482
  showTitle && /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native_paper2.Text, { variant: "titleLarge", style: [styles5.gameTitle, { color: titleColor }], children: game.title }),
1463
1483
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Sections, { profileId })
1464
1484
  ] });
1465
1485
  }
1466
- var styles5 = import_react_native6.StyleSheet.create({
1486
+ var styles5 = import_react_native7.StyleSheet.create({
1467
1487
  filters: {
1468
1488
  flexDirection: "row",
1469
1489
  flexWrap: "wrap",
@@ -1497,11 +1517,11 @@ var styles5 = import_react_native6.StyleSheet.create({
1497
1517
  });
1498
1518
 
1499
1519
  // src/components/imageGameIcon.tsx
1500
- var import_react_native7 = require("react-native");
1520
+ var import_react_native8 = require("react-native");
1501
1521
  var import_jsx_runtime17 = require("react/jsx-runtime");
1502
1522
  function imageGameIcon(images) {
1503
1523
  return function ImageGameIcon({ size, dark }) {
1504
- return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_react_native7.Image, { source: dark ? images.dark : images.light, style: { height: size, width: size }, accessibilityIgnoresInvertColors: true });
1524
+ return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_react_native8.Image, { source: dark ? images.dark : images.light, style: { height: size, width: size }, accessibilityIgnoresInvertColors: true });
1505
1525
  };
1506
1526
  }
1507
1527
 
package/dist/index.mjs CHANGED
@@ -483,6 +483,7 @@ var styles = StyleSheet.create({
483
483
  // src/screens/GameRoute.tsx
484
484
  import { Redirect, useLocalSearchParams } from "expo-router";
485
485
  import { useEffect as useEffect5 } from "react";
486
+ import { AppState } from "react-native";
486
487
 
487
488
  // src/routes.ts
488
489
  var playHref = (game) => ({ pathname: "/play/[game]", params: { game } });
@@ -491,7 +492,7 @@ var STATS_HREF = "/stats";
491
492
  var PROFILES_HREF = "/profiles";
492
493
 
493
494
  // src/screens/LoadoutScreen.tsx
494
- import { defaultColors, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
495
+ import { defaultColors, getRgb, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
495
496
  import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from "@tastic/core";
496
497
  import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from "@tastic/hud/chrome";
497
498
  import { PlayerSetupPanel, ReadyButton as ReadyButton2, SharedActionBand } from "@tastic/hud/lobby";
@@ -519,8 +520,9 @@ var CPU_ICON = "robot";
519
520
  function getSeatTwoPopoverId(idPrefix) {
520
521
  return `${idPrefix}-seat`;
521
522
  }
522
- function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }) {
523
+ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true, previewValue, triggerStyle, usePickerAlign }) {
523
524
  const isPerson = seatTwo === "person";
525
+ const drawn = previewValue ? previewValue(color) : color;
524
526
  const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
525
527
  const cpuOptions = cpuDifficulty?.options;
526
528
  const options = useMemo4(() => {
@@ -529,10 +531,10 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
529
531
  if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
530
532
  if (choices.includes("person")) {
531
533
  list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
532
- list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: "account", color: p.color })));
534
+ list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: "account", color: previewValue ? previewValue(p.color) : p.color })));
533
535
  }
534
536
  return list;
535
- }, [choices, cpuOptions, profiles]);
537
+ }, [choices, cpuOptions, profiles, previewValue]);
536
538
  const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
537
539
  const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
538
540
  const handleChange = (next) => {
@@ -546,16 +548,17 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
546
548
  onSeatTwoChange("person");
547
549
  }
548
550
  };
549
- const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0 };
551
+ const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0, previewValue };
550
552
  const pickerId = getSeatTwoPopoverId(idPrefix);
551
553
  const size = getOptionPickerContentSize(options.length, colorSection);
552
- const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height);
554
+ const useAlign = usePickerAlign ?? useZoneClampedAlign;
555
+ const align = useAlign(host.openId === pickerId, size.width, size.height);
553
556
  const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
554
557
  const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
555
558
  return /* @__PURE__ */ jsxs3(View2, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
556
- /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color, dark, icon, colorSection, alignOverride: align }),
559
+ /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }),
557
560
  isPerson && secondPicker && /* @__PURE__ */ jsx10(View2, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
558
- isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
561
+ isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color: drawn, ready: ready ?? false, onToggleReady })
559
562
  ] });
560
563
  }
561
564
  var styles2 = StyleSheet2.create({
@@ -795,13 +798,20 @@ function LoadoutScreen({ game }) {
795
798
  dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }));
796
799
  router2.push(playHref(game.id));
797
800
  }, [ready, humanSeats, game.id, mode, dispatch]);
798
- const bg = dark ? "#000000" : "#FFFFFF";
799
- const fg = dark ? "#FFFFFF" : "#000000";
800
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
801
+ const bg = pack.Backdrop ? "transparent" : dark ? "#000000" : "#FFFFFF";
802
+ const fg = pack.foreground ?? (dark ? "#FFFFFF" : "#000000");
803
+ const fgMuted = (pack.foreground && halfOf(pack.foreground)) ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
804
+ const { previewColor } = pack;
805
+ const seatPreview = useMemo5(() => previewColor ? (hex) => previewColor(hex, dark) : void 0, [previewColor, dark]);
806
+ const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color;
807
+ const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color;
808
+ const triggerStyle = useMemo5(() => pack.Backdrop ? [styles3.pill, { backgroundColor: themeColors.surface }] : void 0, [pack.Backdrop, themeColors.surface]);
801
809
  const isPortrait = panelLayout.orientationMode === "faceToFace";
802
810
  const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length);
803
811
  const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : panelLayout.p1OnRight ? styles3.p2ZoneLeft : styles3.p2ZoneRight : null;
804
812
  const showReadyButton = p2IsHuman;
813
+ const seatPickerAlign = p2IsHuman ? void 0 : pack.usePickerAlign;
814
+ const layoutKey = p2IsHuman ? "zones" : "row";
805
815
  const controlsHost = usePopoverHost();
806
816
  const p2PanelHost = usePopoverHost();
807
817
  const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
@@ -820,10 +830,10 @@ function LoadoutScreen({ game }) {
820
830
  const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId);
821
831
  const sharedControls = showMetaInSharedBand ? /* @__PURE__ */ jsx12(SharedActionBand, { onBack: safeBack, onSettings: () => setSettingsOpen(true), onRandomize: loadout.randomize, onReset: loadout.reset, fg, popoverOpen: optionsPopoverOpen, children: optionsRow }) : optionsRow;
822
832
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation };
823
- const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color });
824
- const p1Panel = /* @__PURE__ */ jsx12(PlayerSetupPanel, { idPrefix: "p1", host: controlsHost, color: p1Color, onColorChange: handleP1ColorChange, swatches: defaultColors, takenColor: p2Exists ? p2Color : void 0, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, isHuman: true, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), dark, showReadyButton, secondPicker: p1Control, secondPickerId: p1Control ? "p1-control" : void 0, profiles, selectedProfileId: lastSelected[1], takenProfileId: p2IsHuman ? lastSelected[2] : null, guestLabel: "P1", onProfileSelect: handleP1ProfileSelect, onManageProfiles: () => router2.push(PROFILES_HREF) });
825
- const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : void 0;
826
- const p2Panel = game.seatTwo && /* @__PURE__ */ jsx12(SeatTwoPanel, { idPrefix: "p2", host: p2Host, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, profiles, selectedProfileId: lastSelected[2], onProfileSelect: handleP2ProfileSelect, color: p2Color, onColorChange: handleP2ColorChange, swatches: defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, dark, secondPicker: p2Control, secondPickerId: p2Control ? "p2-control" : void 0, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), showReadyButton });
833
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn });
834
+ const p1Panel = /* @__PURE__ */ jsx12(PlayerSetupPanel, { idPrefix: "p1", host: controlsHost, color: p1Color, onColorChange: handleP1ColorChange, swatches: defaultColors, takenColor: p2Exists ? p2Color : void 0, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, isHuman: true, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), dark, showReadyButton, secondPicker: p1Control, secondPickerId: p1Control ? "p1-control" : void 0, profiles, selectedProfileId: lastSelected[1], takenProfileId: p2IsHuman ? lastSelected[2] : null, guestLabel: pack.guestLabel ?? "P1", nullLabel: pack.nullLabel, onProfileSelect: handleP1ProfileSelect, onManageProfiles: () => router2.push(PROFILES_HREF), previewValue: seatPreview, triggerStyle, useSeatPickerAlign: seatPickerAlign }, `p1-${layoutKey}`);
835
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : void 0;
836
+ const p2Panel = game.seatTwo && /* @__PURE__ */ jsx12(SeatTwoPanel, { idPrefix: "p2", host: p2Host, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, profiles, selectedProfileId: lastSelected[2], onProfileSelect: handleP2ProfileSelect, color: p2Color, onColorChange: handleP2ColorChange, swatches: defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", colorCaption: pack.colorCaption, dark, secondPicker: p2Control, secondPickerId: p2Control ? "p2-control" : void 0, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), showReadyButton, previewValue: seatPreview, triggerStyle, usePickerAlign: seatPickerAlign }, `p2-${layoutKey}`);
827
837
  return /* @__PURE__ */ jsxs5(View3, { style: [styles3.container, { backgroundColor: bg }], children: [
828
838
  /* @__PURE__ */ jsxs5(FakeLandscapeView, { orientationMode: panelLayout.orientationMode, p1OnRight: panelLayout.p1OnRight, upsideDown: panelLayout.upsideDown, style: styles3.rotatable, children: [
829
839
  /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
@@ -847,12 +857,16 @@ function LoadoutScreen({ game }) {
847
857
  !isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel,
848
858
  !isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel
849
859
  ] }),
850
- /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Color, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
860
+ /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Drawn, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
851
861
  ] })
852
862
  ] }),
853
863
  /* @__PURE__ */ jsx12(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation, onShowHowToPlay: () => openFor(game.id) })
854
864
  ] });
855
865
  }
866
+ function halfOf(color) {
867
+ const rgb = getRgb(color);
868
+ return rgb ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.5)` : void 0;
869
+ }
856
870
  var styles3 = StyleSheet3.create({
857
871
  container: {
858
872
  flex: 1
@@ -878,6 +892,10 @@ var styles3 = StyleSheet3.create({
878
892
  right: 0,
879
893
  top: 0
880
894
  },
895
+ // A seat button's trigger on a surface over the pack's backdrop, as Home's.
896
+ pill: {
897
+ borderRadius: 999
898
+ },
881
899
  // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
882
900
  // at PlayerSetupPanel's own pickerRow gap (12).
883
901
  playersRow: {
@@ -932,7 +950,9 @@ function useResumePoint(gameId) {
932
950
  useEffect5(() => {
933
951
  if (!gameId) return;
934
952
  setLastActiveGame(gameId);
935
- return clearLastActiveGame;
953
+ return () => {
954
+ if (AppState.currentState === "active") clearLastActiveGame();
955
+ };
936
956
  }, [gameId, setLastActiveGame, clearLastActiveGame]);
937
957
  }
938
958
 
@@ -944,7 +964,7 @@ import { PressAwayOverlay as PressAwayOverlay2, usePopoverHost as usePopoverHost
944
964
  import { getProfilePickerContentSize, ProfilePicker } from "@tastic/profile";
945
965
  import { Redirect as Redirect2, router as router3, useIsFocused } from "expo-router";
946
966
  import { useCallback as useCallback10, useEffect as useEffect6, useMemo as useMemo6, useState as useState6 } from "react";
947
- import { AppState, StyleSheet as StyleSheet4, View as View4 } from "react-native";
967
+ import { AppState as AppState2, StyleSheet as StyleSheet4, View as View4 } from "react-native";
948
968
  import { Icon, Portal, Text } from "react-native-paper";
949
969
 
950
970
  // src/hooks/useFrozenWhileHidden.ts
@@ -1050,9 +1070,9 @@ function HomeMenu() {
1050
1070
  const [settingsOpen, setSettingsOpen] = useState6(false);
1051
1071
  const [rulesGame, setRulesGame] = useState6(null);
1052
1072
  const isFocused = useIsFocused();
1053
- const [appState, setAppState] = useState6(AppState.currentState);
1073
+ const [appState, setAppState] = useState6(AppState2.currentState);
1054
1074
  useEffect6(() => {
1055
- const subscription = AppState.addEventListener("change", setAppState);
1075
+ const subscription = AppState2.addEventListener("change", setAppState);
1056
1076
  return () => subscription.remove();
1057
1077
  }, []);
1058
1078
  const heroActive = isFocused && appState === "active";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -86,7 +86,7 @@
86
86
  "@tastic/achievements": "^0.6.0",
87
87
  "@tastic/core": "^0.14.0",
88
88
  "@tastic/edge-guard": "^0.1.5",
89
- "@tastic/hud": "^0.26.0",
89
+ "@tastic/hud": "^0.27.1",
90
90
  "@tastic/profile": "^0.17.0",
91
91
  "@tastic/split-screen": "^0.5.5",
92
92
  "@testing-library/react-native": "^14.1.0",
@@ -138,7 +138,7 @@
138
138
  "@tastic/achievements": ">=0.6.0",
139
139
  "@tastic/core": ">=0.14.0",
140
140
  "@tastic/edge-guard": ">=0.1.5",
141
- "@tastic/hud": ">=0.26.0",
141
+ "@tastic/hud": ">=0.27.1",
142
142
  "@tastic/profile": ">=0.16.2",
143
143
  "@tastic/split-screen": ">=0.5.5",
144
144
  "expo-blur": ">=58.0.4",
@@ -1,9 +1,9 @@
1
1
  import type { SeedColor } from '@rific/auto-paper'
2
- import type { PopoverHost } from '@tastic/hud/chrome'
2
+ import type { AlignResult, PopoverHost } from '@tastic/hud/chrome'
3
3
  import { type MenuOption, ReadyButton, useZoneClampedAlign } from '@tastic/hud/lobby'
4
4
  import { getOptionPickerContentSize, OptionPicker, type OptionPickerOption, type Profile, type ProfilePickerColorSection } from '@tastic/profile'
5
5
  import { ReactNode, useMemo } from 'react'
6
- import { StyleSheet, View } from 'react-native'
6
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
7
7
 
8
8
  import type { SeatTwo } from '../types'
9
9
 
@@ -57,10 +57,19 @@ export interface SeatTwoPanelProps {
57
57
  ready?: boolean
58
58
  onToggleReady?: () => void
59
59
  showReadyButton?: boolean
60
+ // How the seat's color, and each profile's in the menu, is drawn (PackDefinition.previewColor), as
61
+ // PlayerSetupPanel's own previewValue: `color` and what onColorChange receives stay the saved values.
62
+ previewValue?: (hex: string) => string
63
+ // The button's trigger style (a surface behind its pill over the pack's backdrop).
64
+ triggerStyle?: StyleProp<ViewStyle>
65
+ // A placement hook of the pack's own for the menu, in place of the zone clamp (PlayerSetupPanel's
66
+ // useSeatPickerAlign): left unset inside a split-screen zone, and the same one for the panel's life.
67
+ usePickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => AlignResult
60
68
  }
61
69
 
62
- export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true }: SeatTwoPanelProps) {
70
+ export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, profiles, selectedProfileId, onProfileSelect, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, dark, secondPicker, secondPickerId, ready, onToggleReady, showReadyButton = true, previewValue, triggerStyle, usePickerAlign }: SeatTwoPanelProps) {
63
71
  const isPerson = seatTwo === 'person'
72
+ const drawn = previewValue ? previewValue(color) : color
64
73
  const selectedProfile = isPerson ? (profiles.find((p) => p.id === selectedProfileId) ?? null) : null
65
74
 
66
75
  const cpuOptions = cpuDifficulty?.options
@@ -70,10 +79,10 @@ export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange
70
79
  if (choices.includes('nobody')) list.push({ value: NOBODY, label: 'Nobody', icon: NOBODY_ICON })
71
80
  if (choices.includes('person')) {
72
81
  list.push({ value: GUEST, label: 'Player 2', icon: GUEST_ICON })
73
- list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: p.color })))
82
+ list.push(...[...profiles].sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: `${PROFILE_PREFIX}${p.id}`, label: p.name, icon: 'account', color: previewValue ? previewValue(p.color) : p.color })))
74
83
  }
75
84
  return list
76
- }, [choices, cpuOptions, profiles])
85
+ }, [choices, cpuOptions, profiles, previewValue])
77
86
 
78
87
  const value = seatTwo === 'cpu' ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ''}` : seatTwo === 'nobody' ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST
79
88
  const icon = seatTwo === 'cpu' ? CPU_ICON : seatTwo === 'nobody' ? NOBODY_ICON : selectedProfile ? 'account' : GUEST_ICON
@@ -91,21 +100,23 @@ export function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange
91
100
  }
92
101
 
93
102
  // Nobody has no color to pick: there's nothing of theirs on the board.
94
- const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined }
103
+ const colorSection: ProfilePickerColorSection | undefined = seatTwo === 'nobody' ? undefined : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : undefined, previewValue }
95
104
 
96
105
  const pickerId = getSeatTwoPopoverId(idPrefix)
97
106
  const size = getOptionPickerContentSize(options.length, colorSection)
98
- // Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button.
99
- const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height)
107
+ // Kept inside this player's half in 2 Player, like PlayerSetupPanel's own seat button, or placed by
108
+ // the pack's own hook where it's given one.
109
+ const useAlign = usePickerAlign ?? useZoneClampedAlign
110
+ const align = useAlign(host.openId === pickerId, size.width, size.height)
100
111
 
101
112
  const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false
102
113
  const pickerRowOpen = secondPickerId !== undefined && host.openId === secondPickerId
103
114
 
104
115
  return (
105
116
  <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
106
- <OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={color} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} />
117
+ <OptionPicker id={pickerId} host={host} options={options} value={value} onChange={handleChange} color={drawn} dark={dark} icon={icon} colorSection={colorSection} alignOverride={align} triggerStyle={triggerStyle} />
107
118
  {isPerson && secondPicker && <View style={[styles.pickerRow, pickerRowOpen && styles.pickerRowOpen]}>{secondPicker}</View>}
108
- {isPerson && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
119
+ {isPerson && showReadyButton && onToggleReady && <ReadyButton color={drawn} ready={ready ?? false} onToggleReady={onToggleReady} />}
109
120
  </View>
110
121
  )
111
122
  }
@@ -1,5 +1,6 @@
1
1
  import { Redirect, useLocalSearchParams } from 'expo-router'
2
2
  import { useEffect } from 'react'
3
+ import { AppState } from 'react-native'
3
4
 
4
5
  import { useLastActiveGame } from '../hooks/useLastActiveGame'
5
6
  import { usePackGame } from '../PackContext'
@@ -36,14 +37,20 @@ export function PlayRoute() {
36
37
  }
37
38
 
38
39
  // A resumable game's play screen (GameDefinition.resumable) is where a cold launch comes back to:
39
- // remembered as it mounts, and forgotten when it unmounts, which is the player backing out of it
40
- // (its own back, hardware back or a browser back). Backgrounding the app leaves the screen mounted,
41
- // so it stays remembered. Runs once per play screen: the context's functions never change.
40
+ // remembered as it mounts, and forgotten when the player backs out of it (its own back, hardware back
41
+ // or a browser back), which unmounts it while the app is in use. Backgrounding the app leaves the
42
+ // screen mounted, so it stays remembered. An unmount with the app NOT active keeps it too: when iOS
43
+ // terminates an app it hasn't suspended yet (swiped away from the app switcher soon after leaving
44
+ // it), React Native's renderer stops every surface (RCTSurfacePresenter's will-terminate handler),
45
+ // which unmounts the whole tree, and clearing then sent every such relaunch to Home. Runs once per
46
+ // play screen: the context's functions never change.
42
47
  function useResumePoint(gameId: string | null) {
43
48
  const { setLastActiveGame, clearLastActiveGame } = useLastActiveGame()
44
49
  useEffect(() => {
45
50
  if (!gameId) return
46
51
  setLastActiveGame(gameId)
47
- return clearLastActiveGame
52
+ return () => {
53
+ if (AppState.currentState === 'active') clearLastActiveGame()
54
+ }
48
55
  }, [gameId, setLastActiveGame, clearLastActiveGame])
49
56
  }
@@ -1,4 +1,4 @@
1
- import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { defaultColors, getRgb, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from '@tastic/core'
3
3
  import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from '@tastic/hud/chrome'
4
4
  import { PlayerSetupPanel, ReadyButton, SharedActionBand } from '@tastic/hud/lobby'
@@ -122,9 +122,19 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
122
122
  router.push(playHref(game.id))
123
123
  }, [ready, humanSeats, game.id, mode, dispatch])
124
124
 
125
- const bg = dark ? '#000000' : '#FFFFFF'
126
- const fg = dark ? '#FFFFFF' : '#000000'
127
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
125
+ // Over the pack's Backdrop the loadout draws nothing of its own behind its controls, in the pack's
126
+ // foreground, as Home does.
127
+ const bg = pack.Backdrop ? 'transparent' : dark ? '#000000' : '#FFFFFF'
128
+ const fg = pack.foreground ?? (dark ? '#FFFFFF' : '#000000')
129
+ const fgMuted = (pack.foreground && halfOf(pack.foreground)) ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
130
+ // Each seat's color as the pack draws profile colors (PackDefinition.previewColor), and the seat
131
+ // buttons on the theme's surface over a backdrop, as Home's seat 1 button is. The saved colors stay
132
+ // what the Color rows pick and compare.
133
+ const { previewColor } = pack
134
+ const seatPreview = useMemo(() => (previewColor ? (hex: string) => previewColor(hex, dark) : undefined), [previewColor, dark])
135
+ const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color
136
+ const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color
137
+ const triggerStyle = useMemo(() => (pack.Backdrop ? [styles.pill, { backgroundColor: themeColors.surface }] : undefined), [pack.Backdrop, themeColors.surface])
128
138
 
129
139
  const isPortrait = panelLayout.orientationMode === 'faceToFace'
130
140
  // See @tastic/split-screen's needsSharedNeutralZone: whether back/settings/randomize/reset fold
@@ -138,6 +148,12 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
138
148
 
139
149
  // With one human, the one Ready button sits under both panels instead of in each.
140
150
  const showReadyButton = p2IsHuman
151
+ // The seat buttons' menus are placed by the pack where it places them itself (PackDefinition.
152
+ // usePickerAlign), as on Home, but only in the one-human row: 2 Player's halves keep each menu in
153
+ // its own player's half (hud's useZoneClampedAlign, the panels' own). The panels are keyed by layout,
154
+ // so a placement hook never changes under a mounted panel.
155
+ const seatPickerAlign = p2IsHuman ? undefined : pack.usePickerAlign
156
+ const layoutKey = p2IsHuman ? 'zones' : 'row'
141
157
  // P1's panel and the match options share one host - they sit in the same near/bottom zone. With
142
158
  // one human, seat 2's panel joins it too (one person drives both, so two open menus at once would
143
159
  // be clutter). 2 Player gives P2 their own host so two people can pick at once, each press-away
@@ -171,11 +187,11 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
171
187
  )
172
188
 
173
189
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation }
174
- const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color })
175
- const p1Panel = <PlayerSetupPanel idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel='P1' onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} />
176
- const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : undefined
190
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn })
191
+ const p1Panel = <PlayerSetupPanel key={`p1-${layoutKey}`} idPrefix='p1' host={controlsHost} color={p1Color} onColorChange={handleP1ColorChange} swatches={defaultColors} takenColor={p2Exists ? p2Color : undefined} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} isHuman ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} dark={dark} showReadyButton={showReadyButton} secondPicker={p1Control} secondPickerId={p1Control ? 'p1-control' : undefined} profiles={profiles} selectedProfileId={lastSelected[1]} takenProfileId={p2IsHuman ? lastSelected[2] : null} guestLabel={pack.guestLabel ?? 'P1'} nullLabel={pack.nullLabel} onProfileSelect={handleP1ProfileSelect} onManageProfiles={() => router.push(PROFILES_HREF)} previewValue={seatPreview} triggerStyle={triggerStyle} useSeatPickerAlign={seatPickerAlign} />
192
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : undefined
177
193
  // A game for one player (no seatTwo) has no seat 2 button: seat 1's panel stands alone.
178
- const p2Panel = game.seatTwo && <SeatTwoPanel idPrefix='p2' host={p2Host} choices={game.seatTwo} seatTwo={seatTwo} onSeatTwoChange={changeSeatTwo} cpuDifficulty={loadout.cpuDifficulty} profiles={profiles} selectedProfileId={lastSelected[2]} onProfileSelect={handleP2ProfileSelect} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} dark={dark} secondPicker={p2Control} secondPickerId={p2Control ? 'p2-control' : undefined} ready={ready[2]} onToggleReady={() => setReady((r) => ({ ...r, 2: !r[2] }))} showReadyButton={showReadyButton} />
194
+ const p2Panel = game.seatTwo && <SeatTwoPanel key={`p2-${layoutKey}`} idPrefix='p2' host={p2Host} choices={game.seatTwo} seatTwo={seatTwo} onSeatTwoChange={changeSeatTwo} cpuDifficulty={loadout.cpuDifficulty} profiles={profiles} selectedProfileId={lastSelected[2]} onProfileSelect={handleP2ProfileSelect} color={p2Color} onColorChange={handleP2ColorChange} swatches={defaultColors} takenColor={p1Color} allowSwapTaken={mode === 'vsCpu'} colorCaption={pack.colorCaption} dark={dark} secondPicker={p2Control} secondPickerId={p2Control ? 'p2-control' : undefined} ready={ready[2]} onToggleReady={() => setReady((r) => ({ ...r, 2: !r[2] }))} showReadyButton={showReadyButton} previewValue={seatPreview} triggerStyle={triggerStyle} usePickerAlign={seatPickerAlign} />
179
195
 
180
196
  return (
181
197
  <View style={[styles.container, { backgroundColor: bg }]}>
@@ -212,7 +228,7 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
212
228
  {!isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel}
213
229
  {!isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel}
214
230
  </Animated.View>
215
- <ReadyButton color={p1Color} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
231
+ <ReadyButton color={p1Drawn} ready={ready[1]} onToggleReady={() => setReady((r) => ({ ...r, 1: !r[1] }))} style={anyPlayerPickerOpen && styles.readyButtonHidden} />
216
232
  </View>
217
233
  )}
218
234
  </FakeLandscapeView>
@@ -222,6 +238,13 @@ export function LoadoutScreen({ game }: { game: LoadoutGame }) {
222
238
  )
223
239
  }
224
240
 
241
+ // A foreground at half strength: the loadout's muted text and icons, drawn on the pack's backdrop.
242
+ // Undefined for a color it can't read.
243
+ function halfOf(color: string): string | undefined {
244
+ const rgb = getRgb(color)
245
+ return rgb ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.5)` : undefined
246
+ }
247
+
225
248
  const styles = StyleSheet.create({
226
249
  container: {
227
250
  flex: 1
@@ -247,6 +270,10 @@ const styles = StyleSheet.create({
247
270
  right: 0,
248
271
  top: 0
249
272
  },
273
+ // A seat button's trigger on a surface over the pack's backdrop, as Home's.
274
+ pill: {
275
+ borderRadius: 999
276
+ },
250
277
  // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
251
278
  // at PlayerSetupPanel's own pickerRow gap (12).
252
279
  playersRow: {
package/src/types.ts CHANGED
@@ -288,10 +288,12 @@ export interface PackDefinition {
288
288
  StatsSections?: ComponentType<PackStatsSectionsProps>
289
289
 
290
290
  // A table drawn once behind the whole stack (a felt), seen through every screen: the stack's own
291
- // layers go transparent, and Home, the stats screen and Profiles draw on it in `foreground`.
291
+ // layers go transparent, and Home, each loadout, the stats screen and Profiles draw on it in
292
+ // `foreground`, with their buttons on the theme's surface.
292
293
  Backdrop?: ComponentType
293
- // The text and icon color of what Home, the stats screen and Profiles draw straight on the screen's
294
- // background (the hero, the corner buttons, headers). Unset is black or white with the theme.
294
+ // The text and icon color of what Home, the loadouts, the stats screen and Profiles draw straight on
295
+ // the screen's background (the hero, the corner buttons, headers; a loadout's options at half of
296
+ // it). Unset is black or white with the theme.
295
297
  foreground?: string
296
298
  // Mounted once around How to Play and the stack, inside the store, Theme, ProfilesProvider and every
297
299
  // game's Provider: a context of the pack's own (one stats context across its games).
@@ -313,14 +315,15 @@ export interface PackDefinition {
313
315
  // dark mode change and as a fresh install's theme, in place of a triad of the raw seat colors. With it,
314
316
  // each seat's color is read from the seat colors rather than from the theme.
315
317
  seatTheme?: (colors: Record<Seat, string>, dark: boolean) => Pick<ThemeSettings, 'color' | 'harmony'>
316
- // How every profile color is drawn (the button's chip, the swatches, each ProfileChip): mounted as
317
- // @tastic/profile's ProfileColorProvider. The saved colors stay as picked.
318
+ // How every profile color is drawn (the seat buttons' chips on Home and in the loadouts, their
319
+ // swatches, a loadout's Ready button, each ProfileChip): mounted as @tastic/profile's
320
+ // ProfileColorProvider. The saved colors stay as picked.
318
321
  previewColor?: (hex: string, dark: boolean) => string
319
322
  // The pack's own How to Play: shown on Home's first launch (held back while a cold launch resumes a
320
323
  // game) and from Home's Settings, never in a play screen.
321
324
  guide?: PackGuide
322
- // Seat 1's button: the chip's label for a guest (default 'P1') and the guest row's in its menu
323
- // (default @tastic/profile's).
325
+ // Seat 1's button, on Home and in every loadout: the chip's label for a guest (default 'P1') and the
326
+ // guest row's in its menu (default @tastic/profile's).
324
327
  guestLabel?: string
325
328
  nullLabel?: string
326
329
  // The stats screen keeps a guest's stats under GUEST_PLAYER_ID: a Guest entry in the player picker
@@ -336,7 +339,8 @@ export interface PackDefinition {
336
339
  statsGameTitles?: boolean
337
340
  // A new profile's starting color on Profiles. Unset is auto-paper's first swatch.
338
341
  newProfileColor?: string
339
- // Where a picker's menu opens (its alignOverride) on Home (seat 1's button), the stats screen (its two
342
+ // Where a picker's menu opens (its alignOverride) on Home (seat 1's button), a loadout's one-human row
343
+ // (both seat buttons; 2 Player keeps each menu in its own player's half), the stats screen (its two
340
344
  // pickers) and Profiles (a profile's color swatch). hud's own placement measures a turned button
341
345
  // against the upright window, so with the phone held sideways it can pick the side with no room and
342
346
  // run the menu off the screen (Solitaire's useFrameAutoAlign places it in the turned frame instead).