@tastic/shell 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,9 @@ package ecosystem, on [`@tastic/hud`](https://github.com/jayrdeaton/react-native
18
18
 
19
19
  - **`RootLayout`**: the root of the app (gesture root, safe area, fonts, Redux and its persist gate,
20
20
  the theme, profiles, every game's own provider, the splash gates, the shared orientation reading,
21
- toasts, How to Play, the OTA update dialog) around expo-router's stack.
21
+ toasts, How to Play, the OTA update dialog) around expo-router's stack. The stack follows the app's
22
+ own theme, not the phone's light or dark appearance: the status bar's text, the root window and the
23
+ navigation theme's background (what shows in a sliding screen's corners during a push or pop).
22
24
  - **Screens** for the routes: `HomeScreen` (the pack's hero, seat 1's button, a row per game, or a
23
25
  grid of preview tiles on a wide screen), `LoadoutRoute` and `PlayRoute` (`loadout/[game]`,
24
26
  `play/[game]`), `ProfilesRoute` and `StatsScreen`.
@@ -109,25 +111,25 @@ PuckTastic and DuelTastic draw what they always did:
109
111
 
110
112
  | Field | |
111
113
  | --- | --- |
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. |
114
+ | `Backdrop?` | A table drawn once behind the whole stack (Solitaire's felt). The stack's two layers go transparent (the screens' `contentStyle` and the navigation theme's `colors.background`, which React Navigation paints around every screen), the root window stays dark so light mode doesn't flash white on a push, Android's push and pop crossfade (its default holds the outgoing screen opaque), and Home, each loadout, the stats screen and Profiles draw transparent, with their buttons and tabs on the theme's surface. |
115
+ | `foreground?` | The color of what those screens draw straight on the background (the hero, the corner buttons, headers), and at half strength a loadout's muted options. Unset is black or white with the theme. |
114
116
  | `Provider?` | Mounted once around How to Play and the stack, inside the store, the theme, profiles and every game's `Provider` (one stats context across the pack's games). |
115
- | `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a light status bar, Reduce Motion's crossfade). |
117
+ | `useScreenOptions?` | A hook returning stack screen options (`StackScreenOptions`), merged after the shell's own (a status bar style of the pack's own, Reduce Motion's crossfade). The shell's already follow the app's theme, and stay dark over a `Backdrop`. |
116
118
  | `useFeedbackSounds?` | A hook returning the two sounds every button plays (`FeedbackSounds`: `selection`, `notification`), in place of the shell's two wavs. |
117
119
  | `Toaster?` | The pack's own toaster, drawn in a frame turned with the phone (with the safe-area insets turned onto its edges) in place of the shell's. The toast provider is given no Paper, so nothing portals it out of the frame. |
118
120
  | `portraitWhileKeyboard?` | Every screen reads upright while the software keyboard is up (`@tastic/core`'s `OrientationProvider`), for naming a profile sideways. |
119
121
  | `edgeGuard?` | `false` leaves Edge Guard out of Settings (no edge controls, no Edge Guard plugin). |
120
122
  | `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. |
123
+ | `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
124
  | `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. |
125
+ | `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
126
  | `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
127
  | `AchievementList?` | One achievement list across the pack's games, on the Achievements tab, with the game picker hidden there. |
126
128
  | `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
129
  | `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
130
  | `statsGameTitles?` | `false` leaves each game's name off its sections on All Games, where the sections name it. |
129
131
  | `newProfileColor?` | A new profile's starting color on Profiles. Unset is auto-paper's first swatch. |
130
- | `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. |
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. |
131
133
 
132
134
  ### `GameDefinition`
133
135
 
@@ -204,7 +206,7 @@ became a package (the lower of the two where they differ).
204
206
  | `@rific/feedback-press` | >=0.13.1 | haptic and sound settings, the UI sounds |
205
207
  | `@rific/toaster` | >=0.10.1 | toasts (DuelTastic runs 0.12, PuckTastic 0.10) |
206
208
  | `@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 |
