@tastic/hud 0.20.0 → 0.21.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.
@@ -740,7 +740,7 @@ function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", t
740
740
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
741
741
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: titleVariant, style: [styles10.title, { color: fg }], children: title })
742
742
  ] }),
743
- /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.ScrollView, { style: styles10.scrollView, contentContainerStyle: [styles10.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
743
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_auto_paper9.FadingScrollView, { style: styles10.scrollView, contentContainerStyle: [styles10.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
744
744
  children,
745
745
  showReset && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press3.Button, { mode: "contained", onPress: () => setConfirmResetVisible(true), buttonColor: colors.danger, textColor: colors.onDanger, style: styles10.resetButton, children: resetLabel })
746
746
  ] }),
@@ -781,7 +781,7 @@ var styles10 = import_react_native13.StyleSheet.create({
781
781
  resetButton: {
782
782
  marginTop: 16
783
783
  },
784
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
784
+ // Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
785
785
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
786
786
  // can render at its full unclipped content height instead of the screen's), which leaves the
787
787
  // header's back button touch target unreliable underneath it.
@@ -16,7 +16,7 @@ import {
16
16
  UpdateDialog,
17
17
  getStaggeredWordDuration,
18
18
  useCompactDialog
19
- } from "../chunk-DK3HOZC5.mjs";
19
+ } from "../chunk-UOA35OWJ.mjs";
20
20
  import {
21
21
  COMPACT_DIALOG_HEIGHT,
22
22
  DialogHeader,
@@ -539,11 +539,11 @@ var styles7 = StyleSheet7.create({
539
539
  });
540
540
 
541
541
  // src/BaseStatsScreen.tsx
542
- import { useAutoPaperTheme as useAutoPaperTheme7 } from "@rific/auto-paper";
542
+ import { FadingScrollView as FadingScrollView2, useAutoPaperTheme as useAutoPaperTheme7 } from "@rific/auto-paper";
543
543
  import { Button as Button3, IconButton } from "@rific/feedback-press";
544
544
  import { useRotation as useRotation3 } from "@tastic/core";
545
545
  import { useState as useState4 } from "react";
546
- import { ScrollView, StyleSheet as StyleSheet8, View as View7 } from "react-native";
546
+ import { StyleSheet as StyleSheet8, View as View7 } from "react-native";
547
547
  import { Text as Text7 } from "react-native-paper";
548
548
  import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
549
549
  function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", titleVariant = "headlineSmall", onBack, backLabel = "Back", hitSlop, insets, onReset, resetLabel = "Reset All Stats", resetConfirmTitle = "Reset Everything?", resetConfirmBody = "This permanently erases all stats and achievements. This cannot be undone.", fg: fgOverride, bg: bgOverride, children }) {
@@ -559,7 +559,7 @@ function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", t
559
559
  /* @__PURE__ */ jsx9(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
560
560
  /* @__PURE__ */ jsx9(Text7, { variant: titleVariant, style: [styles8.title, { color: fg }], children: title })
561
561
  ] }),
