@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/README.md CHANGED
@@ -75,7 +75,10 @@ corner of the screen.
75
75
  settings sheet, an achievements/stats screen, and a plain confirm-or-cancel dialog, each factored
76
76
  out after being independently copied into several games. See Rotation below for how each of these
77
77
  (plus `SectionedDropdown`/`InlineColorPicker`) handles rotating inside a `@tastic/split-screen`
78
- zone.
78
+ zone. `BaseStatsScreen`'s header arrow is labeled 'Back' for screen readers (`backLabel` to change
79
+ it) and takes the same opt-in `hitSlop` as `CornerActionButtons`. On Android, back while a
80
+ `ConfirmDialog` is open calls its `onCancel` and goes no further (with no `onCancel`, it does
81
+ nothing), so it never reaches the screen underneath.
79
82
  - **`OverlayCard`** / **`OverlayBackdrop`** / **`useOverlayCardColors`** - the card and dim behind
80
83
  `ConfirmDialog` and the how-to-play guide, in the app's own theme colors and blurred when the
81
84
  player's Blur setting allows it. See Overlay cards below.
@@ -470,7 +473,9 @@ row. Returns a `Fragment`, not a `View`: every call site renders the heading and
470
473
  siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
471
474
  here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
472
475
  (`row.tierColor` once `row.unlockedAt` is set, else the locked color) rather than upstream, since
473
- `getAchievementCatalogRows` deliberately leaves that choice to whatever renders the rows.
476
+ `getAchievementCatalogRows` deliberately leaves that choice to whatever renders the rows. In light
477
+ mode the check mark and progress bar are the tier color mixed half with black, since raw gold and
478
+ silver barely show on a white screen; the badge keeps the true tier color.
474
479
 