209
+ | `@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
210
  | `@tastic/profile` | >=0.16.2 | profiles, the seat buttons, seat colors |
209
211
  | `@tastic/achievements` | >=0.6.0 | `createGameStats`, `forgetProfileAchievements` |
210
212
  | `@tastic/split-screen` | >=0.5.5 | the 2 Player loadout's two halves |
package/dist/index.js CHANGED
@@ -484,8 +484,9 @@ function AppStack() {
484
484
  const pack = usePack();
485
485
  const { Backdrop } = pack;
486
486
  const { dark } = (0, import_auto_paper2.useAutoPaperTheme)();
487
- const rootBackground = (0, import_core6.useThemedRootBackground)(Backdrop ? true : dark, false);
488
- const statusBar = (0, import_core6.useRotationAwareStatusBar)();
487
+ const darkRoot = Backdrop ? true : dark;
488
+ const rootBackground = (0, import_core6.useThemedRootBackground)(darkRoot, false);
489
+ const statusBar = (0, import_core6.useRotationAwareStatusBar)({ style: darkRoot ? "light" : "dark" });
489
490
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
490
491
  const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: import_react_native2.Platform.select({ android: "fade" }) } : void 0;
491
492
  const playOptions = (0, import_react9.useCallback)(
@@ -495,15 +496,19 @@ function AppStack() {
495
496
  },
496
497
  [pack.games]
497
498
  );
498
- const navigationTheme = (0, import_react9.useMemo)(() => Backdrop ? { ...import_expo_router.DarkTheme, colors: { ...import_expo_router.DarkTheme.colors, background: "transparent" } } : null, [Backdrop]);
499
- const stack = /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_expo_router.Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
499
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
500
+ const navigationTheme = (0, import_react9.useMemo)(() => {
501
+ const base = darkRoot ? import_expo_router.DarkTheme : import_expo_router.DefaultTheme;
502
+ return { ...base, colors: { ...base.colors, background: navigationBackground } };
503
+ }, [darkRoot, navigationBackground]);
504
+ const stack = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.ThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_expo_router.Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
500
505
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "index" }),
501
506
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.Stack.Screen, { name: "play/[game]", options: playOptions })
502
- ] });
503
- if (!Backdrop || !navigationTheme) return stack;
507
+ ] }) });
508
+ if (!Backdrop) return stack;
504
509
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_react_native2.View, { style: styles.root, children: [
505
510
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Backdrop, {}),
506
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_expo_router.ThemeProvider, { value: navigationTheme, children: stack })
511
+ stack
507
512
  ] });
508
513
  }
509
514
  function OrientationBridge({ children }) {
@@ -594,8 +599,9 @@ var CPU_ICON = "robot";
594
599
  function getSeatTwoPopoverId(idPrefix) {
595
600
  return `${idPrefix}-seat`;
596
601
  }
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 }) {
602
+ 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 }) {
598
603
  const isPerson = seatTwo === "person";
604
+ const drawn = previewValue ? previewValue(color) : color;
599
605
  const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
600
606
  const cpuOptions = cpuDifficulty?.options;
601
607
  const options = (0, import_react10.useMemo)(() => {
@@ -604,10 +610,10 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
604
610
  if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
605
611
  if (choices.includes("person")) {
606
612
  list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
607
- 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 })));
613
+ 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 })));
608
614
  }
609
615
  return list;
610
- }, [choices, cpuOptions, profiles]);
616
+ }, [choices, cpuOptions, profiles, previewValue]);
611
617
  const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
612
618
  const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
613
619
  const handleChange = (next) => {
@@ -621,16 +627,17 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
621
627
  onSeatTwoChange("person");
622
628
  }
623
629
  };
624
- const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0 };
630
+ const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0, previewValue };
625
631
  const pickerId = getSeatTwoPopoverId(idPrefix);
626
632
  const size = (0, import_profile4.getOptionPickerContentSize)(options.length, colorSection);
627
- const align = (0, import_lobby.useZoneClampedAlign)(host.openId === pickerId, size.width, size.height);
633
+ const useAlign = usePickerAlign ?? import_lobby.useZoneClampedAlign;
634
+ const align = useAlign(host.openId === pickerId, size.width, size.height);
628
635
  const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
629
636
  const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
630
637
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_react_native3.View, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
631
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_profile4.OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color, dark, icon, colorSection, alignOverride: align }),
638
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_profile4.OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }),
632
639
  isPerson && secondPicker && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native3.View, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
633
- isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lobby.ReadyButton, { color, ready: ready ?? false, onToggleReady })
640
+ isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_lobby.ReadyButton, { color: drawn, ready: ready ?? false, onToggleReady })
634
641
  ] });
635
642
  }
636
643
  var styles2 = import_react_native3.StyleSheet.create({
@@ -870,13 +877,20 @@ function LoadoutScreen({ game }) {
870
877
  dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }));
871
878
  import_expo_router4.router.push(playHref(game.id));
872
879
  }, [ready, humanSeats, game.id, mode, dispatch]);
873
- const bg = dark ? "#000000" : "#FFFFFF";
874
- const fg = dark ? "#FFFFFF" : "#000000";
875
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
880
+ const bg = pack.Backdrop ? "transparent" : dark ? "#000000" : "#FFFFFF";
881
+ const fg = pack.foreground ?? (dark ? "#FFFFFF" : "#000000");
882
+ const fgMuted = (pack.foreground && halfOf(pack.foreground)) ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
883
+ const { previewColor } = pack;
884
+ const seatPreview = (0, import_react13.useMemo)(() => previewColor ? (hex) => previewColor(hex, dark) : void 0, [previewColor, dark]);
885
+ const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color;
886
+ const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color;
887
+ const triggerStyle = (0, import_react13.useMemo)(() => pack.Backdrop ? [styles3.pill, { backgroundColor: themeColors.surface }] : void 0, [pack.Backdrop, themeColors.surface]);
876
888
  const isPortrait = panelLayout.orientationMode === "faceToFace";
877
889
  const showMetaInSharedBand = (0, import_split_screen.needsSharedNeutralZone)(panelLayout.orientationMode, humanSeats.length);
878
890
  const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : panelLayout.p1OnRight ? styles3.p2ZoneLeft : styles3.p2ZoneRight : null;
879
891
  const showReadyButton = p2IsHuman;
892
+ const seatPickerAlign = p2IsHuman ? void 0 : pack.usePickerAlign;
893
+ const layoutKey = p2IsHuman ? "zones" : "row";
880
894
  const controlsHost = (0, import_chrome4.usePopoverHost)();
881
895
  const p2PanelHost = (0, import_chrome4.usePopoverHost)();
882
896
  const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
@@ -895,10 +909,10 @@ function LoadoutScreen({ game }) {
895
909
  const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId);
896
910
  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;
897
911
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation };
898
- const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color });
899
- 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) });
900
- const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : void 0;
901
- 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 });
912
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn });
913
+ 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}`);
914
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : void 0;
915
+ 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}`);
902
916
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native4.View, { style: [styles3.container, { backgroundColor: bg }], children: [
903
917
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_core10.FakeLandscapeView, { orientationMode: panelLayout.orientationMode, p1OnRight: panelLayout.p1OnRight, upsideDown: panelLayout.upsideDown, style: styles3.rotatable, children: [
904
918
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_chrome4.PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
@@ -922,12 +936,16 @@ function LoadoutScreen({ game }) {
922
936
  !isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel,
923
937
  !isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel
924
938
  ] }),
925
- /* @__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 })
939
+ /* @__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 })
926
940
  ] })
927
941
  ] }),
928
942
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation, onShowHowToPlay: () => openFor(game.id) })
929
943
  ] });
930
944
  }
945
+ function halfOf(color) {
946
+ const rgb = (0, import_auto_paper4.getRgb)(color);
947
+ return rgb ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.5)` : void 0;
948
+ }
931
949
  var styles3 = import_react_native4.StyleSheet.create({
932
950
  container: {
933
951
  flex: 1
@@ -953,6 +971,10 @@ var styles3 = import_react_native4.StyleSheet.create({
953
971
  right: 0,
954
972
  top: 0
955
973
  },
974
+ // A seat button's trigger on a surface over the pack's backdrop, as Home's.
975
+ pill: {
976
+ borderRadius: 999
977
+ },
956
978
  // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
957
979
  // at PlayerSetupPanel's own pickerRow gap (12).
958
980
  playersRow: {
package/dist/index.mjs CHANGED
@@ -29,7 +29,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
29
29
  import { RotatedFrame } from "@tastic/hud/chrome";
30
30
  import { ProfileColorProvider } from "@tastic/profile";
31
31
  import * as Haptics from "expo-haptics";
32
- import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
32
+ import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
33
33
  import { DeviceMotion } from "expo-sensors";
34
34
  import { useCallback as useCallback6, useMemo as useMemo3 } from "react";
35
35
  import { Platform as Platform2, StyleSheet, View } from "react-native";
@@ -410,8 +410,9 @@ function AppStack() {
410
410
  const pack = usePack();
411
411
  const { Backdrop } = pack;
412
412
  const { dark } = useAutoPaperTheme();
413
- const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false);
414
- const statusBar = useRotationAwareStatusBar();
413
+ const darkRoot = Backdrop ? true : dark;
414
+ const rootBackground = useThemedRootBackground(darkRoot, false);
415
+ const statusBar = useRotationAwareStatusBar({ style: darkRoot ? "light" : "dark" });
415
416
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)();
416
417
  const backdropOptions = Backdrop ? { contentStyle: TRANSPARENT_CONTENT, animation: Platform2.select({ android: "fade" }) } : void 0;
417
418
  const playOptions = useCallback6(
@@ -421,15 +422,19 @@ function AppStack() {
421
422
  },
422
423
  [pack.games]
423
424
  );
424
- const navigationTheme = useMemo3(() => Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "transparent" } } : null, [Backdrop]);
425
- const stack = /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
425
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor;
426
+ const navigationTheme = useMemo3(() => {
427
+ const base = darkRoot ? DarkTheme : DefaultTheme;
428
+ return { ...base, colors: { ...base.colors, background: navigationBackground } };
429
+ }, [darkRoot, navigationBackground]);
430
+ const stack = /* @__PURE__ */ jsx9(NavigationThemeProvider, { value: navigationTheme, children: /* @__PURE__ */ jsxs2(Stack, { screenOptions: { ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }, children: [
426
431
  /* @__PURE__ */ jsx9(Stack.Screen, { name: "index" }),
427
432
  /* @__PURE__ */ jsx9(Stack.Screen, { name: "play/[game]", options: playOptions })
428
- ] });
429
- if (!Backdrop || !navigationTheme) return stack;
433
+ ] }) });
434
+ if (!Backdrop) return stack;
430
435
  return /* @__PURE__ */ jsxs2(View, { style: styles.root, children: [
431
436
  /* @__PURE__ */ jsx9(Backdrop, {}),
432
- /* @__PURE__ */ jsx9(NavigationThemeProvider, { value: navigationTheme, children: stack })
437
+ stack
433
438
  ] });
