@tastic/shell 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -460,7 +460,7 @@ var PROFILES_HREF = "/profiles";
460
460
  import { defaultColors, getRgb, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
461
461
  import { useOrientationState } from "@tastic/core";
462
462
  import { CornerActionButtons, PressAwayOverlay, usePopoverHost } from "@tastic/hud/chrome";
463
- import { PlayerSetupPanel, ReadyButton as ReadyButton2, SharedActionBand } from "@tastic/hud/lobby";
463
+ import { PlayerSetupPanel, ReadyButton, SharedActionBand } from "@tastic/hud/lobby";
464
464
  import { DualZoneLayout, needsSharedNeutralZone, useDualZoneLayout } from "@tastic/split-screen";
465
465
  import { router as router2, useFocusEffect as useFocusEffect2 } from "expo-router";
466
466
  import { useCallback as useCallback9, useEffect as useEffect4, useMemo as useMemo5, useRef as useRef4, useState as useState4 } from "react";
@@ -469,80 +469,55 @@ import Animated from "react-native-reanimated";
469
469
  import { useSafeAreaInsets } from "react-native-safe-area-context";
470
470
  import { useDispatch as useDispatch9 } from "react-redux";
471
471
 
472
- // src/components/SeatTwoPanel.tsx
473
- import { ReadyButton, useZoneClampedAlign } from "@tastic/hud/lobby";
472
+ // src/components/OpponentPicker.tsx
473
+ import { useZoneClampedAlign } from "@tastic/hud/lobby";
474
474
  import { getOptionPickerContentSize, OptionPicker } from "@tastic/profile";
475
475
  import { useMemo as useMemo4 } from "react";
476
476
  import { StyleSheet as StyleSheet2, View as View2 } from "react-native";
477
- import { jsx as jsx10, jsxs as jsxs3 } from "react/jsx-runtime";
477
+ import { jsx as jsx10 } from "react/jsx-runtime";
478
478
  var CPU_PREFIX = "cpu:";
479
- var PROFILE_PREFIX = "profile:";
480
479
  var NOBODY = "nobody";
481
- var GUEST = "guest";
482
- var GUEST_ICON = "account-outline";
480
+ var PERSON = "person";
481
+ var PERSON_ICON = "account-outline";
483
482
  var NOBODY_ICON = "account-off-outline";
484
483
  var CPU_ICON = "robot";
485
- function getSeatTwoPopoverId(idPrefix) {
486
- return `${idPrefix}-seat`;
484
+ function getOpponentPopoverId(idPrefix) {
485
+ return `${idPrefix}-opponent`;
487
486
  }
488
- 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 }) {
489
- const isPerson = seatTwo === "person";
487
+ function OpponentPicker({ idPrefix, host, choices, seatTwo, onSeatTwoChange, cpuDifficulty, color, onColorChange, swatches, takenColor, allowSwapTaken, dark, previewValue, triggerStyle, usePickerAlign }) {
490
488
  const drawn = previewValue ? previewValue(color) : color;
491
- const selectedProfile = isPerson ? profiles.find((p) => p.id === selectedProfileId) ?? null : null;
492
489
  const cpuOptions = cpuDifficulty?.options;
493
490
  const options = useMemo4(() => {
494
491
  const list = [];
495
492
  if (choices.includes("cpu")) list.push(...cpuOptions ? cpuOptions.map((o) => ({ value: `${CPU_PREFIX}${o.value}`, label: o.label, icon: o.icon })) : [{ value: CPU_PREFIX, label: "CPU", icon: CPU_ICON }]);
496
- if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Nobody", icon: NOBODY_ICON });
497
- if (choices.includes("person")) {
498
- list.push({ value: GUEST, label: "Player 2", icon: GUEST_ICON });
499
- 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 })));
500
- }
493
+ if (choices.includes("nobody")) list.push({ value: NOBODY, label: "Solo", icon: NOBODY_ICON, dividerBefore: true });
494
+ if (choices.includes("person")) list.push({ value: PERSON, label: "Player", icon: PERSON_ICON, dividerBefore: true });
501
495
  return list;
502
- }, [choices, cpuOptions, profiles, previewValue]);
503
- const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : selectedProfile ? `${PROFILE_PREFIX}${selectedProfile.id}` : GUEST;
504
- const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : selectedProfile ? "account" : GUEST_ICON;
496
+ }, [choices, cpuOptions]);
497
+ const value = seatTwo === "cpu" ? `${CPU_PREFIX}${cpuDifficulty?.value ?? ""}` : seatTwo === "nobody" ? NOBODY : PERSON;
498
+ const icon = seatTwo === "cpu" ? CPU_ICON : seatTwo === "nobody" ? NOBODY_ICON : PERSON_ICON;
505
499
  const handleChange = (next) => {
506
500
  if (next.startsWith(CPU_PREFIX)) {
507
501
  cpuDifficulty?.onChange(next.slice(CPU_PREFIX.length));
508
502
  onSeatTwoChange("cpu");
509
- } else if (next === NOBODY) {
510
- onSeatTwoChange("nobody");
511
503
  } else {
512
- onProfileSelect(next.startsWith(PROFILE_PREFIX) ? next.slice(PROFILE_PREFIX.length) : null);
513
- onSeatTwoChange("person");
504
+ onSeatTwoChange(next === NOBODY ? "nobody" : "person");
514
505
  }
515
506
  };
516
- const colorSection = seatTwo === "nobody" ? void 0 : { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: selectedProfile ? colorCaption : void 0, previewValue };
517
- const pickerId = getSeatTwoPopoverId(idPrefix);
518
- const size = getOptionPickerContentSize(options.length, colorSection);
507
+ const colorSection = seatTwo === "cpu" ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, previewValue } : void 0;
508
+ const pickerId = getOpponentPopoverId(idPrefix);
509
+ const size = getOptionPickerContentSize(options, colorSection);
519
510
  const useAlign = usePickerAlign ?? useZoneClampedAlign;