475
480
  ```ts
476
481
  interface AchievementCatalogSectionProps {
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { AchievementCatalogRow } from '@tastic/achievements';
4
4
  import { ViewRotation } from '@tastic/core';
5
- import { StyleProp, ViewStyle, Insets, LayoutChangeEvent, TextStyle } from 'react-native';
5
+ import { Insets, StyleProp, ViewStyle, LayoutChangeEvent, TextStyle } from 'react-native';
6
6
 
7
7
  interface Props$7 {
8
8
  rows: AchievementCatalogRow[];
@@ -65,6 +65,8 @@ interface Props$4 {
65
65
  title?: string;
66
66
  titleVariant?: MD3TextVariant;
67
67
  onBack: () => void;
68
+ backLabel?: string;
69
+ hitSlop?: number | Insets;
68
70
  insets: {
69
71
  top: number;
70
72
  left: number;
@@ -79,7 +81,7 @@ interface Props$4 {
79
81
  bg?: string;
80
82
  children: ReactNode;
81
83
  }
82
- declare function BaseStatsScreen({ rotation: rotationOverride, title, titleVariant, onBack, insets, onReset, resetLabel, resetConfirmTitle, resetConfirmBody, fg: fgOverride, bg: bgOverride, children }: Props$4): react.JSX.Element;
84
+ declare function BaseStatsScreen({ rotation: rotationOverride, title, titleVariant, onBack, backLabel, hitSlop, insets, onReset, resetLabel, resetConfirmTitle, resetConfirmBody, fg: fgOverride, bg: bgOverride, children }: Props$4): react.JSX.Element;
83
85
 
84
86
  interface ConfirmDialogProps {
85
87
  visible: boolean;
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { AchievementCatalogRow } from '@tastic/achievements';
4
4
  import { ViewRotation } from '@tastic/core';
5
- import { StyleProp, ViewStyle, Insets, LayoutChangeEvent, TextStyle } from 'react-native';
5
+ import { Insets, StyleProp, ViewStyle, LayoutChangeEvent, TextStyle } from 'react-native';
6
6
 
7
7
  interface Props$7 {
8
8
  rows: AchievementCatalogRow[];
@@ -65,6 +65,8 @@ interface Props$4 {
65
65
  title?: string;
66
66
  titleVariant?: MD3TextVariant;
67
67
  onBack: () => void;
68
+ backLabel?: string;
69
+ hitSlop?: number | Insets;
68
70
  insets: {
69
71
  top: number;
70
72
  left: number;
@@ -79,7 +81,7 @@ interface Props$4 {
79
81
  bg?: string;
80
82
  children: ReactNode;
81
83
  }
82
- declare function BaseStatsScreen({ rotation: rotationOverride, title, titleVariant, onBack, insets, onReset, resetLabel, resetConfirmTitle, resetConfirmBody, fg: fgOverride, bg: bgOverride, children }: Props$4): react.JSX.Element;
84
+ declare function BaseStatsScreen({ rotation: rotationOverride, title, titleVariant, onBack, backLabel, hitSlop, insets, onReset, resetLabel, resetConfirmTitle, resetConfirmBody, fg: fgOverride, bg: bgOverride, children }: Props$4): react.JSX.Element;
83
85
 
84
86
  interface ConfirmDialogProps {
85
87
  visible: boolean;
@@ -141,9 +141,10 @@ var import_jsx_runtime2 = require("react/jsx-runtime");
141
141
  function AchievementCatalogSection({ rows, label = "ALL ACHIEVEMENTS", fg, fgMuted: fgMutedOverride }) {
142
142
  const { dark } = (0, import_auto_paper2.useAutoPaperTheme)();
143
143
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
144
+ const tierAccent = (tierColor) => dark ? tierColor : (0, import_auto_paper2.getBlendedColor)("#000000", tierColor, 0.5);
144
145
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react.Fragment, { children: [
145
146
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Text, { variant: "labelMedium", style: [styles2.label, { color: fgMuted, fontFamily: MONO_FONT }], children: label }),
146
- rows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AchievementRow, { icon: row.icon, title: row.title, description: row.description, badgeColor: row.unlockedAt !== void 0 ? row.tierColor : LOCKED_BADGE_COLOR, checkColor: row.tierColor, unlockedLabel: row.unlockedLabel, progress: row.progress, deviceMarker: row.deviceMarker, fg, fgMuted: fgMutedOverride }, row.id))
147
+ rows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AchievementRow, { icon: row.icon, title: row.title, description: row.description, badgeColor: row.unlockedAt !== void 0 ? row.tierColor : LOCKED_BADGE_COLOR, checkColor: tierAccent(row.tierColor), unlockedLabel: row.unlockedLabel, progress: row.progress, deviceMarker: row.deviceMarker, fg, fgMuted: fgMutedOverride }, row.id))
147
148
  ] });
148
149
  }
149
150
  var styles2 = import_react_native3.StyleSheet.create({
@@ -223,7 +224,7 @@ var import_auto_paper8 = require("@rific/auto-paper");
223
224
  var import_feedback_press2 = require("@rific/feedback-press");
224
225
  var import_updater = require("@rific/updater");
225
226
  var import_core4 = require("@tastic/core");
226
- var import_react5 = require("react");
227
+ var import_react6 = require("react");
227
228
  var import_react_native12 = require("react-native");
228
229
  var import_react_native_paper8 = require("react-native-paper");
229
230
  var import_react_native_safe_area_context2 = require("react-native-safe-area-context");
@@ -232,6 +233,7 @@ var import_react_native_safe_area_context2 = require("react-native-safe-area-con
232
233
  var import_auto_paper7 = require("@rific/auto-paper");
233
234
  var import_feedback_press = require("@rific/feedback-press");
234
235
  var import_core2 = require("@tastic/core");
236
+ var import_react3 = require("react");
235
237
  var import_react_native10 = require("react-native");
236
238
  var import_react_native_paper6 = require("react-native-paper");
237
239
 
@@ -385,6 +387,18 @@ function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onC
385
387
  const { colors } = (0, import_auto_paper7.useAutoPaperTheme)();
386
388
  const { fg } = useOverlayCardColors();
387
389
  const { compact, onLayout } = useCompactDialog(rotation);
390
+ const onCancelRef = (0, import_react3.useRef)(onCancel);
391
+ (0, import_react3.useEffect)(() => {
392
+ onCancelRef.current = onCancel;
393
+ });
394
+ (0, import_react3.useEffect)(() => {
395
+ if (!visible) return;
396
+ const subscription = import_react_native10.BackHandler.addEventListener("hardwareBackPress", () => {
397
+ onCancelRef.current?.();
398
+ return true;
399
+ });
400
+ return () => subscription.remove();
401
+ }, [visible]);
388
402
  if (!visible) return null;
389
403
  const accent = destructive ? colors.danger : colors.primary;
390
404
  const onAccent = destructive ? colors.onDanger : colors.onPrimary;
@@ -421,7 +435,7 @@ var styles7 = import_react_native10.StyleSheet.create({
421
435
 
422
436
  // src/RotatedFrame.tsx
423
437
  var import_core3 = require("@tastic/core");
424
- var import_react3 = require("react");
438
+ var import_react4 = require("react");
425
439
  var import_react_native11 = require("react-native");
426
440
  var import_react_native_paper7 = require("react-native-paper");
427
441
  var import_react_native_safe_area_context = require("react-native-safe-area-context");
@@ -432,7 +446,7 @@ function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea =
432
446
  const window = (0, import_react_native11.useWindowDimensions)();
433
447
  const { width, height } = (0, import_core3.rotateDimensions)(window.width, window.height, rotation);
434
448
  const rawInsets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
435
- const insets = (0, import_react3.useMemo)(() => rotatedSafeArea ? (0, import_core3.rotateInsets)(rawInsets, rotation) : rawInsets, [rotatedSafeArea, rawInsets, rotation]);
449
+ const insets = (0, import_react4.useMemo)(() => rotatedSafeArea ? (0, import_core3.rotateInsets)(rawInsets, rotation) : rawInsets, [rotatedSafeArea, rawInsets, rotation]);
436
450
  const cappedWidth = maxContentWidth !== void 0 && width > height ? maxContentWidth : void 0;
437
451
  const column = /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native11.View, { style: [styles8.column, cappedWidth !== void 0 && { maxWidth: cappedWidth }], pointerEvents: passThrough ? "box-none" : void 0, children });
438
452
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_core3.FakeLandscapeView, { rotation, passThrough, style: styles8.root, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_safe_area_context.SafeAreaInsetsContext.Provider, { value: insets, children: portalHost ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper7.Portal.Host, { children: column }) : column }) });
@@ -450,18 +464,18 @@ var styles8 = import_react_native11.StyleSheet.create({
450
464
  });
451
465
 
452
466
  // src/useUpdateConfirmBridge.ts
453
- var import_react4 = require("react");
467
+ var import_react5 = require("react");
454
468
  function useUpdateConfirmBridge() {
455
- const [manifest, setManifest] = (0, import_react4.useState)(null);
456
- const resolveRef = (0, import_react4.useRef)(null);
457
- const onConfirm = (0, import_react4.useCallback)(
469
+ const [manifest, setManifest] = (0, import_react5.useState)(null);
470
+ const resolveRef = (0, import_react5.useRef)(null);
471
+ const onConfirm = (0, import_react5.useCallback)(
458
472
  (next) => new Promise((resolve) => {
459
473
  resolveRef.current = resolve;
460
474
  setManifest(next);
461
475
  }),
462
476
  []
463
477
  );
464
- const respond = (0, import_react4.useCallback)((confirmed) => {
478
+ const respond = (0, import_react5.useCallback)((confirmed) => {
465
479
  setManifest(null);
466
480
  resolveRef.current?.(confirmed);
467
481
  resolveRef.current = null;
@@ -486,11 +500,11 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
486
500
  const isTouchPrimary = (0, import_core4.useIsTouchPrimaryDevice)();
487
501
  const { settings: hapticSettings, set: setHapticSettings } = (0, import_feedback_press2.useHapticSettings)();
488
502
  const { settings: soundSettings, set: setSoundSettings } = (0, import_feedback_press2.useSoundSettings)();
489
- const sound = (0, import_react5.useContext)(import_feedback_press2.SoundContext);
503
+ const sound = (0, import_react6.useContext)(import_feedback_press2.SoundContext);
490
504
  const { forceShort: forceHapticFeedback } = (0, import_feedback_press2.useVibration)();
491
505
  const { settings: themeSettings, set: setThemeSettings } = (0, import_auto_paper8.useThemeSettings)();
492
506
  const expoBlur = (0, import_auto_paper8.useBlurModule)();
493
- const [infoMessage, setInfoMessage] = (0, import_react5.useState)(null);
507
+ const [infoMessage, setInfoMessage] = (0, import_react6.useState)(null);
494
508
  const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge();
495
509
  const { check, checking, updateReady } = (0, import_updater.useUpdater)({
496
510
  autoCheck: false,
@@ -500,7 +514,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
500
514
  onInfo: (title, message) => setInfoMessage({ title, message })
501
515
  });
502
516
  const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null;
503
- const [opened, setOpened] = (0, import_react5.useState)(visible);
517
+ const [opened, setOpened] = (0, import_react6.useState)(visible);
504
518
  if (visible && !opened) setOpened(true);
505
519
  const showLockOrientation = isTouchPrimary && lockOrientation !== void 0 && onLockOrientationChange !== void 0;
506
520
  const showEdgeGuard = import_react_native12.Platform.OS === "ios" && deferBottomEdgeGestures !== void 0 && onDeferBottomEdgeGestures !== void 0;
@@ -618,7 +632,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
618
632
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native12.View, { style: [overlayActionStyles.actions, styles9.footer], children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_feedback_press2.Button, { mode: "contained", onPress: onDismiss, style: overlayActionStyles.actionButtonSingle, buttonColor: colors.primary, textColor: colors.onPrimary, children: "Done" }) })
619
633
  ] }),
620
634
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(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) }),
621
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null) })
635
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null), onCancel: () => setInfoMessage(null) })
622
636
  ] }) });
623
637
  }
624
638
  var SHEET_GUTTER = 24;
@@ -709,24 +723,24 @@ var styles9 = import_react_native12.StyleSheet.create({
709
723
  var import_auto_paper9 = require("@rific/auto-paper");
710
724
  var import_feedback_press3 = require("@rific/feedback-press");
711
725
  var import_core5 = require("@tastic/core");
712
- var import_react6 = require("react");
726
+ var import_react7 = require("react");
713
727
  var import_react_native13 = require("react-native");
714
728
  var import_react_native_paper9 = require("react-native-paper");
715
729
  var import_jsx_runtime11 = require("react/jsx-runtime");
716
- 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 }) {
730
+ 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 }) {
717
731
  const { dark, colors } = (0, import_auto_paper9.useAutoPaperTheme)();
718
732
  const ambientRotation = (0, import_core5.useRotation)();
719
733
  const rotation = rotationOverride ?? ambientRotation;
720
- const [confirmResetVisible, setConfirmResetVisible] = (0, import_react6.useState)(false);
734
+ const [confirmResetVisible, setConfirmResetVisible] = (0, import_react7.useState)(false);
721
735
  const showReset = !!onReset;
722
736
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
723
737
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
724
738
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: [styles10.container, { backgroundColor: bg }], children: [
725
739
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: [styles10.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
726
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: onBack }),
740
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
727
741
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: titleVariant, style: [styles10.title, { color: fg }], children: title })
728
742
  ] }),
729
- /* @__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: [
730
744
  children,
731
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 })
732
746
  ] }),
@@ -767,7 +781,7 @@ var styles10 = import_react_native13.StyleSheet.create({
767
781
  resetButton: {
768
782
  marginTop: 16
769
783
  },
770
- // 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
771
785
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
772
786
  // can render at its full unclipped content height instead of the screen's), which leaves the
773
787
  // header's back button touch target unreliable underneath it.
@@ -824,7 +838,7 @@ var styles12 = import_react_native15.StyleSheet.create({
824
838
  });
825
839
 
826
840
  // src/StaggeredWord.tsx
827
- var import_react7 = require("react");
841
+ var import_react8 = require("react");
828
842
  var import_react_native16 = require("react-native");
829
843
  var import_react_native_reanimated = __toESM(require("react-native-reanimated"));
830
844
  var import_jsx_runtime14 = require("react/jsx-runtime");
@@ -835,7 +849,7 @@ function getStaggeredWordDuration(letterCount) {
835
849
  }
836
850
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
837
851
  const progress = (0, import_react_native_reanimated.useSharedValue)(reducedMotion ? 1 : 0);
838
- (0, import_react7.useEffect)(() => {
852
+ (0, import_react8.useEffect)(() => {
839
853
  if (reducedMotion) return;
840
854
  progress.value = (0, import_react_native_reanimated.withDelay)(index * STAGGER_MS, (0, import_react_native_reanimated.withTiming)(1, { duration: LETTER_DURATION_MS, easing: import_react_native_reanimated.Easing.out(import_react_native_reanimated.Easing.back(1.5)) }));
841
855
  }, [reducedMotion]);
@@ -16,7 +16,7 @@ import {
16
16
  UpdateDialog,
17
17
  getStaggeredWordDuration,
18
18
  useCompactDialog
19
- } from "../chunk-HGJKNLMD.mjs";
19
+ } from "../chunk-UOA35OWJ.mjs";
20
20
  import {
21
21
  COMPACT_DIALOG_HEIGHT,
22
22
  DialogHeader,
@@ -81,7 +81,7 @@ import { Platform } from "react-native";
81
81
  var MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" });
82
82
 
83
83
  // src/AchievementCatalogSection.tsx
84
- import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
84
+ import { getBlendedColor, useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
85
85
  import { Fragment } from "react";
86
86
  import { StyleSheet as StyleSheet2 } from "react-native";
87
87
  import { Text as Text2 } from "react-native-paper";
@@ -89,9 +89,10 @@ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
89
89
  function AchievementCatalogSection({ rows, label = "ALL ACHIEVEMENTS", fg, fgMuted: fgMutedOverride }) {
90
90
  const { dark } = useAutoPaperTheme2();
91
91
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
92
+ const tierAccent = (tierColor) => dark ? tierColor : getBlendedColor("#000000", tierColor, 0.5);
92
93
  return /* @__PURE__ */ jsxs2(Fragment, { children: [
93
94
  /* @__PURE__ */ jsx2(Text2, { variant: "labelMedium", style: [styles2.label, { color: fgMuted, fontFamily: MONO_FONT }], children: label }),
94
- rows.map((row) => /* @__PURE__ */ jsx2(AchievementRow, { icon: row.icon, title: row.title, description: row.description, badgeColor: row.unlockedAt !== void 0 ? row.tierColor : LOCKED_BADGE_COLOR, checkColor: row.tierColor, unlockedLabel: row.unlockedLabel, progress: row.progress, deviceMarker: row.deviceMarker, fg, fgMuted: fgMutedOverride }, row.id))
95
+ rows.map((row) => /* @__PURE__ */ jsx2(AchievementRow, { icon: row.icon, title: row.title, description: row.description, badgeColor: row.unlockedAt !== void 0 ? row.tierColor : LOCKED_BADGE_COLOR, checkColor: tierAccent(row.tierColor), unlockedLabel: row.unlockedLabel, progress: row.progress, deviceMarker: row.deviceMarker, fg, fgMuted: fgMutedOverride }, row.id))
95
96
  ] });
96
97
  }
97
98
  var styles2 = StyleSheet2.create({
@@ -187,13 +188,26 @@ function useCompactDialog(rotation = 0) {
187
188
  import { useAutoPaperTheme as useAutoPaperTheme5 } from "@rific/auto-paper";
188
189
  import { Button } from "@rific/feedback-press";
189
190
  import { toRotationStyle } from "@tastic/core";
190
- import { StyleSheet as StyleSheet5, View as View4 } from "react-native";
191
+ import { useEffect, useRef } from "react";
192
+ import { BackHandler, StyleSheet as StyleSheet5, View as View4 } from "react-native";
191
193
  import { Portal, Text as Text5 } from "react-native-paper";
192
194
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
193
195
  function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = "help-circle-outline", destructive = false }) {
194
196
  const { colors } = useAutoPaperTheme5();
195
197
  const { fg } = useOverlayCardColors();
196
198
  const { compact, onLayout } = useCompactDialog(rotation);
199
+ const onCancelRef = useRef(onCancel);
200
+ useEffect(() => {
201
+ onCancelRef.current = onCancel;
202
+ });
203
+ useEffect(() => {
204
+ if (!visible) return;
205
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
206
+ onCancelRef.current?.();
207
+ return true;
208
+ });
209
+ return () => subscription.remove();
210
+ }, [visible]);
197
211
  if (!visible) return null;
198
212
  const accent = destructive ? colors.danger : colors.primary;
199
213
  const onAccent = destructive ? colors.onDanger : colors.onPrimary;
@@ -269,10 +283,10 @@ import { Icon as Icon2, Portal as Portal3, Text as Text6 } from "react-native-pa
269
283
  import { useSafeAreaInsets as useSafeAreaInsets2 } from "react-native-safe-area-context";
270
284
 
271
285
  // src/useUpdateConfirmBridge.ts
272
- import { useCallback as useCallback2, useRef, useState as useState2 } from "react";
286
+ import { useCallback as useCallback2, useRef as useRef2, useState as useState2 } from "react";
273
287
  function useUpdateConfirmBridge() {
274
288
  const [manifest, setManifest] = useState2(null);
275
- const resolveRef = useRef(null);
289
+ const resolveRef = useRef2(null);
276
290
  const onConfirm = useCallback2(
277
291
  (next) => new Promise((resolve) => {
278
292
  resolveRef.current = resolve;
@@ -437,7 +451,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
437
451
  /* @__PURE__ */ jsx8(View6, { style: [overlayActionStyles.actions, styles7.footer], children: /* @__PURE__ */ jsx8(Button2, { mode: "contained", onPress: onDismiss, style: overlayActionStyles.actionButtonSingle, buttonColor: colors.primary, textColor: colors.onPrimary, children: "Done" }) })
438
452
  ] }),
439
453
  /* @__PURE__ */ jsx8(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) }),
440
- /* @__PURE__ */ jsx8(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null) })
454
+ /* @__PURE__ */ jsx8(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null), onCancel: () => setInfoMessage(null) })
441
455
  ] }) });
442
456
  }
443
457
  var SHEET_GUTTER = 24;
@@ -525,14 +539,14 @@ var styles7 = StyleSheet7.create({
525
539
  });
526
540
 
527
541
  // src/BaseStatsScreen.tsx
528
- import { useAutoPaperTheme as useAutoPaperTheme7 } from "@rific/auto-paper";
542
+ import { FadingScrollView as FadingScrollView2, useAutoPaperTheme as useAutoPaperTheme7 } from "@rific/auto-paper";
529
543
  import { Button as Button3, IconButton } from "@rific/feedback-press";
530
544
  import { useRotation as useRotation3 } from "@tastic/core";
531
545
  import { useState as useState4 } from "react";
532
- import { ScrollView, StyleSheet as StyleSheet8, View as View7 } from "react-native";
546
+ import { StyleSheet as StyleSheet8, View as View7 } from "react-native";
533
547
  import { Text as Text7 } from "react-native-paper";
534
548
  import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
535
- 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 }) {
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 }) {
536
550
  const { dark, colors } = useAutoPaperTheme7();
537
551
  const ambientRotation = useRotation3();
538
552
  const rotation = rotationOverride ?? ambientRotation;
@@ -542,10 +556,10 @@ function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", t
542
556
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
543
557
  return /* @__PURE__ */ jsxs8(View7, { style: [styles8.container, { backgroundColor: bg }], children: [
544
558
  /* @__PURE__ */ jsxs8(View7, { style: [styles8.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
545
- /* @__PURE__ */ jsx9(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: onBack }),
559
+ /* @__PURE__ */ jsx9(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
546
560
  /* @__PURE__ */ jsx9(Text7, { variant: titleVariant, style: [styles8.title, { color: fg }], children: title })
547
561
  ] }),
548
- /* @__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: [
549
563
  children,
550
564
  showReset && /* @__PURE__ */ jsx9(Button3, { mode: "contained", onPress: () => setConfirmResetVisible(true), buttonColor: colors.danger, textColor: colors.onDanger, style: styles8.resetButton, children: resetLabel })
551
565
  ] }),
@@ -586,7 +600,7 @@ var styles8 = StyleSheet8.create({
586
600
  resetButton: {
587
601
  marginTop: 16
588
602
  },
589
- // 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
590
604
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
591
605
  // can render at its full unclipped content height instead of the screen's), which leaves the
592
606
  // header's back button touch target unreliable underneath it.
@@ -643,7 +657,7 @@ var styles10 = StyleSheet10.create({
643
657
  });
644
658
 
645
659
  // src/StaggeredWord.tsx
646
- import { useEffect } from "react";
660
+ import { useEffect as useEffect2 } from "react";
647
661
  import { StyleSheet as StyleSheet11, View as View9 } from "react-native";
648
662
  import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming } from "react-native-reanimated";
649
663
  import { jsx as jsx12 } from "react/jsx-runtime";
@@ -654,7 +668,7 @@ function getStaggeredWordDuration(letterCount) {
654
668
  }
655
669
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
656
670
  const progress = useSharedValue(reducedMotion ? 1 : 0);
657
- useEffect(() => {
671
+ useEffect2(() => {
658
672
  if (reducedMotion) return;
659
673
  progress.value = withDelay(index * STAGGER_MS, withTiming(1, { duration: LETTER_DURATION_MS, easing: Easing.out(Easing.back(1.5)) }));
660
674
  }, [reducedMotion]);
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 };