434
439
  }
435
440
  function OrientationBridge({ children }) {
@@ -492,7 +497,7 @@ var STATS_HREF = "/stats";
492
497
  var PROFILES_HREF = "/profiles";
493
498
 
494
499
  // src/screens/LoadoutScreen.tsx
495
- import { defaultColors, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
500
+ import { defaultColors, getRgb, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
496
501
  import { FakeLandscapeView, getViewRotation, rotateInsets, useOrientationState } from "@tastic/core";
497
502
  import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from "@tastic/hud/chrome";
498
503
  import { PlayerSetupPanel, ReadyButton as ReadyButton2, SharedActionBand } from "@tastic/hud/lobby";
@@ -520,8 +525,9 @@ var CPU_ICON = "robot";
520
525
  function getSeatTwoPopoverId(idPrefix) {
521
526
  return `${idPrefix}-seat`;
522
527
  }
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 }) {
528
+ 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 }) {
524
529
  const isPerson = seatTwo === "person";
530
+ const drawn = previewValue ? previewValue(color) : color;
525
531
  const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
526
532
  const cpuOptions = cpuDifficulty?.options;
527
533
  const options = useMemo4(() => {
@@ -530,10 +536,10 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
530
536
  if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
531
537
  if (choices.includes("person")) {
532
538
  list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
533
- 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 })));
539
+ 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 })));
534
540
  }
