@tastic/hud 0.19.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.
package/dist/index.mjs CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  UpdateDialog,
20
20
  getStaggeredWordDuration,
21
21
  useCompactDialog
22
- } from "./chunk-HGJKNLMD.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.19.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.12.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,4 +1,4 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { getBlendedColor, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import type { AchievementCatalogRow } from '@tastic/achievements'
3
3
  import { Fragment } from 'react'
4
4
  import { StyleSheet } from 'react-native'
@@ -36,9 +36,16 @@ interface Props {
36
36
  // AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
37
37
  // (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
38
38
  // the same reason.
39
+ //
40
+ // The check mark and progress bar (AchievementRow's checkColor) are the tier color in dark mode, and
41
+ // the tier color mixed half with black in light mode. Raw gold (#FFD54F) and silver (#C0C0C8) are
42
+ // about 1.4:1 and 1.8:1 against the light screen's white, so a bar or check in either barely shows;
43
+ // mixed, they're about 5:1 and 6:1 (bronze goes from about 3:1 to 9:1). The badge keeps the true tier
44
+ // color: its glyph is black, which reads on all three.
39
45
  export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
40
46
  const { dark } = useAutoPaperTheme()
41
47
  const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
48
+ const tierAccent = (tierColor: string) => (dark ? tierColor : getBlendedColor('#000000', tierColor, 0.5))
42
49
 
43
50
  return (
44
51
  <Fragment>
@@ -46,7 +53,7 @@ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg
46
53
  {label}
47
54
  </Text>
48
55
  {rows.map((row) => (
49
- <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
56
+ <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={tierAccent(row.tierColor)} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
50
57
  ))}
51
58
  </Fragment>
52
59
  )
@@ -21,10 +21,10 @@ interface Props {
21
21
  // timestamp - this component has no opinion on date formatting or streak conventions, which vary
22
22
  // per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
23
23
  unlockedLabel?: string
24
- // Color for the unlocked check-circle icon/badge glyph - typically the same tier color passed to
25
- // badgeColor, kept as its own prop rather than reused internally so a caller can special-case it
26
- // independently if its own tier scheme ever needs to (matching LightCycles' own call site, which
27
- // does pass the identical value to both today).
24
+ // Color for the unlocked check-circle icon and the locked progress bar - defaults to badgeColor.
25
+ // Its own prop so a caller can tune it apart from the badge: AchievementCatalogSection passes the
26
+ // tier color darkened in light mode, where raw gold and silver barely show on white, while the badge
27
+ // keeps the true tier color behind its black glyph.
28
28
  checkColor?: string
29
29
  // Only rendered while locked (unlockedLabel is undefined) - 0..1.
30
30
  progress?: number
@@ -400,7 +400,9 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
400
400
  </View>
401
401
  </Dialog>
402
402
  <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={0} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
403
- <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} />
403
+ {/* onCancel with no cancelLabel adds no button: it's only so Android back dismisses the
404
+ message the way OK does (ConfirmDialog consumes back either way). */}
405
+ <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={0} onConfirm={() => setInfoMessage(null)} onCancel={() => setInfoMessage(null)} />
404
406
  </RotatedFrame>
405
407
  </Portal>
406
408
  )