562
- /* @__PURE__ */ jsxs8(ScrollView, { style: styles8.scrollView, contentContainerStyle: [styles8.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
562
+ /* @__PURE__ */ jsxs8(FadingScrollView2, { style: styles8.scrollView, contentContainerStyle: [styles8.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
563
563
  children,
564
564
  showReset && /* @__PURE__ */ jsx9(Button3, { mode: "contained", onPress: () => setConfirmResetVisible(true), buttonColor: colors.danger, textColor: colors.onDanger, style: styles8.resetButton, children: resetLabel })
565
565
  ] }),
@@ -600,7 +600,7 @@ var styles8 = StyleSheet8.create({
600
600
  resetButton: {
601
601
  marginTop: 16
602
602
  },
603
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
603
+ // Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
604
604
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
605
605
  // can render at its full unclipped content height instead of the screen's), which leaves the
606
606
  // header's back button touch target unreliable underneath it.
package/dist/index.d.mts CHANGED
@@ -178,6 +178,7 @@ declare function LabeledDropdown<T extends string>({ id, host, options, value, o
178
178
  declare function loadSkiaWeb(): Promise<void>;
179
179
 
180
180
  declare function getColorPopoverId(idPrefix: string): string;
181
+ declare function getProfilePopoverId(idPrefix: string): string;
181
182
  declare function getCpuDifficultyPopoverId(idPrefix: string): string;
182
183
  interface PlayerSetupPanelProps<C extends string = string> {
183
184
  idPrefix: string;
@@ -189,6 +190,8 @@ interface PlayerSetupPanelProps<C extends string = string> {
189
190
  swatches: SeedColor[];
190
191
  takenColor?: string;
191
192
  allowSwapTaken?: boolean;
193
+ colorCaption?: string;
194
+ showColor?: boolean;
192
195
  colorAlignOverride?: AlignResult;
193
196
  colorRotation?: PopoverRotation;
194
197
  isHuman: boolean;
@@ -207,11 +210,10 @@ interface PlayerSetupPanelProps<C extends string = string> {
207
210
  cpuDifficulty?: C;
208
211
  cpuDifficultyOptions?: LabeledDropdownOption<C>[];
209
212
  onCpuDifficultyChange?: (value: C) => void;
210
- cpuDifficultyAlignOverride?: AlignResult;
211
213
  humanIcon?: string;
212
214
  cpuIcon?: string;
213
215
  }
214
- declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
216
+ declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
215
217
 
216
218
  interface Props$3 {
217
219
  visible: boolean;
@@ -277,4 +279,4 @@ declare function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack
277
279
 
278
280
  declare function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): ReturnType<typeof useAutoAlign>;
279
281
 
280
- export { AchievementUnlockList, type AchievementUnlockOwner, type AlignResult, ControlSchemePicker, type ControlSchemePickerProps, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, TriggerGaugeHost, type TriggerGaugeProps, type UnlockedAchievement, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, loadSkiaWeb, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };
282
+ export { AchievementUnlockList, type AchievementUnlockOwner, type AlignResult, ControlSchemePicker, type ControlSchemePickerProps, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, TriggerGaugeHost, type TriggerGaugeProps, type UnlockedAchievement, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, getProfilePopoverId, loadSkiaWeb, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };
package/dist/index.d.ts CHANGED
@@ -178,6 +178,7 @@ declare function LabeledDropdown<T extends string>({ id, host, options, value, o
178
178
  declare function loadSkiaWeb(): Promise<void>;
179
179
 
180
180
  declare function getColorPopoverId(idPrefix: string): string;
181
+ declare function getProfilePopoverId(idPrefix: string): string;
181
182
  declare function getCpuDifficultyPopoverId(idPrefix: string): string;
182
183
  interface PlayerSetupPanelProps<C extends string = string> {
183
184
  idPrefix: string;
@@ -189,6 +190,8 @@ interface PlayerSetupPanelProps<C extends string = string> {
189
190
  swatches: SeedColor[];
190
191
  takenColor?: string;
191
192
  allowSwapTaken?: boolean;
193
+ colorCaption?: string;
194
+ showColor?: boolean;
192
195
  colorAlignOverride?: AlignResult;
193
196
  colorRotation?: PopoverRotation;
194
197
  isHuman: boolean;
@@ -207,11 +210,10 @@ interface PlayerSetupPanelProps<C extends string = string> {
207
210
  cpuDifficulty?: C;
208
211
  cpuDifficultyOptions?: LabeledDropdownOption<C>[];
209
212
  onCpuDifficultyChange?: (value: C) => void;
210
- cpuDifficultyAlignOverride?: AlignResult;
211
213
  humanIcon?: string;
212
214
  cpuIcon?: string;
213
215
  }
214
- declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
216
+ declare function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon, cpuIcon }: PlayerSetupPanelProps<C>): react.JSX.Element;
215
217
 
216
218
  interface Props$3 {
217
219
  visible: boolean;
@@ -277,4 +279,4 @@ declare function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack
277
279
 
278
280
  declare function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation?: PopoverRotation): ReturnType<typeof useAutoAlign>;
279
281
 
280
- export { AchievementUnlockList, type AchievementUnlockOwner, type AlignResult, ControlSchemePicker, type ControlSchemePickerProps, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, TriggerGaugeHost, type TriggerGaugeProps, type UnlockedAchievement, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, loadSkiaWeb, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };
282
+ export { AchievementUnlockList, type AchievementUnlockOwner, type AlignResult, ControlSchemePicker, type ControlSchemePickerProps, type CornerStatusBadgeSeat, CornerStatusBadges, type CornerStatusBadgesProps, InlineColorPicker, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption, type MenuOption, type MenuSection, type MultiSelectSection, type OrientationMode, PlayerSetupPanel, type PlayerSetupPanelProps, type PopoverAlign, PopoverBody, type PopoverHost, type PopoverRotation, type PopoverVerticalAlign, PressAwayOverlay, type QuitConfirmation, ReadyButton, SectionedDropdown, SharedActionBand, type SingleSelectSection, TriggerGaugeHost, type TriggerGaugeProps, type UnlockedAchievement, getColorPopoverId, getCpuDifficultyPopoverId, getInlineColorPickerContentSize, getLabeledDropdownContentHeight, getProfilePopoverId, loadSkiaWeb, useAutoAlign, usePopoverHost, useQuitConfirmation, useZoneClampedAlign };
package/dist/index.js CHANGED
@@ -204,6 +204,7 @@ __export(index_exports, {
204
204
  getCpuDifficultyPopoverId: () => getCpuDifficultyPopoverId,
205
205
  getInlineColorPickerContentSize: () => getInlineColorPickerContentSize,
206
206
  getLabeledDropdownContentHeight: () => getLabeledDropdownContentHeight,
207
+ getProfilePopoverId: () => getProfilePopoverId,
207
208
  getStaggeredWordDuration: () => getStaggeredWordDuration,
208
209
  isCompactDialogHeight: () => isCompactDialogHeight,
209
210
  loadSkiaWeb: () => loadSkiaWeb,
@@ -944,7 +945,7 @@ function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", t
944
945
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
945
946
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_react_native_paper10.Text, { variant: titleVariant, style: [styles11.title, { color: fg }], children: title })
946
947
  ] }),
947
- /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.ScrollView, { style: styles11.scrollView, contentContainerStyle: [styles11.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
948
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_auto_paper10.FadingScrollView, { style: styles11.scrollView, contentContainerStyle: [styles11.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
948
949
  children,
949
950
  showReset && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_feedback_press3.Button, { mode: "contained", onPress: () => setConfirmResetVisible(true), buttonColor: colors.danger, textColor: colors.onDanger, style: styles11.resetButton, children: resetLabel })
950
951
  ] }),
@@ -985,7 +986,7 @@ var styles11 = import_react_native14.StyleSheet.create({
985
986
  resetButton: {
986
987
  marginTop: 16
987
988
  },
988
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
989
+ // Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
989
990
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
990
991
  // can render at its full unclipped content height instead of the screen's), which leaves the
991
992
  // header's back button touch target unreliable underneath it.
@@ -1668,7 +1669,7 @@ function loadSkiaWeb() {
1668
1669
 
1669
1670
  // src/PlayerSetupPanel.tsx
1670
1671
  var import_profile2 = require("@tastic/profile");
1671
- var import_react_native26 = require("react-native");
1672
+ var import_react_native27 = require("react-native");
1672
1673
  var import_react_native_paper16 = require("react-native-paper");
1673
1674
 
1674
1675
  // src/ReadyButton.tsx
@@ -1705,32 +1706,79 @@ function usePopoverHost() {
1705
1706
  return { openId, toggle, close };
1706
1707
  }
1707
1708
 
1709
+ // src/useZoneClampedAlign.ts
1710
+ var import_split_screen = require("@tastic/split-screen");
1711
+ var import_react12 = require("react");
1712
+ var import_react_native26 = require("react-native");
1713
+ var import_react_native_safe_area_context3 = require("react-native-safe-area-context");
1714
+ var EDGE_MARGIN2 = 12;
1715
+ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
1716
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
1717
+ const zone = (0, import_split_screen.useZoneBounds)();
1718
+ const { height: windowHeight } = (0, import_react_native26.useWindowDimensions)();
1719
+ const insets = (0, import_react_native_safe_area_context3.useSafeAreaInsets)();
1720
+ const shouldMeasure = open && auto.measured && zone !== null;
1721
+ const [ownBounds, setOwnBounds] = (0, import_react12.useState)(null);
1722
+ const [prevShouldMeasure, setPrevShouldMeasure] = (0, import_react12.useState)(shouldMeasure);
1723
+ if (shouldMeasure !== prevShouldMeasure) {
1724
+ setPrevShouldMeasure(shouldMeasure);
1725
+ setOwnBounds(null);
1726
+ }
1727
+ (0, import_react12.useEffect)(() => {
1728
+ if (!shouldMeasure) return;
1729
+ auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
1730
+ const belowShared = zone.zoneSide === "belowShared";
1731
+ const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
1732
+ const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
1733
+ const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
1734
+ let awaySide = belowShared ? "below" : "above";
1735
+ let towardSide = belowShared ? "above" : "below";
1736
+ if (zone.rotated) {
1737
+ awaySide = awaySide === "above" ? "below" : "above";
1738
+ towardSide = towardSide === "above" ? "below" : "above";
1739
+ }
1740
+ setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
1741
+ });
1742
+ }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
1743
+ if (!zone) return auto;
1744
+ if (!ownBounds) return { ...auto, measured: false };
1745
+ return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
1746
+ }
1747
+
1708
1748
  // src/PlayerSetupPanel.tsx
1709
1749
  var import_jsx_runtime25 = require("react/jsx-runtime");
1710
1750
  function getColorPopoverId(idPrefix) {
1711
1751
  return `${idPrefix}-color`;
1712
1752
  }
1753
+ function getProfilePopoverId(idPrefix) {
1754
+ return `${idPrefix}-profile`;
1755
+ }
1713
1756
  function getCpuDifficultyPopoverId(idPrefix) {
1714
1757
  return `${idPrefix}-difficulty`;
1715
1758
  }
1716
- function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = "face-man", cpuIcon = "robot" }) {
1759
+ function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor = true, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon = "face-man", cpuIcon = "robot" }) {
1717
1760
  const ownHost = usePopoverHost();
1718
1761
  const popover = host ?? ownHost;
1719
1762
  const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false;
1720
1763
  const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || secondPickerId !== void 0 && popover.openId === secondPickerId;
1721
1764
  const showProfilePicker = isHuman && profiles !== void 0 && !!onProfileSelect;
1722
1765
  const showCpuDifficulty = !isHuman && cpuDifficulty !== void 0 && cpuDifficultyOptions !== void 0 && !!onCpuDifficultyChange;
1766
+ const seatButton = showProfilePicker || showCpuDifficulty;
1723
1767
  const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null;
1724
- return /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_react_native26.View, { style: [styles21.panel, ownPopoverOpen && styles21.panelOpen], children: [
1725
- showProfilePicker ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_profile2.ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles }) : showCpuDifficulty ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(LabeledDropdown, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, alignOverride: cpuDifficultyAlignOverride }) : label && /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_react_native_paper16.Text, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
1726
- /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_react_native26.View, { style: [styles21.pickerRow, pickerRowPopoverOpen && styles21.pickerRowOpen], children: [
1727
- /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
1768
+ const colorSection = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : void 0 } : void 0;
1769
+ const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix);
1770
+ const seatPickerSize = showProfilePicker ? (0, import_profile2.getProfilePickerContentSize)(profiles.length, !!onManageProfiles, colorSection) : (0, import_profile2.getOptionPickerContentSize)(cpuDifficultyOptions?.length ?? 0, colorSection);
1771
+ const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height);
1772
+ return /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_react_native27.View, { style: [styles21.panel, ownPopoverOpen && styles21.panelOpen], children: [
1773
+ showProfilePicker ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_profile2.ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles, colorSection, alignOverride: seatPickerAlign }) : showCpuDifficulty ? /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_profile2.OptionPicker, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, icon: cpuIcon, colorSection, alignOverride: seatPickerAlign }) : label && /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(import_react_native_paper16.Text, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
1774
+ (!seatButton || secondPicker) && /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(import_react_native27.View, { style: [styles21.pickerRow, pickerRowPopoverOpen && styles21.pickerRowOpen], children: [
1775
+ !seatButton && /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
1728
1776
  secondPicker
1729
1777
  ] }),
1730
1778
  isHuman && showReadyButton && onToggleReady && /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(ReadyButton, { color, ready: ready ?? false, onToggleReady })
1731
1779
  ] });
1732
1780
  }
1733
- var styles21 = import_react_native26.StyleSheet.create({
1781
+ var styles21 = import_react_native27.StyleSheet.create({
1734
1782
  panel: {
1735
1783
  alignItems: "center",
1736
1784
  gap: 12
@@ -1741,9 +1789,9 @@ var styles21 = import_react_native26.StyleSheet.create({
1741
1789
  panelOpen: {
1742
1790
  zIndex: 100
1743
1791
  },
1744
- // Same gap as `panel`'s own vertical rhythm, reused horizontally - the color picker and (when
1745
- // present) secondPicker sit side by side within this row instead of stacked in the panel's own
1746
- // column.
1792
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
1793
+ // (when present) secondPicker sit side by side within this row instead of stacked in the panel's
1794
+ // own column.
1747
1795
  pickerRow: {
1748
1796
  alignItems: "center",
1749
1797
  flexDirection: "row",
@@ -1762,20 +1810,20 @@ var styles21 = import_react_native26.StyleSheet.create({
1762
1810
  });
1763
1811
 
1764
1812
  // src/PressAwayOverlay.tsx
1765
- var import_react_native27 = require("react-native");
1813
+ var import_react_native28 = require("react-native");
1766
1814
  var import_jsx_runtime26 = require("react/jsx-runtime");
1767
1815
  function PressAwayOverlay({ active, onPress, style }) {
1768
1816
  if (!active) return null;
1769
- return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_react_native27.Pressable, { accessibilityLabel: "Dismiss", accessibilityRole: "button", onPress, style: [import_react_native27.StyleSheet.absoluteFill, style] });
1817
+ return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(import_react_native28.Pressable, { accessibilityLabel: "Dismiss", accessibilityRole: "button", onPress, style: [import_react_native28.StyleSheet.absoluteFill, style] });
1770
1818
  }
1771
1819
 
1772
1820
  // src/SharedActionBand.tsx
1773
1821
  var import_feedback_press9 = require("@rific/feedback-press");
1774
- var import_react_native28 = require("react-native");
1822
+ var import_react_native29 = require("react-native");
1775
1823
  var import_jsx_runtime27 = require("react/jsx-runtime");
1776
1824
  function SharedActionBand({ onBack, onSettings, onRandomize, onReset, fg, popoverOpen, children }) {
1777
- return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_react_native28.View, { style: [styles22.column, popoverOpen && styles22.columnOpen], children: [
1778
- /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_react_native28.View, { style: styles22.actionsRow, children: [
1825
+ return /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_react_native29.View, { style: [styles22.column, popoverOpen && styles22.columnOpen], children: [
1826
+ /* @__PURE__ */ (0, import_jsx_runtime27.jsxs)(import_react_native29.View, { style: styles22.actionsRow, children: [
1779
1827
  /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_feedback_press9.IconButton, { icon: "arrow-left", iconColor: fg, size: 20, onPress: onBack, accessibilityLabel: "Back" }),
1780
1828
  onRandomize && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_feedback_press9.IconButton, { icon: "dice-multiple", iconColor: fg, size: 18, onPress: onRandomize, accessibilityLabel: "Randomize match settings" }),
1781
1829
  onReset && /* @__PURE__ */ (0, import_jsx_runtime27.jsx)(import_feedback_press9.IconButton, { icon: "restore", iconColor: fg, size: 18, onPress: onReset, accessibilityLabel: "Reset match settings to defaults" }),
@@ -1784,7 +1832,7 @@ function SharedActionBand({ onBack, onSettings, onRandomize, onReset, fg, popove
1784
1832
  children
1785
1833
  ] });
1786
1834
  }
1787
- var styles22 = import_react_native28.StyleSheet.create({
1835
+ var styles22 = import_react_native29.StyleSheet.create({
1788
1836
  actionsRow: {
1789
1837
  alignItems: "center",
1790
1838
  flexDirection: "row",
@@ -1800,8 +1848,8 @@ var styles22 = import_react_native28.StyleSheet.create({
1800
1848
  });
1801
1849
 
1802
1850
  // src/StaggeredWord.tsx
1803
- var import_react12 = require("react");
1804
- var import_react_native29 = require("react-native");
1851
+ var import_react13 = require("react");
1852
+ var import_react_native30 = require("react-native");
1805
1853
  var import_react_native_reanimated2 = __toESM(require("react-native-reanimated"));
1806
1854
  var import_jsx_runtime28 = require("react/jsx-runtime");
1807
1855
  var STAGGER_MS = 45;
@@ -1811,7 +1859,7 @@ function getStaggeredWordDuration(letterCount) {
1811
1859
  }
1812
1860
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
1813
1861
  const progress = (0, import_react_native_reanimated2.useSharedValue)(reducedMotion ? 1 : 0);
1814
- (0, import_react12.useEffect)(() => {
1862
+ (0, import_react13.useEffect)(() => {
1815
1863
  if (reducedMotion) return;
1816
1864
  progress.value = (0, import_react_native_reanimated2.withDelay)(index * STAGGER_MS, (0, import_react_native_reanimated2.withTiming)(1, { duration: LETTER_DURATION_MS, easing: import_react_native_reanimated2.Easing.out(import_react_native_reanimated2.Easing.back(1.5)) }));
1817
1865
  }, [reducedMotion]);
@@ -1822,9 +1870,9 @@ function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSiz
1822
1870
  return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(import_react_native_reanimated2.default.Text, { allowFontScaling: false, style: [{ color, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }, style], children: char });
1823
1871
  }
1824
1872
  function StaggeredWord({ word, color, fontFamily, fontSize, lineHeight, reducedMotion, startIndex = 0, fontWeight = "bold", letterSpacing, style, onLayout }) {
1825
- return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(import_react_native29.View, { style: [styles23.row, style], onLayout, children: word.split("").map((char, i) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(AnimatedLetter, { char, index: startIndex + i, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }, i)) });
1873
+ return /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(import_react_native30.View, { style: [styles23.row, style], onLayout, children: word.split("").map((char, i) => /* @__PURE__ */ (0, import_jsx_runtime28.jsx)(AnimatedLetter, { char, index: startIndex + i, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }, i)) });
1826
1874
  }
1827
- var styles23 = import_react_native29.StyleSheet.create({
1875
+ var styles23 = import_react_native30.StyleSheet.create({
1828
1876
  row: {
1829
1877
  flexDirection: "row"
1830
1878
  }
@@ -1841,63 +1889,24 @@ function UpdateDialog({ rotation = 0, autoCheck = true, autoPrompt = true, onErr
1841
1889
  }
1842
1890
 
1843
1891
  // src/useQuitConfirmation.ts
1844
- var import_react13 = require("react");
1892
+ var import_react14 = require("react");
1845
1893
  function useQuitConfirmation(hasProgress, onConfirmedBack) {
1846
- const [confirmVisible, setConfirmVisible] = (0, import_react13.useState)(false);
1847
- const hasProgressRef = (0, import_react13.useRef)(hasProgress);
1848
- (0, import_react13.useEffect)(() => {
1894
+ const [confirmVisible, setConfirmVisible] = (0, import_react14.useState)(false);
1895
+ const hasProgressRef = (0, import_react14.useRef)(hasProgress);
1896
+ (0, import_react14.useEffect)(() => {
1849
1897
  hasProgressRef.current = hasProgress;
1850
1898
  });
1851
- const onConfirmedBackRef = (0, import_react13.useRef)(onConfirmedBack);
1852
- (0, import_react13.useEffect)(() => {
1899
+ const onConfirmedBackRef = (0, import_react14.useRef)(onConfirmedBack);
1900
+ (0, import_react14.useEffect)(() => {
1853
1901
  onConfirmedBackRef.current = onConfirmedBack;
1854
1902
  });
1855
- const requestBack = (0, import_react13.useCallback)(() => {
1903
+ const requestBack = (0, import_react14.useCallback)(() => {
1856
1904
  if (hasProgressRef.current()) setConfirmVisible(true);
1857
1905
  else onConfirmedBackRef.current();
1858
1906
  }, []);
1859
- const cancelBack = (0, import_react13.useCallback)(() => setConfirmVisible(false), []);
1907
+ const cancelBack = (0, import_react14.useCallback)(() => setConfirmVisible(false), []);
1860
1908
  return { confirmVisible, requestBack, cancelBack };
1861
1909
  }
1862
-
1863
- // src/useZoneClampedAlign.ts
1864
- var import_split_screen = require("@tastic/split-screen");
1865
- var import_react14 = require("react");
1866
- var import_react_native30 = require("react-native");
1867
- var import_react_native_safe_area_context3 = require("react-native-safe-area-context");
1868
- var EDGE_MARGIN2 = 12;
1869
- function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
1870
- const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
1871
- const zone = (0, import_split_screen.useZoneBounds)();
1872
- const { height: windowHeight } = (0, import_react_native30.useWindowDimensions)();
1873
- const insets = (0, import_react_native_safe_area_context3.useSafeAreaInsets)();
1874
- const shouldMeasure = open && auto.measured && zone !== null;
1875
- const [ownBounds, setOwnBounds] = (0, import_react14.useState)(null);
1876
- const [prevShouldMeasure, setPrevShouldMeasure] = (0, import_react14.useState)(shouldMeasure);
1877
- if (shouldMeasure !== prevShouldMeasure) {
1878
- setPrevShouldMeasure(shouldMeasure);
1879
- setOwnBounds(null);
1880
- }
1881
- (0, import_react14.useEffect)(() => {
1882
- if (!shouldMeasure) return;
1883
- auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
1884
- const belowShared = zone.zoneSide === "belowShared";
1885
- const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
1886
- const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
1887
- const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
1888
- let awaySide = belowShared ? "below" : "above";
1889
- let towardSide = belowShared ? "above" : "below";
1890
- if (zone.rotated) {
1891
- awaySide = awaySide === "above" ? "below" : "above";
1892
- towardSide = towardSide === "above" ? "below" : "above";
1893
- }
1894
- setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
1895
- });
1896
- }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
1897
- if (!zone) return auto;
1898
- if (!ownBounds) return { ...auto, measured: false };
1899
- return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
1900
- }
1901
1910
  // Annotate the CommonJS export names for ESM import in node:
1902
1911
  0 && (module.exports = {
1903
1912
  AchievementCatalogSection,
@@ -1937,6 +1946,7 @@ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
1937
1946
  getCpuDifficultyPopoverId,
1938
1947
  getInlineColorPickerContentSize,
1939
1948
  getLabeledDropdownContentHeight,
1949
+ getProfilePopoverId,
1940
1950
  getStaggeredWordDuration,
1941
1951
  isCompactDialogHeight,
1942
1952
  loadSkiaWeb,
package/dist/index.mjs CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  UpdateDialog,
20
20
  getStaggeredWordDuration,
21
21
  useCompactDialog
22
- } from "./chunk-DK3HOZC5.mjs";
22
+ } from "./chunk-UOA35OWJ.mjs";
23
23
  import {
24
24
  COMPACT_DIALOG_HEIGHT,
25
25
  DialogHeader,
@@ -683,7 +683,7 @@ function loadSkiaWeb() {
683
683
  }
684
684
 
685
685
  // src/PlayerSetupPanel.tsx
686
- import { ProfilePicker } from "@tastic/profile";
686
+ import { getOptionPickerContentSize, getProfilePickerContentSize, OptionPicker, ProfilePicker } from "@tastic/profile";
687
687
  import { StyleSheet as StyleSheet8, View as View8 } from "react-native";
688
688
  import { Text as Text6 } from "react-native-paper";
689
689
 
@@ -721,26 +721,73 @@ function usePopoverHost() {
721
721
  return { openId, toggle, close };
722
722
  }
723
723
 
724
+ // src/useZoneClampedAlign.ts
725
+ import { useZoneBounds } from "@tastic/split-screen";
726
+ import { useEffect as useEffect2, useState as useState3 } from "react";
727
+ import { useWindowDimensions as useWindowDimensions3 } from "react-native";
728
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
729
+ var EDGE_MARGIN2 = 12;
730
+ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
731
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
732
+ const zone = useZoneBounds();
733
+ const { height: windowHeight } = useWindowDimensions3();
734
+ const insets = useSafeAreaInsets();
735
+ const shouldMeasure = open && auto.measured && zone !== null;
736
+ const [ownBounds, setOwnBounds] = useState3(null);
737
+ const [prevShouldMeasure, setPrevShouldMeasure] = useState3(shouldMeasure);
738
+ if (shouldMeasure !== prevShouldMeasure) {
739
+ setPrevShouldMeasure(shouldMeasure);
740
+ setOwnBounds(null);
741
+ }
742
+ useEffect2(() => {
743
+ if (!shouldMeasure) return;
744
+ auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
745
+ const belowShared = zone.zoneSide === "belowShared";
746
+ const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
747
+ const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
748
+ const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
749
+ let awaySide = belowShared ? "below" : "above";
750
+ let towardSide = belowShared ? "above" : "below";
751
+ if (zone.rotated) {
752
+ awaySide = awaySide === "above" ? "below" : "above";
753
+ towardSide = towardSide === "above" ? "below" : "above";
754
+ }
755
+ setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
756
+ });
757
+ }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
758
+ if (!zone) return auto;
759
+ if (!ownBounds) return { ...auto, measured: false };
760
+ return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
761
+ }
762
+
724
763
  // src/PlayerSetupPanel.tsx
725
764
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
726
765
  function getColorPopoverId(idPrefix) {
727
766
  return `${idPrefix}-color`;
728
767
  }
768
+ function getProfilePopoverId(idPrefix) {
769
+ return `${idPrefix}-profile`;
770
+ }
729
771
  function getCpuDifficultyPopoverId(idPrefix) {
730
772
  return `${idPrefix}-difficulty`;
731
773
  }
732
- function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = "face-man", cpuIcon = "robot" }) {
774
+ function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor = true, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon = "face-man", cpuIcon = "robot" }) {
733
775
  const ownHost = usePopoverHost();
734
776
  const popover = host ?? ownHost;
735
777
  const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false;
736
778
  const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || secondPickerId !== void 0 && popover.openId === secondPickerId;
737
779
  const showProfilePicker = isHuman && profiles !== void 0 && !!onProfileSelect;
738
780
  const showCpuDifficulty = !isHuman && cpuDifficulty !== void 0 && cpuDifficultyOptions !== void 0 && !!onCpuDifficultyChange;
781
+ const seatButton = showProfilePicker || showCpuDifficulty;
739
782
  const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null;
783
+ const colorSection = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : void 0 } : void 0;
784
+ const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix);
785
+ const seatPickerSize = showProfilePicker ? getProfilePickerContentSize(profiles.length, !!onManageProfiles, colorSection) : getOptionPickerContentSize(cpuDifficultyOptions?.length ?? 0, colorSection);
786
+ const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height);
740
787
  return /* @__PURE__ */ jsxs7(View8, { style: [styles8.panel, ownPopoverOpen && styles8.panelOpen], children: [
741
- showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(LabeledDropdown, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, alignOverride: cpuDifficultyAlignOverride }) : label && /* @__PURE__ */ jsx10(Text6, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
742
- /* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
743
- /* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
788
+ showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles, colorSection, alignOverride: seatPickerAlign }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(OptionPicker, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, icon: cpuIcon, colorSection, alignOverride: seatPickerAlign }) : label && /* @__PURE__ */ jsx10(Text6, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
789
+ (!seatButton || secondPicker) && /* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
790
+ !seatButton && /* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
744
791
  secondPicker
745
792
  ] }),
746
793
  isHuman && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
@@ -757,9 +804,9 @@ var styles8 = StyleSheet8.create({
757
804
  panelOpen: {
758
805
  zIndex: 100
759
806
  },
760
- // Same gap as `panel`'s own vertical rhythm, reused horizontally - the color picker and (when
761
- // present) secondPicker sit side by side within this row instead of stacked in the panel's own
762
- // column.
807
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
808
+ // (when present) secondPicker sit side by side within this row instead of stacked in the panel's
809
+ // own column.
763
810
  pickerRow: {
764
811
  alignItems: "center",
765
812
  flexDirection: "row",
@@ -816,15 +863,15 @@ var styles9 = StyleSheet10.create({
816
863
  });
817
864
 
818
865
  // src/useQuitConfirmation.ts
819
- import { useCallback as useCallback3, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
866
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef2, useState as useState4 } from "react";
820
867
  function useQuitConfirmation(hasProgress, onConfirmedBack) {
821
- const [confirmVisible, setConfirmVisible] = useState3(false);
868
+ const [confirmVisible, setConfirmVisible] = useState4(false);
822
869
  const hasProgressRef = useRef2(hasProgress);
823
- useEffect2(() => {
870
+ useEffect3(() => {
824
871
  hasProgressRef.current = hasProgress;
825
872
  });
826
873
  const onConfirmedBackRef = useRef2(onConfirmedBack);
827
- useEffect2(() => {
874
+ useEffect3(() => {
828
875
  onConfirmedBackRef.current = onConfirmedBack;
829
876
  });
830
877
  const requestBack = useCallback3(() => {
@@ -834,45 +881,6 @@ function useQuitConfirmation(hasProgress, onConfirmedBack) {
834
881
  const cancelBack = useCallback3(() => setConfirmVisible(false), []);
835
882
  return { confirmVisible, requestBack, cancelBack };
836
883
  }
837
-
838
- // src/useZoneClampedAlign.ts
839
- import { useZoneBounds } from "@tastic/split-screen";
840
- import { useEffect as useEffect3, useState as useState4 } from "react";
841
- import { useWindowDimensions as useWindowDimensions3 } from "react-native";
842
- import { useSafeAreaInsets } from "react-native-safe-area-context";
843
- var EDGE_MARGIN2 = 12;
844
- function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
845
- const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
846
- const zone = useZoneBounds();
847
- const { height: windowHeight } = useWindowDimensions3();
848
- const insets = useSafeAreaInsets();
849
- const shouldMeasure = open && auto.measured && zone !== null;
850
- const [ownBounds, setOwnBounds] = useState4(null);
851
- const [prevShouldMeasure, setPrevShouldMeasure] = useState4(shouldMeasure);
852
- if (shouldMeasure !== prevShouldMeasure) {
853
- setPrevShouldMeasure(shouldMeasure);
854
- setOwnBounds(null);
855
- }
856
- useEffect3(() => {
857
- if (!shouldMeasure) return;
858
- auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
859
- const belowShared = zone.zoneSide === "belowShared";
860
- const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
861
- const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
862
- const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
863
- let awaySide = belowShared ? "below" : "above";
864
- let towardSide = belowShared ? "above" : "below";
865
- if (zone.rotated) {
866
- awaySide = awaySide === "above" ? "below" : "above";
867
- towardSide = towardSide === "above" ? "below" : "above";
868
- }
869
- setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
870
- });
871
- }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
872
- if (!zone) return auto;
873
- if (!ownBounds) return { ...auto, measured: false };
874
- return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
875
- }
876
884
  export {
877
885
  AchievementCatalogSection,
878
886
  AchievementRow,
@@ -911,6 +919,7 @@ export {
911
919
  getCpuDifficultyPopoverId,
912
920
  getInlineColorPickerContentSize,
913
921
  getLabeledDropdownContentHeight,
922
+ getProfilePopoverId,
914
923
  getStaggeredWordDuration,
915
924
  isCompactDialogHeight,
916
925
  loadSkiaWeb,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -85,16 +85,16 @@
85
85
  "prettier": "@infinitetoken/eslint-config/prettier",
86
86
  "devDependencies": {
87
87
  "@infinitetoken/eslint-config": "^0.2.0",
88
- "@infinitetoken/jest-config": "^0.4.0",
89
- "@infinitetoken/tsconfig": "^0.5.0",
90
- "@rific/auto-paper": "^0.13.2",
88
+ "@infinitetoken/jest-config": "^0.5.2",
89
+ "@infinitetoken/tsconfig": "^0.6.0",
90
+ "@rific/auto-paper": "^0.13.3",
91
91
  "@rific/core": "^0.3.1",
92
92
  "@rific/feedback-press": "^0.13.0",
93
93
  "@rific/updater": "^0.7.1",
94
- "@shopify/react-native-skia": "^2.6.2",
94
+ "@shopify/react-native-skia": "^2.14.0",
95
95
  "@tastic/achievements": "^0.6.0",
96
96
  "@tastic/core": "^0.14.0",
97
- "@tastic/profile": "^0.13.0",
97
+ "@tastic/profile": "^0.15.0",
98
98
  "@tastic/split-screen": "^0.5.2",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
@@ -108,10 +108,10 @@
108
108
  "prettier": "^3.8.3",
109
109
  "react": "^19.0.0",
110
110
  "react-dom": "^19.0.0",
111
- "react-native": "^0.86.3",
111
+ "react-native": "^0.88.0",
112
112
  "react-native-paper": "^5.15.2",
113
- "react-native-reanimated": "^4.5.1",
114
- "react-native-safe-area-context": "^5.10.0",
113
+ "react-native-reanimated": "^4.7.1",
114
+ "react-native-safe-area-context": "^5.10.1",
115
115
  "tsup": "^8.0.0",
116
116
  "typescript": "^6.0.3"
117
117
  },
@@ -122,7 +122,7 @@
122
122
  "@shopify/react-native-skia": ">=2.6.0",
123
123
  "@tastic/achievements": ">=0.2.0",
124
124
  "@tastic/core": ">=0.12.0",
125
- "@tastic/profile": ">=0.4.0",
125
+ "@tastic/profile": ">=0.15.0",
126
126
  "@tastic/split-screen": ">=0.5.0",
127
127
  "react": ">=19.0.0",
128
128
  "react-native": ">=0.76.0",
@@ -1,8 +1,8 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, IconButton } from '@rific/feedback-press'
3
3
  import { useRotation, type ViewRotation } from '@tastic/core'
4
4
  import { ReactNode, useState } from 'react'
5
- import { type Insets, ScrollView, StyleSheet, View } from 'react-native'
5
+ import { type Insets, StyleSheet, View } from 'react-native'
6
6
  import { Text } from 'react-native-paper'
7
7
 
8
8
  import { ConfirmDialog } from './ConfirmDialog'
@@ -101,7 +101,9 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
101
101
  </Text>
102
102
  </View>
103
103
 
104
- <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
104
+ {/* Fades out at an edge while there is more past it, the same cue as the Settings sheet's rows and the
105
+ Profiles screen's (Jay, 2026-10-16: every scrolling screen alike). */}
106
+ <FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
105
107
  {children}
106
108
 
107
109
  {showReset && (
@@ -109,7 +111,7 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
109
111
  {resetLabel}
110
112
  </Button>
111
113
  )}
112
- </ScrollView>
114
+ </FadingScrollView>
113
115
 
114
116
  <ConfirmDialog
115
117
  visible={confirmResetVisible}
@@ -147,7 +149,7 @@ const styles = StyleSheet.create({
147
149
  resetButton: {
148
150
  marginTop: 16
149
151
  },
150
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
152
+ // Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
151
153
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
152
154
  // can render at its full unclipped content height instead of the screen's), which leaves the
153
155
  // header's back button touch target unreliable underneath it.
@@ -1,31 +1,35 @@
1
1
  import { SeedColor } from '@rific/auto-paper'
2
- import { Profile, ProfilePicker } from '@tastic/profile'
2
+ import { getOptionPickerContentSize, getProfilePickerContentSize, OptionPicker, Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
3
3
  import { ReactNode } from 'react'
4
4
  import { StyleSheet, View } from 'react-native'
5
5
  import { Text } from 'react-native-paper'
6
6
 
7
7
  import { InlineColorPicker } from './InlineColorPicker'
8
- import { LabeledDropdown, LabeledDropdownOption } from './LabeledDropdown'
8
+ import { LabeledDropdownOption } from './LabeledDropdown'
9
9
  import { ReadyButton } from './ReadyButton'
10
10
  import { AlignResult, PopoverRotation } from './useAutoAlign'
11
11
  import { PopoverHost, usePopoverHost } from './usePopoverHost'
12
+ import { useZoneClampedAlign } from './useZoneClampedAlign'
12
13
 
13
- // This panel's own internal color/CPU-difficulty popover ids, derived from idPrefix - exported so a
14
- // caller that needs to know whether one of them is currently open (e.g. to compute the `open`
15
- // argument useZoneClampedAlign's own first parameter needs, ahead of handing its result to
16
- // colorAlignOverride/cpuDifficultyAlignOverride below) doesn't have to hardcode or guess this
17
- // panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
14
+ // This panel's own internal popover ids, derived from idPrefix - exported so a caller that needs to
15
+ // know whether one of them is currently open (e.g. to elevate the zone or row holding the panel)
16
+ // doesn't have to hardcode or guess this panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
18
17
  // externally-constructed picker - this is the equivalent for the two pickers this panel owns
19
18
  // itself, which have no prop of their own to name their id with.
20
19
  export function getColorPopoverId(idPrefix: string): string {
21
20
  return `${idPrefix}-color`
22
21
  }
22
+ // The seat button's dropdown on a human seat (@tastic/profile's ProfilePicker opens under
23
+ // `${idPrefix}-profile`). A CPU seat's opens under getCpuDifficultyPopoverId.
24
+ export function getProfilePopoverId(idPrefix: string): string {
25
+ return `${idPrefix}-profile`
26
+ }
23
27
  export function getCpuDifficultyPopoverId(idPrefix: string): string {
24
28
  return `${idPrefix}-difficulty`
25
29
  }
26
30
 
27
31
  export interface PlayerSetupPanelProps<C extends string = string> {
28
- // Namespaces this panel's popover ids ('color', whatever secondPickerId names, 'difficulty') so
32
+ // Namespaces this panel's popover ids ('profile', 'difficulty', 'color', whatever secondPickerId names) so
29
33
  // two panels can safely share one host (see `host` below) without their ids colliding.
30
34
  idPrefix: string
31
35
  // Shared popover host, when this panel's own popovers should be mutually exclusive with another
@@ -35,7 +39,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
35
39
  // real people editing at once is the point.
36
40
  host?: PopoverHost
37
41
  label?: string
38
- // Applied only to the plain `label` fallback text below (ProfilePicker/LabeledDropdown render
42
+ // Applied only to the plain `label` fallback text below (ProfilePicker/OptionPicker render
39
43
  // their own fixed chrome regardless of this prop) - omit to inherit whichever font the calling
40
44
  // screen's own theme resolves for a labelSmall Text, rather than this package forcing its own
41
45
  // MONO_FONT onto what's really the host app's own copy.
@@ -44,21 +48,24 @@ export interface PlayerSetupPanelProps<C extends string = string> {
44
48
  onColorChange: (hex: string) => void
45
49
  swatches: SeedColor[]
46
50
  // The other player's current color - stays visible in the swatch grid but disabled, rather than
47
- // removed from it entirely. Unless allowSwapTaken is set - see InlineColorPicker's own doc.
51
+ // removed from it entirely. Unless allowSwapTaken is set (the other seat is the CPU), which lets
52
+ // it be picked so the caller can swap the two - see InlineColorPicker's own doc.
48
53
  takenColor?: string
49
54
  allowSwapTaken?: boolean
50
- // Passed straight through to the internal InlineColorPicker's own `alignOverride` - this panel
51
- // has no way for a caller to reach that picker directly (it's not a ReactNode slot the way
52
- // secondPicker is, see that prop's own doc), so a caller inside a @tastic/split-screen zone
53
- // needs this to substitute useZoneClampedAlign's result for the color picker's own popover.
55
+ // A short line under the seat button's Color row while a profile is selected, saying where the
56
+ // color applies (e.g. 'Snake only': the pick is kept in this game, not on the profile). Left out
57
+ // for a guest or the CPU, who have no color anywhere else.
58
+ colorCaption?: string
59
+ // False leaves the Color row out of the seat button's dropdown (e.g. a CPU seat set to "None",
60
+ // with no second player to color). Defaults to true.
61
+ showColor?: boolean
62
+ // The fallback color swatch's placement and rotation, passed straight through to its
63
+ // InlineColorPicker. That swatch is only drawn on a seat with neither a profile picker nor a CPU
64
+ // difficulty picker (see the header below); the seat button places its own dropdown.
54
65
  colorAlignOverride?: AlignResult
55
- // Passed straight through to the internal InlineColorPicker's own `rotation` - same reasoning as
56
- // colorAlignOverride above (no other way for a caller to reach that picker directly), for a
57
- // caller whose own rotation reading needs to differ from InlineColorPicker's default ambient
58
- // useRotation() read (see that component's own `rotation` doc for when that's worth doing).
59
66
  colorRotation?: PopoverRotation
60
67
  isHuman: boolean
61
- // Whatever sits beside the color picker in pickerRow, if anything - a ControlSchemePicker, a raw
68
+ // Whatever sits under the seat button in pickerRow, if anything - a ControlSchemePicker, a raw
62
69
  // SectionedDropdown, or nothing at all. The five apps this component was extracted from each
63
70
  // duplicated this entire panel and differed only in what (if anything) occupied this one slot,
64
71
  // each wanting a genuinely different concept there (which physical input drives a seat, vs. a
@@ -69,7 +76,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
69
76
  secondPicker?: ReactNode
70
77
  // The popover id secondPicker opens under, if it opens one at all - lets pickerRow's own
71
78
  // "elevate above later siblings while one of my own popovers is open" logic (see pickerRowOpen)
72
- // react to secondPicker's popover the same way it already does for the color picker's, without
79
+ // react to secondPicker's popover the same way it already does for the fallback swatch's, without
73
80
  // this component needing any opinion on what secondPicker actually renders. Omit when secondPicker
74
81
  // has no popover of its own, or isn't provided at all.
75
82
  secondPickerId?: string
@@ -82,7 +89,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
82
89
  profiles?: Profile[]
83
90
  selectedProfileId?: string | null
84
91
  takenProfileId?: string | null
85
- // What the name trigger shows before any profile's selected - this seat's own 'P1'/'P2' (see
92
+ // What the seat button shows before any profile's selected - this seat's own 'P1'/'P2' (see
86
93
  // ProfilePicker's own doc for why it's never "Player"). Required whenever profiles is provided,
87
94
  // since the trigger always needs some idle text.
88
95
  guestLabel?: string
@@ -95,29 +102,30 @@ export interface PlayerSetupPanelProps<C extends string = string> {
95
102
  cpuDifficulty?: C
96
103
  cpuDifficultyOptions?: LabeledDropdownOption<C>[]
97
104
  onCpuDifficultyChange?: (value: C) => void
98
- // Passed straight through to the internal LabeledDropdown's own `alignOverride` - same reasoning
99
- // as colorAlignOverride above, for the CPU-difficulty picker instead of the color picker.
100
- cpuDifficultyAlignOverride?: AlignResult
101
- // InlineColorPicker's own `icon` for a human vs. a non-human seat (overridden by a selected
102
- // profile's own `tag`, same precedence InlineColorPicker itself applies) - defaults match every
103
- // consumer's own convention. A caller with its own extra CPU states (e.g. a "no CPU" slot) computes
104
- // its own icon string and passes it through cpuIcon rather than this component needing any opinion
105
- // on what those states are.
105
+ // The icon on a non-human seat's button chip, and on the fallback color swatch for a human vs. a
106
+ // non-human seat (overridden there by a selected profile's own `tag`). A human seat's button chip
107
+ // shows the selected profile's tag, or ProfilePicker's guest icon. A caller with its own extra CPU
108
+ // states (e.g. a "no CPU" slot) computes its own icon string and passes it through cpuIcon rather
109
+ // than this component needing any opinion on what those states are.
106
110
  humanIcon?: string
107
111
  cpuIcon?: string
108
112
  }
109
113
 
110
- // One panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved
111
- // profile switcher for a human seat, a CPU-difficulty picker for a non-human one, or a plain label
112
- // when neither applies), a color picker optionally paired with a second, per-app picker (see
113
- // `secondPicker`'s own doc), and a Ready toggle for human seats. Ported from five apps' own
114
- // near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles,
115
- // Snake), which had all independently converged on this exact skeleton.
114
+ // One panel per player slot in a local-multiplayer loadout/lobby screen: one seat button, an
115
+ // optional second, per-app picker under it (see `secondPicker`'s own doc), and a Ready toggle for
116
+ // human seats. A human seat's button is @tastic/profile's ProfilePicker and a CPU seat's is its
117
+ // OptionPicker (the difficulty levels), each with the seat's color behind a Color row at the top of
118
+ // the same dropdown: Solitaire's player button, made the fleet's seat control (Jay, 2026-10-16). It
119
+ // used to be a profile (or difficulty) dropdown with a separate color swatch beside it, which read
120
+ // as two unrelated things. A seat with neither picker wired keeps the plain label and the separate
121
+ // swatch. Ported from five apps' own near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx
122
+ // (AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
123
+ // exact skeleton.
116
124
  //
117
- // Owns its own popover host shared by its own color/profile/difficulty pickers unless a `host` is
118
- // passed in to share with another panel (or a shared-controls row) instead - same convention as
119
- // every popover-hosting component in this package.
120
- export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = 'face-man', cpuIcon = 'robot' }: PlayerSetupPanelProps<C>) {
125
+ // Owns its own popover host shared by its own pickers unless a `host` is passed in to share with
126
+ // another panel (or a shared-controls row) instead - same convention as every popover-hosting
127
+ // component in this package.
128
+ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor = true, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon = 'face-man', cpuIcon = 'robot' }: PlayerSetupPanelProps<C>) {
121
129
  const ownHost = usePopoverHost()
122
130
  const popover = host ?? ownHost
123
131
  // Checking *this panel's own* ids, not just "is anything open on the host" - when `host` is
@@ -126,22 +134,33 @@ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, la
126
134
  // letting DOM order (wrongly) decide which one paints on top.
127
135
  const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false
128
136
  // Narrower than ownPopoverOpen on purpose - only true for a popover actually inside pickerRow
129
- // itself (color, or secondPicker's own popover when it has one and secondPickerId names it). See
130
- // pickerRowOpen's own style comment for why pickerRow needs this in addition to panelOpen above.
137
+ // itself (the fallback color swatch, or secondPicker's own popover when it has one and
138
+ // secondPickerId names it). See pickerRowOpen's own style comment for why pickerRow needs this in
139
+ // addition to panelOpen above.
131
140
  const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || (secondPickerId !== undefined && popover.openId === secondPickerId)
132
141
  const showProfilePicker = isHuman && profiles !== undefined && !!onProfileSelect
133
142
  const showCpuDifficulty = !isHuman && cpuDifficulty !== undefined && cpuDifficultyOptions !== undefined && !!onCpuDifficultyChange
134
- // The color button's own tag display (see InlineColorPicker) - read live from the selected
135
- // profile rather than snapshotted at selection time, since a caller typically has no other
136
- // editing surface for it on this same screen.
143
+ const seatButton = showProfilePicker || showCpuDifficulty
144
+ // Read live from the selected profile rather than snapshotted at selection time, since a caller
145
+ // typically has no other editing surface for it on this same screen.
137
146
  const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null
138
147
 
148
+ const colorSection: ProfilePickerColorSection | undefined = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : undefined } : undefined
149
+ // The seat button's dropdown is placed here rather than by the caller: inside a
150
+ // @tastic/split-screen zone (2 Player) it has to stay in that player's half, which
151
+ // useZoneClampedAlign does, and outside one it's useAutoAlign's own placement. Sized from the same
152
+ // section and rows the picker draws. Rotation 0, the same as the profile, color and difficulty
153
+ // dropdowns this button replaced were all placed with.
154
+ const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix)
155
+ const seatPickerSize = showProfilePicker ? getProfilePickerContentSize(profiles!.length, !!onManageProfiles, colorSection) : getOptionPickerContentSize(cpuDifficultyOptions?.length ?? 0, colorSection)
156
+ const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height)
157
+
139
158
  return (
140
159
  <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
141
160
  {showProfilePicker ? (
142
- <ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} />
161
+ <ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} colorSection={colorSection} alignOverride={seatPickerAlign} />
143
162
  ) : showCpuDifficulty ? (
144
- <LabeledDropdown id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} alignOverride={cpuDifficultyAlignOverride} />
163
+ <OptionPicker id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} icon={cpuIcon} colorSection={colorSection} alignOverride={seatPickerAlign} />
145
164
  ) : (
146
165
  label && (
147
166
  <Text variant='labelSmall' style={{ color, fontFamily: labelFontFamily }}>
@@ -150,14 +169,16 @@ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, la
150
169
  )
151
170
  )}
152
171
 
153
- <View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
154
- {/* Human slots show the selected profile's own tag when it has one, falling back to
155
- humanIcon; a non-human slot always falls back to cpuIcon - this is what actually
156
- distinguishes "you" from "the CPU" now, not a fixed icon. */}
157
- <InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />
172
+ {(!seatButton || secondPicker) && (
173
+ <View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
174
+ {/* The fallback swatch, for a seat with no button: a human slot shows the selected
175
+ profile's own tag when it has one, falling back to humanIcon; a non-human slot always
176
+ falls back to cpuIcon. */}
177
+ {!seatButton && <InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />}
158
178
 
159
- {secondPicker}
160
- </View>
179
+ {secondPicker}
180
+ </View>
181
+ )}
161
182
 
162
183
  {isHuman && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
163
184
  </View>
@@ -175,9 +196,9 @@ const styles = StyleSheet.create({
175
196
  panelOpen: {
176
197
  zIndex: 100
177
198
  },
178
- // Same gap as `panel`'s own vertical rhythm, reused horizontally - the color picker and (when
179
- // present) secondPicker sit side by side within this row instead of stacked in the panel's own
180
- // column.
199
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
200
+ // (when present) secondPicker sit side by side within this row instead of stacked in the panel's
201
+ // own column.
181
202
  pickerRow: {
182
203
  alignItems: 'center',
183
204
  flexDirection: 'row',
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, Labele
16
16
  export { loadSkiaWeb } from './loadSkiaWeb'
17
17
  export { overlayActionStyles, overlayStyles } from './overlayCard'
18
18
  export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from './OverlaySurface'
19
- export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
19
+ export { getColorPopoverId, getCpuDifficultyPopoverId, getProfilePopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
20
20
  export { PopoverBody } from './PopoverBody'
21
21
  export { PressAwayOverlay } from './PressAwayOverlay'
22
22
  export { ReadyButton } from './ReadyButton'