535
541
  return list;
536
- }, [choices, cpuOptions, profiles]);
542
+ }, [choices, cpuOptions, profiles, previewValue]);
537
543
  const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
538
544
  const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
539
545
  const handleChange = (next) => {
@@ -547,16 +553,17 @@ function SeatTwoPanel({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDi
547
553
  onSeatTwoChange("person");
548
554
  }
549
555
  };
550
- const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0 };
556
+ const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0, previewValue };
551
557
  const pickerId = getSeatTwoPopoverId(idPrefix);
552
558
  const size = getOptionPickerContentSize(options.length, colorSection);
553
- const align = useZoneClampedAlign(host.openId === pickerId, size.width, size.height);
559
+ const useAlign = usePickerAlign ?? useZoneClampedAlign;
560
+ const align = useAlign(host.openId === pickerId, size.width, size.height);
554
561
  const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
555
562
  const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
556
563
  return /* @__PURE__ */ jsxs3(View2, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
557
- /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color, dark, icon, colorSection, alignOverride: align }),
564
+ /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }),
558
565
  isPerson && secondPicker && /* @__PURE__ */ jsx10(View2, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
559
- isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
566
+ isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color: drawn, ready: ready ?? false, onToggleReady })
560
567
  ] });
561
568
  }
562
569
  var styles2 = StyleSheet2.create({
@@ -796,13 +803,20 @@ function LoadoutScreen({ game }) {
796
803
  dispatch(liveplayActions.setActiveRound({ game: game.id, mode, settings: roundSettingsRef.current }));
797
804
  router2.push(playHref(game.id));
798
805
  }, [ready, humanSeats, game.id, mode, dispatch]);
799
- const bg = dark ? "#000000" : "#FFFFFF";
800
- const fg = dark ? "#FFFFFF" : "#000000";
801
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
806
+ const bg = pack.Backdrop ? "transparent" : dark ? "#000000" : "#FFFFFF";
807
+ const fg = pack.foreground ?? (dark ? "#FFFFFF" : "#000000");
808
+ const fgMuted = (pack.foreground && halfOf(pack.foreground)) ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
809
+ const { previewColor } = pack;
810
+ const seatPreview = useMemo5(() => previewColor ? (hex) => previewColor(hex, dark) : void 0, [previewColor, dark]);
811
+ const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color;
812
+ const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color;
813
+ const triggerStyle = useMemo5(() => pack.Backdrop ? [styles3.pill, { backgroundColor: themeColors.surface }] : void 0, [pack.Backdrop, themeColors.surface]);
802
814
  const isPortrait = panelLayout.orientationMode === "faceToFace";
803
815
  const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length);