@@ -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 { 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'
@@ -30,6 +30,14 @@ interface Props {
30
30
  // caller that wants something else entirely.
31
31
  titleVariant?: MD3TextVariant
32
32
  onBack: () => void
33
+ // The header arrow's accessibility label, default 'Back' (same name and default as
34
+ // CornerActionButtons' own backLabel). Without one, VoiceOver and TalkBack announce only "button".
35
+ backLabel?: string
36
+ // Opt-in extra touch area on the header arrow, forwarded as its IconButton's own hitSlop, exactly
37
+ // like CornerActionButtons' hitSlop: the target is the 40pt box, so 4 makes it 48pt, and nothing
38
+ // visible moves. Needs @rific/feedback-press 0.13.0 or later (ignored on older versions, and on
39
+ // web). Omitted means no slop, as before.
40
+ hitSlop?: number | Insets
33
41
  insets: { top: number; left: number; bottom: number; right: number }
34
42
  // Optional, and always passed as a pair - a caller with nothing resettable (or that deliberately
35
43
  // doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
@@ -70,7 +78,7 @@ interface Props {
70
78
  // convention. `children` is the seam for whatever isn't shared: this component has no opinion on
71
79
  // what a "stat" is or how achievements are catalogued - see StatRow/StatSection/AchievementRow for
72
80
  // the smaller presentational pieces built to go inside it.
73
- export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, 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 }: Props) {
81
+ export 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 }: Props) {
74
82
  const { dark, colors } = useAutoPaperTheme()
75
83
  const ambientRotation = useRotation()
76
84
  const rotation = rotationOverride ?? ambientRotation
@@ -87,13 +95,15 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
87
95
  return (
88
96
  <View style={[styles.container, { backgroundColor: bg }]}>
89
97
  <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
90
- <IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
98
+ <IconButton icon='arrow-left' iconColor={fg} size={24} hitSlop={hitSlop} onPress={onBack} accessibilityLabel={backLabel} />
91
99
  <Text variant={titleVariant} style={[styles.title, { color: fg }]}>
92
100
  {title}
93
101
  </Text>
94
102
  </View>
95
103
 
96
- <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}>
97
107
  {children}
98
108
 
99
109
  {showReset && (
@@ -101,7 +111,7 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
101
111
  {resetLabel}
102
112
  </Button>
103
113
  )}
104
- </ScrollView>
114
+ </FadingScrollView>
105
115
 
106
116
  <ConfirmDialog
107
117
  visible={confirmResetVisible}
@@ -139,7 +149,7 @@ const styles = StyleSheet.create({
139
149
  resetButton: {
140
150
  marginTop: 16
141
151
  },
142
- // 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
143
153
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
144
154
  // can render at its full unclipped content height instead of the screen's), which leaves the
145
155
  // header's back button touch target unreliable underneath it.
@@ -1,8 +1,8 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button } from '@rific/feedback-press'
3
3
  import { toRotationStyle, type ViewRotation } from '@tastic/core'
4
- import { type ReactNode } from 'react'
5
- import { StyleSheet, View } from 'react-native'
4
+ import { type ReactNode, useEffect, useRef } from 'react'
5
+ import { BackHandler, StyleSheet, View } from 'react-native'
6
6
  import { Portal, Text } from 'react-native-paper'
7
7
 
8
8
  import { DialogHeader } from './DialogHeader'
@@ -43,6 +43,29 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
43
43
  const { colors } = useAutoPaperTheme()
44
44
  const { fg } = useOverlayCardColors()
45
45
  const { compact, onLayout } = useCompactDialog(rotation)
46
+
47
+ // Android's back means Cancel while the dialog is up. It's a Portal over a View, not a Modal, so
48
+ // nothing else catches the press: it would fall through to whatever is underneath (the router, or a
49
+ // screen's own back listener) and close that screen with the dialog still up. RN calls the newest
50
+ // listener first, and this one is added when the dialog opens. Always consumed, even with no
51
+ // onCancel: a dialog with only a confirm button has to be answered, not backed out from under.
52
+ //
53
+ // onCancel goes through a ref (kept current after every commit, the useQuitConfirmation idiom) so the
54
+ // listener is added once per opening. Callers pass a new arrow each render, and re-adding the
55
+ // listener on each would move it to the top of the stack, ahead of anything opened above it since.
56
+ const onCancelRef = useRef(onCancel)
57
+ useEffect(() => {
58
+ onCancelRef.current = onCancel
59
+ })
60
+ useEffect(() => {
61
+ if (!visible) return
62
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
63
+ onCancelRef.current?.()
64
+ return true
65
+ })
66
+ return () => subscription.remove()
67
+ }, [visible])
68
+
46
69
  if (!visible) return null
47
70
 
48
71
  const accent = destructive ? colors.danger : colors.primary