520
511
  const align = useAlign(host.openId === pickerId, size.width, size.height);
521
- const ownPopoverOpen = host.openId?.startsWith(`${idPrefix}-`) ?? false;
522
- const pickerRowOpen = secondPickerId !== void 0 && host.openId === secondPickerId;
523
- return /* @__PURE__ */ jsxs3(View2, { style: [styles2.panel, ownPopoverOpen && styles2.panelOpen], children: [
524
- /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }),
525
- isPerson && secondPicker && /* @__PURE__ */ jsx10(View2, { style: [styles2.pickerRow, pickerRowOpen && styles2.pickerRowOpen], children: secondPicker }),
526
- isPerson && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color: drawn, ready: ready ?? false, onToggleReady })
527
- ] });
512
+ return /* @__PURE__ */ jsx10(View2, { style: [styles2.button, host.openId === pickerId && styles2.buttonOpen], children: /* @__PURE__ */ jsx10(OptionPicker, { id: pickerId, host, options, value, onChange: handleChange, color: drawn, dark, icon, colorSection, alignOverride: align, triggerStyle }) });
528
513
  }
529
514
  var styles2 = StyleSheet2.create({
530
- panel: {
531
- alignItems: "center",
532
- gap: 12
533
- },
534
- // A popover escaping this panel needs the panel itself elevated to paint above a later sibling.
535
- panelOpen: {
536
- zIndex: 100
537
- },
538
- pickerRow: {
539
- alignItems: "center",
540
- flexDirection: "row",
541
- gap: 12
515
+ // Its own column, so a row of seat buttons doesn't stretch the pill to the tallest of them.
516
+ button: {
517
+ alignItems: "center"
542
518
  },
543
- // Lifts the controls picker's popover above the Ready button below it (each View is its own
544
- // stacking context on the web).
545
- pickerRowOpen: {
519
+ // A popover escaping this button needs the button itself elevated to paint above a later sibling.
520
+ buttonOpen: {
546
521
  zIndex: 100
547
522
  }
548
523
  });
@@ -582,18 +557,26 @@ function useAppSettings() {
582
557
  }
583
558
 
584
559
  // src/components/SettingsDialog.tsx
585
- import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
560
+ import { jsx as jsx11, jsxs as jsxs3 } from "react/jsx-runtime";
586
561
  function SettingsDialog({ visible, onDismiss, onShowHowToPlay, children }) {
587
562
  const onUpdateError = useUpdateErrorToast2();
588
563
  const { settings, setSettings } = useAppSettings();
589
564
  const { otaVersion, SettingsSections, edgeGuard = true } = usePack();
590
565
  const edgeGuardProps = edgeGuard ? { deferBottomEdgeGestures: settings.deferBottomEdgeGestures, onDeferBottomEdgeGestures: (value) => setSettings({ deferBottomEdgeGestures: value }) } : {};
591
- return /* @__PURE__ */ jsxs4(BaseSettingsDialog, { visible, onDismiss, version: otaVersion, ...edgeGuardProps, onUpdateError, onShowHowToPlay, children: [
566
+ return /* @__PURE__ */ jsxs3(BaseSettingsDialog, { visible, onDismiss, version: otaVersion, ...edgeGuardProps, onUpdateError, onShowHowToPlay, children: [
592
567
  children,
593
568
  SettingsSections && /* @__PURE__ */ jsx11(SettingsSections, {})
594
569
  ] });
595
570
  }
596
571
 
572
+ // src/hooks/useAtComputer.ts
573
+ import { useIsTouchPrimaryDevice as useIsTouchPrimaryDevice2 } from "@tastic/core";
574
+ import { Platform as Platform3 } from "react-native";
575
+ function useAtComputer() {
576
+ const touchPrimary = useIsTouchPrimaryDevice2();
577
+ return Platform3.OS === "web" && !touchPrimary;
578
+ }
579
+
597
580
  // src/hooks/useOpponent.ts
598
581
  import { useCallback as useCallback7 } from "react";
599
582
  import { useDispatch as useDispatch7, useSelector as useSelector6 } from "react-redux";
@@ -747,8 +730,10 @@ var liveplayActions = actions5;
747
730
  var liveplaySlice_default = reducer5;
748
731
 
749
732
  // src/screens/LoadoutScreen.tsx
750
- import { jsx as jsx12, jsxs as jsxs5 } from "react/jsx-runtime";
733
+ import { jsx as jsx12, jsxs as jsxs4 } from "react/jsx-runtime";
751
734
  var PANEL_SWAP_FADE_MS = 180;
735
+ var P1_CONTROL_ID = "p1-control";
736
+ var P2_CONTROL_ID = "p2-control";
752
737
  function LoadoutScreen({ game }) {
753
738
  const pack = usePack();
754
739
  const { seatTwo: savedSeatTwo, setSeatTwo } = useOpponent();
@@ -761,7 +746,9 @@ function LoadoutScreen({ game }) {
761
746
  const { openFor } = useGameGuide();
762
747
  const { profiles, lastSelected, selectProfile } = useProfiles();
763
748
  const { orientationMode } = useOrientationState();
764
- const panelMode = p2IsHuman && !game.sideBySideWhenWide ? "faceToFace" : orientationMode;
749
+ const atComputer = useAtComputer();
750
+ const twoPlayerMode = game.sideBySideAtComputer && atComputer ? "sideBySide" : game.sideBySideWhenWide ? orientationMode : "faceToFace";
751
+ const panelMode = p2IsHuman ? twoPlayerMode : orientationMode;
765
752
  const { panelLayout, panelFadeStyle } = useDualZoneLayout(panelMode, false, true, false, PANEL_SWAP_FADE_MS);
766
753
  const playersRowFade = panelFadeStyle;
767
754
  const [settingsOpen, setSettingsOpen] = useState4(false);
@@ -770,7 +757,7 @@ function LoadoutScreen({ game }) {
770
757
  const { p1Color, p2Color, handleP1ColorChange, handleP2ColorChange } = useSeatColors(p2IsHuman);
771
758
  const dispatch = useDispatch9();
772
759
  const handleP1ProfileSelect = useCallback9((profile) => selectProfile(1, profile?.id ?? null), [selectProfile]);
773
- const handleP2ProfileSelect = useCallback9((profileId) => selectProfile(2, profileId), [selectProfile]);
760
+ const handleP2ProfileSelect = useCallback9((profile) => selectProfile(2, profile?.id ?? null), [selectProfile]);
774
761
  const [ready, setReady] = useState4({ 1: false, 2: false });
775
762
  useFocusEffect2(
776
763
  useCallback9(() => {
@@ -801,16 +788,16 @@ function LoadoutScreen({ game }) {
801
788
  const p1Drawn = seatPreview ? seatPreview(p1Color) : p1Color;
802
789
  const p2Drawn = seatPreview ? seatPreview(p2Color) : p2Color;
803
790
  const triggerStyle = useMemo5(() => pack.Backdrop ? [styles3.pill, { backgroundColor: themeColors.surface }] : void 0, [pack.Backdrop, themeColors.surface]);
791
+ const readySurface = pack.Backdrop ? themeColors.surface : void 0;
804
792
  const isPortrait = panelLayout.orientationMode === "faceToFace";
805
793
  const showMetaInSharedBand = needsSharedNeutralZone(panelLayout.orientationMode, humanSeats.length);
806
794
  const p2ZoneStyle = p2IsHuman ? isPortrait ? styles3.p2ZoneTop : styles3.p2ZoneRight : null;
807
- const showReadyButton = p2IsHuman;
808
795
  const seatPickerAlign = p2IsHuman ? void 0 : pack.usePickerAlign;
809
796
  const layoutKey = p2IsHuman ? "zones" : "row";
810
797
  const controlsHost = usePopoverHost();
811
- const p2PanelHost = usePopoverHost();
812
- const p2Host = p2IsHuman ? p2PanelHost : controlsHost;
813
- const anyPlayerPickerOpen = !!(controlsHost.openId?.startsWith("p1-") || controlsHost.openId?.startsWith("p2-"));
798
+ const p2Host = usePopoverHost();
799
+ const p1PickerOpen = controlsHost.openId?.startsWith("p1-") ?? false;
800
+ const p1ControlOpen = controlsHost.openId === P1_CONTROL_ID;
814
801
  const optionsRow = loadout.options({
815
802
  host: controlsHost,
816
803
  accentColor: themeColors.tertiary,
@@ -825,11 +812,23 @@ function LoadoutScreen({ game }) {
825
812
  const sharedControls = showMetaInSharedBand ? /* @__PURE__ */ jsx12(SharedActionBand, { onBack: safeBack, onSettings: () => setSettingsOpen(true), onRandomize: loadout.randomize, onReset: loadout.reset, fg, popoverOpen: optionsPopoverOpen, children: optionsRow }) : optionsRow;
826
813
  const controlLook = { accentColor: themeColors.tertiary, mutedColor: fgMuted, dark };
827
814
  const p1Control = loadout.seatControl(1, controlsHost, { ...controlLook, seatColor: p1Drawn });
828
- 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}`);
815
+ 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, dark, showReadyButton: false, 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}`);
816
+ const opponentPicker = game.seatTwo && /* @__PURE__ */ jsx12(OpponentPicker, { idPrefix: "p1", host: controlsHost, choices: game.seatTwo, seatTwo, onSeatTwoChange: changeSeatTwo, cpuDifficulty: loadout.cpuDifficulty, color: p2Color, onColorChange: handleP2ColorChange, swatches: defaultColors, takenColor: p1Color, allowSwapTaken: mode === "vsCpu", dark, previewValue: seatPreview, triggerStyle, usePickerAlign: seatPickerAlign }, `opponent-${layoutKey}`);
817
+ const toggleP1Ready = () => setReady((r) => ({ ...r, 1: !r[1] }));
818
+ const seatOne = /* @__PURE__ */ jsxs4(View3, { style: [styles3.seatOne, !p2IsHuman && styles3.seatOneAlone, p1PickerOpen && styles3.raised], children: [
819
+ /* @__PURE__ */ jsxs4(View3, { style: [styles3.seatOneButtons, p1PickerOpen && styles3.raised], children: [
820
+ /* @__PURE__ */ jsxs4(Animated.View, { style: [styles3.playersRow, p1PickerOpen && styles3.raised, !p2IsHuman && playersRowFade], children: [
821
+ p1Panel,
822
+ opponentPicker
823
+ ] }),
824
+ p1Control && /* @__PURE__ */ jsx12(View3, { style: [styles3.controlRow, p1ControlOpen && styles3.raised], children: p1Control })
825
+ ] }),
826
+ /* @__PURE__ */ jsx12(ReadyButton, { color: p1Drawn, ready: ready[1], onToggleReady: toggleP1Ready, surfaceColor: readySurface, dark, style: !p2IsHuman && p1PickerOpen && styles3.readyButtonHidden })
827
+ ] });
829
828
  const p2Control = p2IsHuman ? loadout.seatControl(2, p2Host, { ...controlLook, seatColor: p2Drawn }) : void 0;
830
- 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}`);
831
- return /* @__PURE__ */ jsxs5(View3, { style: [styles3.container, { backgroundColor: bg }], children: [
832
- /* @__PURE__ */ jsxs5(View3, { style: styles3.frame, children: [
829
+ const p2Panel = p2IsHuman && /* @__PURE__ */ jsx12(PlayerSetupPanel, { idPrefix: "p2", host: p2Host, color: p2Color, onColorChange: handleP2ColorChange, swatches: defaultColors, takenColor: p1Color, colorCaption: pack.colorCaption, isHuman: true, ready: ready[2], onToggleReady: () => setReady((r) => ({ ...r, 2: !r[2] })), dark, secondPicker: p2Control, secondPickerId: p2Control ? P2_CONTROL_ID : void 0, profiles, selectedProfileId: lastSelected[2], takenProfileId: lastSelected[1], guestLabel: pack.guestLabel ?? "P2", nullLabel: pack.nullLabel, onProfileSelect: handleP2ProfileSelect, previewValue: seatPreview, triggerStyle, readySurfaceColor: readySurface }, "p2-zones");
830
+ return /* @__PURE__ */ jsxs4(View3, { style: [styles3.container, { backgroundColor: bg }], children: [
831
+ /* @__PURE__ */ jsxs4(View3, { style: styles3.frame, children: [
833
832
  /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null, onPress: controlsHost.close }),
834
833
  p2ZoneStyle && /* @__PURE__ */ jsx12(PressAwayOverlay, { active: controlsHost.openId !== null || p2Host.openId !== null, onPress: p2Host.close, style: p2ZoneStyle }),
835
834
  !showMetaInSharedBand && /* @__PURE__ */ jsx12(CornerActionButtons, { onBack: safeBack, onSettings: () => setSettingsOpen(true), fg, insets }),
@@ -838,20 +837,16 @@ function LoadoutScreen({ game }) {
838
837
  {
839
838
  panelLayout,
840
839
  panelFadeStyle,
841
- p1: p1Panel,
840
+ p1: seatOne,
842
841
  p2: p2Panel,
843
842
  shared: sharedControls,
844
- p1Elevated: controlsHost.openId?.startsWith("p1-") ?? false,
843
+ p1Elevated: p1PickerOpen,
845
844
  p2Elevated: p2Host.openId !== null,
846
845
  sharedElevated: optionsPopoverOpen
847
846
  }
848
- ) : /* @__PURE__ */ jsxs5(View3, { style: styles3.stackedZone, children: [
847
+ ) : /* @__PURE__ */ jsxs4(View3, { style: styles3.stackedZone, children: [
849
848
  sharedControls,
850
- /* @__PURE__ */ jsxs5(Animated.View, { style: [styles3.playersRow, anyPlayerPickerOpen && styles3.playersRowOpen, playersRowFade], children: [
851
- p1Panel,
852
- p2Panel
853
- ] }),
854
- /* @__PURE__ */ jsx12(ReadyButton2, { color: p1Drawn, ready: ready[1], onToggleReady: () => setReady((r) => ({ ...r, 1: !r[1] })), style: anyPlayerPickerOpen && styles3.readyButtonHidden })
849
+ seatOne
855
850
  ] })
856
851
  ] }),
857
852
  /* @__PURE__ */ jsx12(SettingsDialog, { visible: settingsOpen, onDismiss: () => setSettingsOpen(false), onShowHowToPlay: () => openFor(game.id) })
@@ -865,6 +860,12 @@ var styles3 = StyleSheet3.create({
865
860
  container: {
866
861
  flex: 1
867
862
  },
863
+ // Player 1's controls, under their row of buttons.
864
+ controlRow: {
865
+ alignItems: "center",
866
+ flexDirection: "row",
867
+ gap: 12
868
+ },
868
869
  // A real full-bleed box to size the absolutely-positioned children against.
869
870
  frame: {
870
871
  alignItems: "center",
@@ -891,21 +892,37 @@ var styles3 = StyleSheet3.create({
891
892
  pill: {
892
893
  borderRadius: 999
893
894
  },
894
- // The one-human row: seat 1's panel and seat 2's button read as one evenly spaced row of buttons,
895
- // at PlayerSetupPanel's own pickerRow gap (12).
895
+ // Seat 1's button and the opponent button read as one evenly spaced row of buttons, at
896
+ // PlayerSetupPanel's own pickerRow gap (12).
896
897
  playersRow: {
897
898
  flexDirection: "row",
898
899
  gap: 12
899
900
  },
900
- playersRowOpen: {
901
+ // Lifts whichever piece of player 1's has a menu open above its later siblings (each View is its
902
+ // own stacking context on the web).
903
+ raised: {
901
904
  zIndex: 100
902
905
  },
903
- // See anyPlayerPickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
906
+ // See p1PickerOpen: opacity 0 (not display:'none') keeps the button's layout space, and
904
907
  // pointerEvents:'none' keeps the sliver an open menu doesn't cover from being a hidden tap target.
905
908
  readyButtonHidden: {
906
909
  opacity: 0,
907
910
  pointerEvents: "none"
908
911
  },
912
+ // Player 1's column: their row of buttons and controls, then Ready, at PlayerSetupPanel's own
913
+ // rhythm in their half of 2 Player.
914
+ seatOne: {
915
+ alignItems: "center",
916
+ gap: 12
917
+ },
918
+ // With one person, Ready sits as far under the buttons as the match options sit above them.
919
+ seatOneAlone: {
920
+ gap: 28
921
+ },
922
+ seatOneButtons: {
923
+ alignItems: "center",
924
+ gap: 12
925
+ },
909
926
  stackedZone: {
910
927
  alignItems: "center",
911
928
  gap: 28
@@ -1031,7 +1048,7 @@ function useHomeLayout() {
1031
1048
  }
1032
1049
 
1033
1050
  // src/screens/HomeScreen.tsx
1034
- import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs6 } from "react/jsx-runtime";
1051
+ import { Fragment as Fragment4, jsx as jsx14, jsxs as jsxs5 } from "react/jsx-runtime";
1035
1052
  function HomeScreen() {
1036
1053
  const pack = usePack();
1037
1054
  const { consumeResumeGame } = useLastActiveGame();
@@ -1099,19 +1116,19 @@ function HomeMenu() {
1099
1116
  const picker = /* @__PURE__ */ jsx14(ProfilePicker, { idPrefix: SEAT_ONE_PICKER, host: pickerHost, profiles, selectedId: lastSelected[1], color: p1Drawn, dark, guestLabel: pack.guestLabel ?? "P1", nullLabel: pack.nullLabel, onSelect: (profile) => selectProfile(1, profile?.id ?? null), onManage: () => router3.push(PROFILES_HREF), colorSection, alignOverride: pickerAlign, triggerStyle });
1100
1117
  const pressAway = /* @__PURE__ */ jsx14(PressAwayOverlay2, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles4.pressAway });
1101
1118
  const RulesSheet = rulesGame?.Rules;
1102
- return /* @__PURE__ */ jsxs6(View4, { style: [styles4.container, { backgroundColor: bg }], children: [
1103
- /* @__PURE__ */ jsx14(View4, { style: styles4.frame, children: /* @__PURE__ */ jsxs6(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
1104
- isWide ? /* @__PURE__ */ jsxs6(Fragment4, { children: [
1119
+ return /* @__PURE__ */ jsxs5(View4, { style: [styles4.container, { backgroundColor: bg }], children: [
1120
+ /* @__PURE__ */ jsx14(View4, { style: styles4.frame, children: /* @__PURE__ */ jsxs5(RulesHost, { enabled: pack.games.some((game) => game.Rules), children: [
1121
+ isWide ? /* @__PURE__ */ jsxs5(Fragment4, { children: [
1105
1122
  pressAway,
1106
- /* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
1123
+ /* @__PURE__ */ jsxs5(FadingScrollView, { ...tab && gridClamp, style: [styles4.wideRightPane, { left: layout.paddingLeft + layout.leftPaneWidth + HOME_WIDE_PANE_GAP }], contentContainerStyle: { paddingTop: 16 + insets.top, paddingBottom: 16 + insets.bottom, paddingRight: layout.paddingRight }, children: [
1107
1124
  tabBar && /* @__PURE__ */ jsx14(View4, { style: [styles4.wideTabs, { width: layout.rightPaneWidth }], children: tabBar }),
1108
1125
  /* @__PURE__ */ jsx14(View4, { style: [styles4.grid, { width: layout.rightPaneWidth }], children: pack.games.map((game) => /* @__PURE__ */ jsx14(GameTile, { game, hidden: isHidden(tab, game), width: layout.tileWidth, previewWidth: layout.previewWidth, previewRevealed: previewsRevealed, dark, p1Color, p2Color, onPress: () => openGame(game), onRules: hasRules(game) ? () => openRules(game) : void 0 }, game.id)) })
1109
1126
  ] }),
1110
- /* @__PURE__ */ jsxs6(View4, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
1127
+ /* @__PURE__ */ jsxs5(View4, { style: [styles4.wideLeftPane, { left: layout.paddingLeft, width: layout.leftPaneWidth, paddingTop: HOME_TOP_BAND + insets.top, paddingBottom: 16 + insets.bottom }, pickerHost.openId !== null && styles4.pickerOpen], children: [
1111
1128
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive, fit: layout.heroFit }),
1112
1129
  /* @__PURE__ */ jsx14(View4, { style: styles4.widePicker, children: picker })
1113
1130
  ] })
1114
- ] }) : /* @__PURE__ */ jsxs6(FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1131
+ ] }) : /* @__PURE__ */ jsxs5(FadingScrollView, { ...tab && columnClamp, style: styles4.scroll, contentContainerStyle: [styles4.content, { paddingTop: 56 + insets.top, paddingBottom: 24 + insets.bottom, paddingLeft: 16 + insets.left, paddingRight: 16 + insets.right }], children: [
1115
1132
  pressAway,
1116
1133
  /* @__PURE__ */ jsx14(pack.HeroTitle, { words: pack.wordmark, color: fg, p1Color, p2Color, active: heroActive }),
1117
1134
  /* @__PURE__ */ jsx14(View4, { style: [styles4.picker, pickerHost.openId !== null && styles4.pickerOpen], children: picker }),
@@ -1167,10 +1184,10 @@ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPres
1167
1184
  return (
1168
1185
  // The rules button sits beside the row's own button, laid over its end, not inside it: a button
1169
1186
  // inside a button is invalid on the web.
1170
- /* @__PURE__ */ jsxs6(View4, { style: hidden ? styles4.hidden : void 0, children: [
1171
- /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1187
+ /* @__PURE__ */ jsxs5(View4, { style: hidden ? styles4.hidden : void 0, children: [
1188
+ /* @__PURE__ */ jsxs5(Pressable, { onPress, style: [styles4.row, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1172
1189
  game.Preview ? /* @__PURE__ */ jsx14(PreviewBox, { game, width: ROW_PREVIEW_WIDTH, revealed: previewRevealed, dark, p1Color, p2Color }) : /* @__PURE__ */ jsx14(View4, { style: [styles4.rowIcon, { backgroundColor: dark ? ICON_DARK_BG : ICON_LIGHT_BG }], children: GameIcon && /* @__PURE__ */ jsx14(GameIcon, { size: ROW_ICON_SIZE, p1Color, p2Color, dark }) }),
1173
- /* @__PURE__ */ jsxs6(View4, { style: styles4.rowText, children: [
1190
+ /* @__PURE__ */ jsxs5(View4, { style: styles4.rowText, children: [
1174
1191
  /* @__PURE__ */ jsx14(Text, { variant: "titleLarge", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
1175
1192
  /* @__PURE__ */ jsx14(Text, { variant: "bodyMedium", style: { color: well.fgMuted }, numberOfLines: 2, children: game.blurb })
1176
1193
  ] })
@@ -1181,8 +1198,8 @@ function GameRow({ game, hidden, dark, p1Color, p2Color, previewRevealed, onPres
1181
1198
  }
1182
1199
  function GameTile({ game, hidden, width, previewWidth, dark, p1Color, p2Color, previewRevealed, onPress, onRules }) {
1183
1200
  const well = useWellColors();
1184
- return /* @__PURE__ */ jsxs6(View4, { style: [{ width }, hidden && styles4.hidden], children: [
1185
- /* @__PURE__ */ jsxs6(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1201
+ return /* @__PURE__ */ jsxs5(View4, { style: [{ width }, hidden && styles4.hidden], children: [
1202
+ /* @__PURE__ */ jsxs5(Pressable, { onPress, style: [styles4.tile, { backgroundColor: well.bg }], accessibilityRole: "button", accessibilityLabel: game.title, children: [
1186
1203
  /* @__PURE__ */ jsx14(PreviewBox, { game, width: previewWidth, revealed: previewRevealed, dark, p1Color, p2Color }),
1187
1204
  /* @__PURE__ */ jsx14(Text, { variant: "titleMedium", style: [styles4.rowTitle, { color: well.fg }], numberOfLines: 1, children: game.title }),
1188
1205
  /* @__PURE__ */ jsx14(Text, { variant: "bodySmall", style: { color: well.fgMuted }, numberOfLines: 3, children: game.blurb })
@@ -1368,7 +1385,7 @@ import { StyleSheet as StyleSheet5, View as View5 } from "react-native";
1368
1385
  import { SegmentedButtons as SegmentedButtons2, Text as Text2 } from "react-native-paper";
1369
1386
  import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
1370
1387
  import { useDispatch as useDispatch10 } from "react-redux";
1371
- import { jsx as jsx16, jsxs as jsxs7 } from "react/jsx-runtime";
1388
+ import { jsx as jsx16, jsxs as jsxs6 } from "react/jsx-runtime";
1372
1389
  var STATS = "stats";
1373
1390
  var ACHIEVEMENTS = "achievements";
1374
1391
  var TABS = [
@@ -1421,10 +1438,10 @@ function StatsContent({ pickerHost }) {
1421
1438
  const surface = pack.Backdrop ? { backgroundColor: colors.surface } : void 0;
1422
1439
  const triggerStyle = pack.Backdrop ? [styles5.pill, surface] : void 0;
1423
1440
  const PackTabContent = packTab?.Content;
1424
- return /* @__PURE__ */ jsxs7(BaseStatsScreen, { title, onBack: safeBack, insets, onReset: reset.onReset, resetLabel: reset.label, resetConfirmTitle: reset.title, resetConfirmBody: reset.body, fg: pack.foreground, bg: pack.Backdrop ? "transparent" : void 0, rotation: 0, children: [
1441
+ return /* @__PURE__ */ jsxs6(BaseStatsScreen, { title, onBack: safeBack, insets, onReset: reset.onReset, resetLabel: reset.label, resetConfirmTitle: reset.title, resetConfirmBody: reset.body, fg: pack.foreground, bg: pack.Backdrop ? "transparent" : void 0, rotation: 0, children: [
1425
1442
  /* @__PURE__ */ jsx16(PressAwayOverlay3, { active: pickerHost.openId !== null, onPress: pickerHost.close, style: styles5.pressAway }),
1426
1443
  /* @__PURE__ */ jsx16(SegmentedButtons2, { value: tab, onValueChange: setTab, buttons: tabButtons, style: [styles5.tabs, surface] }),
1427
- /* @__PURE__ */ jsxs7(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1444
+ /* @__PURE__ */ jsxs6(View5, { style: [styles5.filters, pickerHost.openId !== null && styles5.filtersOpen], onStartShouldSetResponder: () => pickerHost.openId !== null, children: [
1428
1445
  profiles.length > 0 && /* @__PURE__ */ jsx16(OptionPicker2, { id: PLAYER_PICKER_ID, host: pickerHost, options: playerOptions, value: profileId ?? EVERYONE, onChange: (value) => setSelectedProfileId(value === EVERYONE ? null : value), color: colors.primary, dark, icon: profileId ? "account" : "account-group", alignOverride: playerAlign, triggerStyle }),
1429
1446
  showGamePicker && /* @__PURE__ */ jsx16(OptionPicker2, { id: GAME_PICKER_ID, host: pickerHost, options: gameOptions, value: gameId, onChange: setGameId, color: colors.primary, dark, icon: "view-grid-outline", alignOverride: gameAlign, triggerStyle })
1430
1447
  ] }),
@@ -1445,7 +1462,7 @@ function GameStats({ game, tab, profileId, showTitle, titleColor }) {
1445
1462
  const { StatsSections, AchievementList } = game;
1446
1463
  const Sections = tab === STATS ? StatsSections : AchievementList;
1447
1464
  if (!Sections) return null;
1448
- return /* @__PURE__ */ jsxs7(View5, { style: styles5.game, children: [
1465
+ return /* @__PURE__ */ jsxs6(View5, { style: styles5.game, children: [
1449
1466
  showTitle && /* @__PURE__ */ jsx16(Text2, { variant: "titleLarge", style: [styles5.gameTitle, { color: titleColor }], children: game.title }),
1450
1467
  /* @__PURE__ */ jsx16(Sections, { profileId })
1451
1468
  ] });
@@ -1492,6 +1509,45 @@ function imageGameIcon(images) {
1492
1509
  };
1493
1510
  }
1494
1511
 
1512
+ // src/components/MatchChips.tsx
1513
+ import { useAutoPaperTheme as useAutoPaperTheme6 } from "@rific/auto-paper";
1514
+ import { IconButton as IconButton2 } from "@rific/feedback-press";
1515
+ import { useEffect as useEffect7 } from "react";
1516
+ import { StyleSheet as StyleSheet6, useWindowDimensions as useWindowDimensions2 } from "react-native";
1517
+ import Animated2, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
1518
+ import { useSafeAreaInsets as useSafeAreaInsets4 } from "react-native-safe-area-context";
1519
+ import { Fragment as Fragment5, jsx as jsx18, jsxs as jsxs7 } from "react/jsx-runtime";
1520
+ var MATCH_CHIPS_FADE_MS = 150;
1521
+ var CHIP_SIZE = 48;
1522
+ var EDGE_PAD = 8;
1523
+ function MatchChips({ playing, faceToFace, centerLineY, onBack, onSettings }) {
1524
+ const { height } = useWindowDimensions2();
1525
+ const insets = useSafeAreaInsets4();
1526
+ const { dark } = useAutoPaperTheme6();
1527
+ const atComputer = useAtComputer();
1528
+ const visible = atComputer || !playing;
1529
+ const opacity = useSharedValue(visible ? 1 : 0);
1530
+ useEffect7(() => {
1531
+ opacity.value = withTiming(visible ? 1 : 0, { duration: MATCH_CHIPS_FADE_MS });
1532
+ }, [visible]);
1533
+ const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
1534
+ const iconColor = dark ? "#FFFFFF" : "#000000";
1535
+ const containerColor = dark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.55)";
1536
+ const between = faceToFace && !atComputer;
1537
+ const top = between ? (centerLineY ?? height / 2) - CHIP_SIZE / 2 : EDGE_PAD + insets.top;
1538
+ const backSlot = { top, left: EDGE_PAD + insets.left };
1539
+ const settingsSlot = { top, right: EDGE_PAD + insets.right };
1540
+ const pointerEvents = visible ? "box-none" : "none";
1541
+ return /* @__PURE__ */ jsxs7(Fragment5, { children: [
1542
+ /* @__PURE__ */ jsx18(Animated2.View, { pointerEvents, style: [styles6.slot, backSlot, fadeStyle], testID: "match-chips-back", children: /* @__PURE__ */ jsx18(IconButton2, { icon: "arrow-left", iconColor, containerColor, style: styles6.chip, size: 24, onPress: onBack, accessibilityLabel: "Back" }) }),
1543
+ /* @__PURE__ */ jsx18(Animated2.View, { pointerEvents, style: [styles6.slot, settingsSlot, fadeStyle], testID: "match-chips-settings", children: /* @__PURE__ */ jsx18(IconButton2, { icon: "cog", iconColor, containerColor, style: styles6.chip, size: 24, onPress: onSettings, accessibilityLabel: "Settings" }) })
1544
+ ] });
1545
+ }
1546
+ var styles6 = StyleSheet6.create({
1547
+ chip: { margin: 0 },
1548
+ slot: { position: "absolute" }
1549
+ });
1550
+
1495
1551
  // src/hooks/useActiveRound.ts
1496
1552
  import { useSelector as useSelector8 } from "react-redux";
1497
1553
  function useActiveRound(game, defaults) {
@@ -1636,7 +1692,7 @@ function forgetProfileAchievements(dropStats) {
1636
1692
  // src/gameStats.tsx
1637
1693
  import { createAchievementsSlice, mapUnlocksBySeat, useAchievements } from "@tastic/achievements";
1638
1694
  import { createContext as createContext5, useCallback as useCallback11, useContext as useContext5, useMemo as useMemo8 } from "react";
1639
- import { jsx as jsx18 } from "react/jsx-runtime";
1695
+ import { jsx as jsx19 } from "react/jsx-runtime";
1640
1696
  var SEATS = [1, 2];
1641
1697
  function createGameStats({ key, defaultStats, isValidStats, catalog, getProfileStatsView }) {
1642
1698
  const { actions: actions6, reducer: sliceReducer } = createAchievementsSlice({
@@ -1669,7 +1725,7 @@ function createGameStats({ key, defaultStats, isValidStats, catalog, getProfileS
1669
1725
  [recordOutcome]
1670
1726
  );
1671
1727
  const value = useMemo8(() => ({ stats, unlockedAchievements, record, resetAll }), [stats, unlockedAchievements, record, resetAll]);
1672
- return /* @__PURE__ */ jsx18(Context.Provider, { value, children });
1728
+ return /* @__PURE__ */ jsx19(Context.Provider, { value, children });
1673
1729
  }
1674
1730
  function useStats() {
1675
1731
  const ctx = useContext5(Context);
@@ -1747,6 +1803,8 @@ export {
1747
1803
  GUEST_PLAYER_ID,
1748
1804
  HomeScreen,
1749
1805
  LoadoutRoute,
1806
+ MATCH_CHIPS_FADE_MS,
1807
+ MatchChips,
1750
1808
  PREVIEW_ASPECT,
1751
1809
  PackProvider,
1752
1810
  PlayRoute,
@@ -1782,6 +1840,7 @@ export {
1782
1840
  seatTwoFor,
1783
1841
  useActiveRound,
1784
1842
  useAppSettings,
1843
+ useAtComputer,
1785
1844
  useGameGuide,
1786
1845
  useOpponent,
1787
1846
  usePack,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/shell",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The app around a pack of React Native games, two-seat or for one player: Home, the loadout, the stats screen, Profiles and Settings, the pack's Redux store and its root providers, driven by one PackDefinition",
5
5
  "keywords": [
6
6
  "react-native",
@@ -75,7 +75,7 @@
75
75
  "@react-native-masked-view/masked-view": "0.3.2",
76
76
  "@react-native/jest-preset": "^0.88.0",
77
77
  "@reduxjs/toolkit": "^2.14.0",
78
- "@rific/auto-paper": "^0.13.4",
78
+ "@rific/auto-paper": "^0.14.0",
79
79
  "@rific/core": "^0.5.0",
80
80
  "@rific/feedback-press": "^0.13.1",
81
81
  "@rific/focus-chain": "^0.4.8",
@@ -84,10 +84,10 @@
84
84
  "@rific/toaster": "^0.12.0",
85
85
  "@rific/updater": "^0.8.0",
86
86
  "@tastic/achievements": "^0.6.0",
87
- "@tastic/core": "^0.15.0",
87
+ "@tastic/core": "^0.16.0",
88
88
  "@tastic/edge-guard": "^0.1.5",
89
- "@tastic/hud": "^0.27.1",
90
- "@tastic/profile": "^0.17.0",
89
+ "@tastic/hud": "^0.28.0",
90
+ "@tastic/profile": "^0.18.0",
91
91
  "@tastic/split-screen": "^0.5.5",
92
92
  "@testing-library/react-native": "^14.1.0",
93
93
  "@types/jest": "30.0.0",
@@ -135,10 +135,10 @@
135
135
  "@rific/splash-gate": ">=0.2.3",
136
136
  "@rific/toaster": ">=0.10.1",
137
137
  "@tastic/achievements": ">=0.6.0",
138
- "@tastic/core": ">=0.15.0",
138
+ "@tastic/core": ">=0.16.0",
139
139
  "@tastic/edge-guard": ">=0.1.5",
140
- "@tastic/hud": ">=0.27.1",
141
- "@tastic/profile": ">=0.16.2",
140
+ "@tastic/hud": ">=0.28.0",
141
+ "@tastic/profile": ">=0.18.0",
142
142
  "@tastic/split-screen": ">=0.5.5",
143
143
  "expo-blur": ">=58.0.4",
144
144
  "expo-constants": ">=58.0.12",
@@ -0,0 +1,72 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { IconButton } from '@rific/feedback-press'
3
+ import { useEffect } from 'react'
4
+ import { StyleSheet, useWindowDimensions } from 'react-native'
5
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
6
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
7
+
8
+ import { useAtComputer } from '../hooks/useAtComputer'
9
+
10
+ // How long the chips take to fade out when play starts, and back in when it stops.
11
+ export const MATCH_CHIPS_FADE_MS = 150
12
+ // The chip's box: a 24 icon in IconButton's 48, with Paper's margin zeroed.
13
+ const CHIP_SIZE = 48
14
+ const EDGE_PAD = 8
15
+
16
+ export interface MatchChipsProps {
17
+ /** Whether play is on right now (a live puck, a shot in flight, a round being ridden). While it is,
18
+ * the chips fade out and can't be pressed, so they never cover play; they come back the moment it
19
+ * stops (between points, a round's card, Game Over). Ignored at a computer, where they stay up. */
20
+ playing: boolean
21
+ /** Two players at opposite ends of one screen right now (2 Player face to face). The chips then sit
22
+ * on the side edges at the line between the players, rather than in the top corners, which are the
23
+ * far player's end. False for one human (vs CPU, solo) and for side by side. Ignored at a
24
+ * computer: both players share one upright screen there. */
25
+ faceToFace: boolean
26
+ /** Where that line between the players is, as a y in the window. Defaults to the window's middle;
27
+ * pass it when the board isn't centered (a band of score above it, say). */
28
+ centerLineY?: number
29
+ onBack: () => void
30
+ onSettings: () => void
31
+ }
32
+
33
+ /** The back and settings chips over a match, in every game of every pack: one rule for where they sit
34
+ * and when they show. The window's top corners (the same spot as hud's CornerActionButtons on every
35
+ * other screen), except 2 Player face to face, which puts them on the side edges at the line between
36
+ * the players; faded out while play is on, except at a computer (useAtComputer: the web without a
37
+ * touch-first screen), where they stay up in the page's corners. */
38
+ export function MatchChips({ playing, faceToFace, centerLineY, onBack, onSettings }: MatchChipsProps) {
39
+ const { height } = useWindowDimensions()
40
+ const insets = useSafeAreaInsets()
41
+ const { dark } = useAutoPaperTheme()
42
+ const atComputer = useAtComputer()
43
+ const visible = atComputer || !playing
44
+ const opacity = useSharedValue(visible ? 1 : 0)
45
+ useEffect(() => {
46
+ opacity.value = withTiming(visible ? 1 : 0, { duration: MATCH_CHIPS_FADE_MS })
47
+ }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps
48
+ const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))
49
+
50
+ const iconColor = dark ? '#FFFFFF' : '#000000'
51
+ const containerColor = dark ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.55)'
52
+ const between = faceToFace && !atComputer
53
+ const top = between ? (centerLineY ?? height / 2) - CHIP_SIZE / 2 : EDGE_PAD + insets.top
54
+ const backSlot = { top, left: EDGE_PAD + insets.left }
55
+ const settingsSlot = { top, right: EDGE_PAD + insets.right }
56
+ const pointerEvents = visible ? 'box-none' : 'none'
57
+ return (
58
+ <>
59
+ <Animated.View pointerEvents={pointerEvents} style={[styles.slot, backSlot, fadeStyle]} testID='match-chips-back'>
60
+ <IconButton icon='arrow-left' iconColor={iconColor} containerColor={containerColor} style={styles.chip} size={24} onPress={onBack} accessibilityLabel='Back' />
61
+ </Animated.View>
62
+ <Animated.View pointerEvents={pointerEvents} style={[styles.slot, settingsSlot, fadeStyle]} testID='match-chips-settings'>
63
+ <IconButton icon='cog' iconColor={iconColor} containerColor={containerColor} style={styles.chip} size={24} onPress={onSettings} accessibilityLabel='Settings' />
64
+ </Animated.View>
65
+ </>
66
+ )
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ chip: { margin: 0 },
71
+ slot: { position: 'absolute' }
72
+ })