804
816
  const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : panelLayout.p1OnRight ? styles3.p2ZoneLeft : styles3.p2ZoneRight : null;
805
817
  const showReadyButton = p2IsHuman;
818
+ const seatPickerAlign = p2IsHuman ? void 0 : pack.usePickerAlign;
819
+ const layoutKey = p2IsHuman ? "zones" : "row";
806
820
  const controlsHost = usePopoverHost();
807
821
  const p2PanelHost = usePopoverHost();
808
822
  const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
@@ -821,10 +835,10 @@ function LoadoutScreen({ game }) {
821
835
  const optionsPopoverOpen = controlsHost.openId !== null && loadout.optionIds.includes(controlsHost.openId);
822
836
  const sharedControls = showMetaInSharedBand ? /* @__PURE__ */ jsx12(SharedActionBand, { onBack: safeBack, onSettings: () => setSettingsOpen(true), onRandomize: loadout.randomize, onReset: loadout.reset, fg, popoverOpen: optionsPopoverOpen, children: optionsRow }) : optionsRow;
823
837
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark, rotation };
824
- const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Color });
825
- 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) });
826
- const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Color }) : void 0;
827
- 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 });
838
+ const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn });
839
+ 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}`);
840
+ const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : void 0;
841
+ 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}`);
828
842
  return /* @__PURE__ */ jsxs5(View3, { style: [styles3.container, { backgroundColor: bg }], children: [
829
843
  /* @__PURE__ */ jsxs5(FakeLandscapeView, { orientationMode: panelLayout.orientationMode, p1OnRight: panelLayout.p1OnRight, upsideDown: panelLayout.upsideDown, style: styles3.rotatable, children: [
830
844
  /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
@@ -848,12 +862,16 @@ function LoadoutScreen({ game }) {
848
862
  !isPortrait && panelLayout.p1OnRight ? p2Panel : p1Panel,
849
863
  !isPortrait && panelLayout.p1OnRight ? p1Panel : p2Panel
850
864
  ] }),
851
- /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Color, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
865
+ /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Drawn, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
852
866
  ] })
853
867
  ] }),
854
868
  /* @__PURE__ */ jsx12(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), rotation, onShowHowToPlay: () => openFor(game.id) })
855
869
  ] });
856
870
  }
871
+ function halfOf(color) {
872
+ const rgb = getRgb(color);
873
+ return rgb ? `rgba(${rgb.r},${rgb.g},${rgb.b},0.5)` : void 0;
874
+ }
857
875
  var styles3 = StyleSheet3.create({
858
876
  container: {
859
877
  flex: 1
@@ -879,6 +897,10 @@ var styles3 = StyleSheet3.create({
879
897
  right: 0,
880
898
  top: 0
881
899
  },
900
+ // A seat button's trigger on a surface over the pack's backdrop, as Home's.
901
+ pill: {
902
+ borderRadius: 999
903
+ },
882
904
  // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
883
905
  // at PlayerSetupPanel's own pickerRow gap (12).
884
906
  playersRow: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",
@@ -5,7 +5,7 @@ import { OrientationProvider, useRotationAwareStatusBar, useThemedRootBackground
5
5
  import { RotatedFrame } from '@tastic/hud/chrome'
6
6
  import { ProfileColorProvider } from '@tastic/profile'
7
7
  import * as Haptics from 'expo-haptics'
8
- import { DarkTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
8
+ import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'
9
9
  import { DeviceMotion } from 'expo-sensors'
10
10
  import { ReactNode, useCallback, useMemo } from 'react'
11
11
  import { Platform, StyleSheet, View } from 'react-native'
@@ -50,13 +50,16 @@ import type { PackDefinition, PackGameDefinition } from './types'
50
50
  const PLAY_OPTIONS = { statusBarHidden: true }
51
51
  const KEEP_STATUS_BAR_OPTIONS = {}
52
52
 
53
- // A pack's Backdrop (PackDefinition.Backdrop) is drawn once behind the whole stack and has to show
54
- // through two layers: each screen's contentStyle, and the layers React Navigation paints its theme's
55
- // `colors.background` on around every screen (the ScreenStack's container on native, each screen's
56
- // wrapper on the web). contentStyle alone left an opaque light grey slab over Solitaire's felt on the
57
- // web. Nothing else reads the navigation theme (headers are hidden; Paper has its own). Built only for
58
- // a pack with a backdrop, as it renders: never at import, where a pack's tests may stand in for
59
- // expo-router without a DarkTheme.
53
+ // The navigation theme is the root background too. React Navigation paints its theme's
54
+ // `colors.background` on the layers around every screen (the ScreenStack's container on native, each
55
+ // screen's wrapper on the web), and expo-router hands its container no theme, so that was always
56
+ // DefaultTheme's light grey: on iOS it showed in a sliding screen's rounded corners during every push and
57
+ // pop, whatever the app's theme. So the stack always gets a theme whose background is the root
58
+ // background's own color, and a pack's Backdrop (PackDefinition.Backdrop), drawn once behind the whole
59
+ // stack, gets a transparent one so it shows through both layers (contentStyle alone left an opaque light
60
+ // grey slab over Solitaire's felt on the web). Nothing else reads the navigation theme (headers are
61
+ // hidden; Paper has its own). Built as the stack renders, never at import, where a pack's tests may stand
62
+ // in for expo-router without its themes.
60
63
  const TRANSPARENT_CONTENT = { backgroundColor: 'transparent' }
61
64
 
62
65
  // The pack's own screen options (PackDefinition.useScreenOptions), as a hook every pack calls: an
@@ -67,10 +70,13 @@ function AppStack() {
67
70
  const pack = usePack()
68
71
  const { Backdrop } = pack
69
72
  const { dark } = useAutoPaperTheme()
70
- // Over a backdrop the root window stays dark whatever the mode: a backdrop is a dark table (the
71
- // felt), and a light root window would flash white around it during a push or pop.
72
- const rootBackground = useThemedRootBackground(Backdrop ? true : dark, false)
73
- const statusBar = useRotationAwareStatusBar()
73
+ // Over a backdrop the root stays dark whatever the mode: a backdrop is a dark table (the felt), and a
74
+ // light root window would flash white around it during a push or pop.
75
+ const darkRoot = Backdrop ? true : dark
76
+ const rootBackground = useThemedRootBackground(darkRoot, false)
77
+ // The status bar's text follows the app's theme, not the phone's appearance (react-native-screens'
78
+ // default `auto`), which drew dark text on a dark theme's black Home with the phone set to light.
79
+ const statusBar = useRotationAwareStatusBar({ style: darkRoot ? 'light' : 'dark' })
74
80
  const packOptions = (pack.useScreenOptions ?? noScreenOptions)()
75
81
  // Over a backdrop every screen is transparent, and Android crossfades: its default push and pop hold
76
82
  // the outgoing screen opaque for ~450ms while the new one slides over it, which would show Home
@@ -83,18 +89,24 @@ function AppStack() {
83
89
  },
84
90
  [pack.games]
85
91
  )
86
- const navigationTheme = useMemo(() => (Backdrop ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: 'transparent' } } : null), [Backdrop])
92
+ const navigationBackground = Backdrop ? TRANSPARENT_CONTENT.backgroundColor : rootBackground.contentStyle.backgroundColor
93
+ const navigationTheme = useMemo(() => {
94
+ const base = darkRoot ? DarkTheme : DefaultTheme
95
+ return { ...base, colors: { ...base.colors, background: navigationBackground } }
96
+ }, [darkRoot, navigationBackground])
87
97
  const stack = (
88
- <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
89
- <Stack.Screen name='index' />
90
- <Stack.Screen name='play/[game]' options={playOptions} />
91
- </Stack>
98
+ <NavigationThemeProvider value={navigationTheme}>
99
+ <Stack screenOptions={{ ...rootBackground, ...statusBar, ...backdropOptions, ...packOptions }}>
100
+ <Stack.Screen name='index' />
101
+ <Stack.Screen name='play/[game]' options={playOptions} />
102
+ </Stack>
103
+ </NavigationThemeProvider>
92
104
  )
93
- if (!Backdrop || !navigationTheme) return stack
105
+ if (!Backdrop) return stack
94
106
  return (
95
107
  <View style={styles.root}>
96
108
  <Backdrop />
97
- <NavigationThemeProvider value={navigationTheme}>{stack}</NavigationThemeProvider>
109
+ {stack}
98
110
  </View>
99
111
  )
100
112
  }
@@ -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,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).