@tastic/hud 0.19.0 → 0.20.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,21 +723,21 @@ 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
743
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.ScrollView, { style: styles10.scrollView, contentContainerStyle: [styles10.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -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-DK3HOZC5.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;
@@ -532,7 +546,7 @@ import { useState as useState4 } from "react";
532
546
  import { ScrollView, 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,7 +556,7 @@ 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
562
  /* @__PURE__ */ jsxs8(ScrollView, { style: styles8.scrollView, contentContainerStyle: [styles8.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -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.js CHANGED
@@ -38,22 +38,22 @@ __export(TriggerGauge_exports, {
38
38
  function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, dashWidth, startDeg = DEFAULT_START_DEG, endDeg = startDeg }) {
39
39
  const totalDurationMs = MOUNT_DURATION_MS + Math.max(0, segments - 1) * MOUNT_STAGGER_MS;
40
40
  const mountProgress = (0, import_react_native_reanimated.useSharedValue)(0);
41
- (0, import_react8.useLayoutEffect)(() => {
41
+ (0, import_react9.useLayoutEffect)(() => {
42
42
  mountProgress.value = (0, import_react_native_reanimated.withTiming)(1, { duration: totalDurationMs, easing: import_react_native_reanimated.Easing.out(import_react_native_reanimated.Easing.cubic) });
43
43
  }, []);
44
44
  const rawSweepDeg = ((endDeg - startDeg) % 360 + 360) % 360;
45
45
  const totalSweepDeg = rawSweepDeg === 0 ? 360 : rawSweepDeg;
46
46
  const step = segments > 0 ? totalSweepDeg / segments : 0;
47
- const centerDeg = (0, import_react8.useCallback)((i) => startDeg + (i + 0.5) * step, [startDeg, step]);
47
+ const centerDeg = (0, import_react9.useCallback)((i) => startDeg + (i + 0.5) * step, [startDeg, step]);
48
48
  const singleSelect = litIndices.length === 1;
49
49
  const angleDeg = (0, import_react_native_reanimated.useSharedValue)(centerDeg(litIndices[0] ?? 0));
50
50
  const litIndicesKey = [...litIndices].sort((a, b) => a - b).join(",");
51
- const [transitionFrom, setTransitionFrom] = (0, import_react8.useState)(litIndices);
51
+ const [transitionFrom, setTransitionFrom] = (0, import_react9.useState)(litIndices);
52
52
  const transitionFromKey = [...transitionFrom].sort((a, b) => a - b).join(",");
53
- const lastKeyRef = (0, import_react8.useRef)(litIndicesKey);
54
- const lastIndicesRef = (0, import_react8.useRef)(litIndices);
53
+ const lastKeyRef = (0, import_react9.useRef)(litIndicesKey);
54
+ const lastIndicesRef = (0, import_react9.useRef)(litIndices);
55
55
  const selectionProgress = (0, import_react_native_reanimated.useSharedValue)(1);
56
- (0, import_react8.useLayoutEffect)(() => {
56
+ (0, import_react9.useLayoutEffect)(() => {
57
57
  if (litIndicesKey !== lastKeyRef.current) {
58
58
  if (singleSelect && lastIndicesRef.current.length === 1) {
59
59
  angleDeg.value = (0, import_react_native_reanimated.withTiming)(centerDeg(litIndices[0]), { duration: SELECTION_TRANSITION_DURATION_MS, easing: import_react_native_reanimated.Easing.out(import_react_native_reanimated.Easing.cubic) });
@@ -135,13 +135,13 @@ function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, das
135
135
  /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native_skia.Path, { path: litPath, style: "stroke", strokeWidth: LIT_THICKNESS, strokeCap: "round", color: accentColor, opacity: 1 })
136
136
  ] }) });
137
137
  }
138
- var import_react_native_skia, import_core7, import_react8, import_react_native17, import_react_native_reanimated, import_jsx_runtime16, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles14;
138
+ var import_react_native_skia, import_core7, import_react9, import_react_native17, import_react_native_reanimated, import_jsx_runtime16, UNLIT_THICKNESS, LIT_THICKNESS, DASH_GAP_PX, ARC_GAP, DEFAULT_START_DEG, MIN_DASH_ANGLE_DEG, MOUNT_DURATION_MS, MOUNT_STAGGER_MS, SELECTION_TRANSITION_DURATION_MS, styles14;
139
139
  var init_TriggerGauge = __esm({
140
140
  "src/TriggerGauge.tsx"() {
141
141
  "use strict";
142
142
  import_react_native_skia = require("@shopify/react-native-skia");
143
143
  import_core7 = require("@tastic/core");
144
- import_react8 = require("react");
144
+ import_react9 = require("react");
145
145
  import_react_native17 = require("react-native");
146
146
  import_react_native_reanimated = require("react-native-reanimated");
147
147
  import_jsx_runtime16 = require("react/jsx-runtime");
@@ -301,9 +301,10 @@ var import_jsx_runtime2 = require("react/jsx-runtime");
301
301
  function AchievementCatalogSection({ rows, label = "ALL ACHIEVEMENTS", fg, fgMuted: fgMutedOverride }) {
302
302
  const { dark } = (0, import_auto_paper2.useAutoPaperTheme)();
303
303
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
304
+ const tierAccent = (tierColor) => dark ? tierColor : (0, import_auto_paper2.getBlendedColor)("#000000", tierColor, 0.5);
304
305
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react.Fragment, { children: [
305
306
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Text, { variant: "labelMedium", style: [styles2.label, { color: fgMuted, fontFamily: MONO_FONT }], children: label }),
306
- 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))
307
+ 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))
307
308
  ] });
308
309
  }
309
310
  var styles2 = import_react_native3.StyleSheet.create({
@@ -427,7 +428,7 @@ var import_auto_paper9 = require("@rific/auto-paper");
427
428
  var import_feedback_press2 = require("@rific/feedback-press");
428
429
  var import_updater = require("@rific/updater");
429
430
  var import_core4 = require("@tastic/core");
430
- var import_react5 = require("react");
431
+ var import_react6 = require("react");
431
432
  var import_react_native13 = require("react-native");
432
433
  var import_react_native_paper9 = require("react-native-paper");
433
434
  var import_react_native_safe_area_context2 = require("react-native-safe-area-context");
@@ -436,6 +437,7 @@ var import_react_native_safe_area_context2 = require("react-native-safe-area-con
436
437
  var import_auto_paper8 = require("@rific/auto-paper");
437
438
  var import_feedback_press = require("@rific/feedback-press");
438
439
  var import_core2 = require("@tastic/core");
440
+ var import_react3 = require("react");
439
441
  var import_react_native11 = require("react-native");
440
442
  var import_react_native_paper7 = require("react-native-paper");
441
443
 
@@ -589,6 +591,18 @@ function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onC
589
591
  const { colors } = (0, import_auto_paper8.useAutoPaperTheme)();
590
592
  const { fg } = useOverlayCardColors();
591
593
  const { compact, onLayout } = useCompactDialog(rotation);
594
+ const onCancelRef = (0, import_react3.useRef)(onCancel);
595
+ (0, import_react3.useEffect)(() => {
596
+ onCancelRef.current = onCancel;
597
+ });
598
+ (0, import_react3.useEffect)(() => {
599
+ if (!visible) return;
600
+ const subscription = import_react_native11.BackHandler.addEventListener("hardwareBackPress", () => {
601
+ onCancelRef.current?.();
602
+ return true;
603
+ });
604
+ return () => subscription.remove();
605
+ }, [visible]);
592
606
  if (!visible) return null;
593
607
  const accent = destructive ? colors.danger : colors.primary;
594
608
  const onAccent = destructive ? colors.onDanger : colors.onPrimary;
@@ -625,7 +639,7 @@ var styles8 = import_react_native11.StyleSheet.create({
625
639
 
626
640
  // src/RotatedFrame.tsx
627
641
  var import_core3 = require("@tastic/core");
628
- var import_react3 = require("react");
642
+ var import_react4 = require("react");
629
643
  var import_react_native12 = require("react-native");
630
644
  var import_react_native_paper8 = require("react-native-paper");
631
645
  var import_react_native_safe_area_context = require("react-native-safe-area-context");
@@ -636,7 +650,7 @@ function RotatedFrame({ children, rotation: rotationOverride, rotatedSafeArea =
636
650
  const window = (0, import_react_native12.useWindowDimensions)();
637
651
  const { width, height } = (0, import_core3.rotateDimensions)(window.width, window.height, rotation);
638
652
  const rawInsets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
639
- const insets = (0, import_react3.useMemo)(() => rotatedSafeArea ? (0, import_core3.rotateInsets)(rawInsets, rotation) : rawInsets, [rotatedSafeArea, rawInsets, rotation]);
653
+ const insets = (0, import_react4.useMemo)(() => rotatedSafeArea ? (0, import_core3.rotateInsets)(rawInsets, rotation) : rawInsets, [rotatedSafeArea, rawInsets, rotation]);
640
654
  const cappedWidth = maxContentWidth !== void 0 && width > height ? maxContentWidth : void 0;
641
655
  const column = /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native12.View, { style: [styles9.column, cappedWidth !== void 0 && { maxWidth: cappedWidth }], pointerEvents: passThrough ? "box-none" : void 0, children });
642
656
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_core3.FakeLandscapeView, { rotation, passThrough, style: styles9.root, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_safe_area_context.SafeAreaInsetsContext.Provider, { value: insets, children: portalHost ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_paper8.Portal.Host, { children: column }) : column }) });
@@ -654,18 +668,18 @@ var styles9 = import_react_native12.StyleSheet.create({
654
668
  });
655
669
 
656
670
  // src/useUpdateConfirmBridge.ts
657
- var import_react4 = require("react");
671
+ var import_react5 = require("react");
658
672
  function useUpdateConfirmBridge() {
659
- const [manifest, setManifest] = (0, import_react4.useState)(null);
660
- const resolveRef = (0, import_react4.useRef)(null);
661
- const onConfirm = (0, import_react4.useCallback)(
673
+ const [manifest, setManifest] = (0, import_react5.useState)(null);
674
+ const resolveRef = (0, import_react5.useRef)(null);
675
+ const onConfirm = (0, import_react5.useCallback)(
662
676
  (next) => new Promise((resolve) => {
663
677
  resolveRef.current = resolve;
664
678
  setManifest(next);
665
679
  }),
666
680
  []
667
681
  );
668
- const respond = (0, import_react4.useCallback)((confirmed) => {
682
+ const respond = (0, import_react5.useCallback)((confirmed) => {
669
683
  setManifest(null);
670
684
  resolveRef.current?.(confirmed);
671
685
  resolveRef.current = null;
@@ -690,11 +704,11 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
690
704
  const isTouchPrimary = (0, import_core4.useIsTouchPrimaryDevice)();
691
705
  const { settings: hapticSettings, set: setHapticSettings } = (0, import_feedback_press2.useHapticSettings)();
692
706
  const { settings: soundSettings, set: setSoundSettings } = (0, import_feedback_press2.useSoundSettings)();
693
- const sound = (0, import_react5.useContext)(import_feedback_press2.SoundContext);
707
+ const sound = (0, import_react6.useContext)(import_feedback_press2.SoundContext);
694
708
  const { forceShort: forceHapticFeedback } = (0, import_feedback_press2.useVibration)();
695
709
  const { settings: themeSettings, set: setThemeSettings } = (0, import_auto_paper9.useThemeSettings)();
696
710
  const expoBlur = (0, import_auto_paper9.useBlurModule)();
697
- const [infoMessage, setInfoMessage] = (0, import_react5.useState)(null);
711
+ const [infoMessage, setInfoMessage] = (0, import_react6.useState)(null);
698
712
  const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge();
699
713
  const { check, checking, updateReady } = (0, import_updater.useUpdater)({
700
714
  autoCheck: false,
@@ -704,7 +718,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
704
718
  onInfo: (title, message) => setInfoMessage({ title, message })
705
719
  });
706
720
  const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null;
707
- const [opened, setOpened] = (0, import_react5.useState)(visible);
721
+ const [opened, setOpened] = (0, import_react6.useState)(visible);
708
722
  if (visible && !opened) setOpened(true);
709
723
  const showLockOrientation = isTouchPrimary && lockOrientation !== void 0 && onLockOrientationChange !== void 0;
710
724
  const showEdgeGuard = import_react_native13.Platform.OS === "ios" && deferBottomEdgeGestures !== void 0 && onDeferBottomEdgeGestures !== void 0;
@@ -822,7 +836,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
822
836
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native13.View, { style: [overlayActionStyles.actions, styles10.footer], children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press2.Button, { mode: "contained", onPress: onDismiss, style: overlayActionStyles.actionButtonSingle, buttonColor: colors.primary, textColor: colors.onPrimary, children: "Done" }) })
823
837
  ] }),
824
838
  /* @__PURE__ */ (0, import_jsx_runtime11.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) }),
825
- /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null) })
839
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation: 0, onConfirm: () => setInfoMessage(null), onCancel: () => setInfoMessage(null) })
826
840
  ] }) });
827
841
  }
828
842
  var SHEET_GUTTER = 24;
@@ -913,21 +927,21 @@ var styles10 = import_react_native13.StyleSheet.create({
913
927
  var import_auto_paper10 = require("@rific/auto-paper");
914
928
  var import_feedback_press3 = require("@rific/feedback-press");
915
929
  var import_core5 = require("@tastic/core");
916
- var import_react6 = require("react");
930
+ var import_react7 = require("react");
917
931
  var import_react_native14 = require("react-native");
918
932
  var import_react_native_paper10 = require("react-native-paper");
919
933
  var import_jsx_runtime12 = require("react/jsx-runtime");
920
- 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 }) {
934
+ 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 }) {
921
935
  const { dark, colors } = (0, import_auto_paper10.useAutoPaperTheme)();
922
936
  const ambientRotation = (0, import_core5.useRotation)();
923
937
  const rotation = rotationOverride ?? ambientRotation;
924
- const [confirmResetVisible, setConfirmResetVisible] = (0, import_react6.useState)(false);
938
+ const [confirmResetVisible, setConfirmResetVisible] = (0, import_react7.useState)(false);
925
939
  const showReset = !!onReset;
926
940
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
927
941
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
928
942
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.View, { style: [styles11.container, { backgroundColor: bg }], children: [
929
943
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.View, { style: [styles11.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
930
- /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: onBack }),
944
+ /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, hitSlop, onPress: onBack, accessibilityLabel: backLabel }),
931
945
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_react_native_paper10.Text, { variant: titleVariant, style: [styles11.title, { color: fg }], children: title })
932
946
  ] }),
933
947
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.ScrollView, { style: styles11.scrollView, contentContainerStyle: [styles11.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -1086,11 +1100,11 @@ var styles13 = import_react_native16.StyleSheet.create({
1086
1100
  });
1087
1101
 
1088
1102
  // src/SkiaGate.tsx
1089
- var import_react7 = require("react");
1103
+ var import_react8 = require("react");
1090
1104
  var import_jsx_runtime15 = require("react/jsx-runtime");
1091
1105
  function SkiaGate({ getComponent, componentProps, fallback = null }) {
1092
- const [Component, setComponent] = (0, import_react7.useState)(null);
1093
- (0, import_react7.useEffect)(() => {
1106
+ const [Component, setComponent] = (0, import_react8.useState)(null);
1107
+ (0, import_react8.useEffect)(() => {
1094
1108
  let cancelled = false;
1095
1109
  getComponent().then((mod) => {
1096
1110
  if (!cancelled) setComponent(() => mod.default);
@@ -1111,7 +1125,7 @@ function TriggerGaugeHost(props) {
1111
1125
  }
1112
1126
 
1113
1127
  // src/useAutoAlign.ts
1114
- var import_react9 = require("react");
1128
+ var import_react10 = require("react");
1115
1129
  var import_react_native18 = require("react-native");
1116
1130
  var EDGE_MARGIN = 12;
1117
1131
  function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
@@ -1124,18 +1138,18 @@ function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
1124
1138
  return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight };
1125
1139
  }
1126
1140
  function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
1127
- const triggerRef = (0, import_react9.useRef)(null);
1141
+ const triggerRef = (0, import_react10.useRef)(null);
1128
1142
  const { width: windowWidth, height: windowHeight } = (0, import_react_native18.useWindowDimensions)();
1129
- const [align, setAlign] = (0, import_react9.useState)("center");
1130
- const [verticalAlign, setVerticalAlign] = (0, import_react9.useState)("below");
1131
- const [maxHeight, setMaxHeight] = (0, import_react9.useState)(contentHeight);
1132
- const [measured, setMeasured] = (0, import_react9.useState)(false);
1133
- const [prevOpen, setPrevOpen] = (0, import_react9.useState)(open);
1143
+ const [align, setAlign] = (0, import_react10.useState)("center");
1144
+ const [verticalAlign, setVerticalAlign] = (0, import_react10.useState)("below");
1145
+ const [maxHeight, setMaxHeight] = (0, import_react10.useState)(contentHeight);
1146
+ const [measured, setMeasured] = (0, import_react10.useState)(false);
1147
+ const [prevOpen, setPrevOpen] = (0, import_react10.useState)(open);
1134
1148
  if (open !== prevOpen) {
1135
1149
  setPrevOpen(open);
1136
1150
  if (open) setMeasured(false);
1137
1151
  }
1138
- const measure = (0, import_react9.useCallback)(() => {
1152
+ const measure = (0, import_react10.useCallback)(() => {
1139
1153
  triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
1140
1154
  const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight);
1141
1155
  const centerX = x + triggerWidth / 2;
@@ -1152,7 +1166,7 @@ function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
1152
1166
  setMeasured(true);
1153
1167
  });
1154
1168
  }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation]);
1155
- (0, import_react9.useEffect)(() => {
1169
+ (0, import_react10.useEffect)(() => {
1156
1170
  if (open) measure();
1157
1171
  }, [open, measure]);
1158
1172
  return { align, maxHeight, measured, triggerRef, verticalAlign };
@@ -1681,13 +1695,13 @@ var styles20 = import_react_native25.StyleSheet.create({
1681
1695
  });
1682
1696
 
1683
1697
  // src/usePopoverHost.ts
1684
- var import_react10 = require("react");
1698
+ var import_react11 = require("react");
1685
1699
  function usePopoverHost() {
1686
- const [openId, setOpenId] = (0, import_react10.useState)(null);
1687
- const toggle = (0, import_react10.useCallback)((id) => {
1700
+ const [openId, setOpenId] = (0, import_react11.useState)(null);
1701
+ const toggle = (0, import_react11.useCallback)((id) => {
1688
1702
  setOpenId((prev) => prev === id ? null : id);
1689
1703
  }, []);
1690
- const close = (0, import_react10.useCallback)(() => setOpenId(null), []);
1704
+ const close = (0, import_react11.useCallback)(() => setOpenId(null), []);
1691
1705
  return { openId, toggle, close };
1692
1706
  }
1693
1707
 
@@ -1786,7 +1800,7 @@ var styles22 = import_react_native28.StyleSheet.create({
1786
1800
  });
1787
1801
 
1788
1802
  // src/StaggeredWord.tsx
1789
- var import_react11 = require("react");
1803
+ var import_react12 = require("react");
1790
1804
  var import_react_native29 = require("react-native");
1791
1805
  var import_react_native_reanimated2 = __toESM(require("react-native-reanimated"));
1792
1806
  var import_jsx_runtime28 = require("react/jsx-runtime");
@@ -1797,7 +1811,7 @@ function getStaggeredWordDuration(letterCount) {
1797
1811
  }
1798
1812
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
1799
1813
  const progress = (0, import_react_native_reanimated2.useSharedValue)(reducedMotion ? 1 : 0);
1800
- (0, import_react11.useEffect)(() => {
1814
+ (0, import_react12.useEffect)(() => {
1801
1815
  if (reducedMotion) return;
1802
1816
  progress.value = (0, import_react_native_reanimated2.withDelay)(index * STAGGER_MS, (0, import_react_native_reanimated2.withTiming)(1, { duration: LETTER_DURATION_MS, easing: import_react_native_reanimated2.Easing.out(import_react_native_reanimated2.Easing.back(1.5)) }));
1803
1817
  }, [reducedMotion]);
@@ -1827,28 +1841,28 @@ function UpdateDialog({ rotation = 0, autoCheck = true, autoPrompt = true, onErr
1827
1841
  }
1828
1842
 
1829
1843
  // src/useQuitConfirmation.ts
1830
- var import_react12 = require("react");
1844
+ var import_react13 = require("react");
1831
1845
  function useQuitConfirmation(hasProgress, onConfirmedBack) {
1832
- const [confirmVisible, setConfirmVisible] = (0, import_react12.useState)(false);
1833
- const hasProgressRef = (0, import_react12.useRef)(hasProgress);
1834
- (0, import_react12.useEffect)(() => {
1846
+ const [confirmVisible, setConfirmVisible] = (0, import_react13.useState)(false);
1847
+ const hasProgressRef = (0, import_react13.useRef)(hasProgress);
1848
+ (0, import_react13.useEffect)(() => {
1835
1849
  hasProgressRef.current = hasProgress;
1836
1850
  });
1837
- const onConfirmedBackRef = (0, import_react12.useRef)(onConfirmedBack);
1838
- (0, import_react12.useEffect)(() => {
1851
+ const onConfirmedBackRef = (0, import_react13.useRef)(onConfirmedBack);
1852
+ (0, import_react13.useEffect)(() => {
1839
1853
  onConfirmedBackRef.current = onConfirmedBack;
1840
1854
  });
1841
- const requestBack = (0, import_react12.useCallback)(() => {
1855
+ const requestBack = (0, import_react13.useCallback)(() => {
1842
1856
  if (hasProgressRef.current()) setConfirmVisible(true);
1843
1857
  else onConfirmedBackRef.current();
1844
1858
  }, []);
1845
- const cancelBack = (0, import_react12.useCallback)(() => setConfirmVisible(false), []);
1859
+ const cancelBack = (0, import_react13.useCallback)(() => setConfirmVisible(false), []);
1846
1860
  return { confirmVisible, requestBack, cancelBack };
1847
1861
  }
1848
1862
 
1849
1863
  // src/useZoneClampedAlign.ts
1850
1864
  var import_split_screen = require("@tastic/split-screen");
1851
- var import_react13 = require("react");
1865
+ var import_react14 = require("react");
1852
1866
  var import_react_native30 = require("react-native");
1853
1867
  var import_react_native_safe_area_context3 = require("react-native-safe-area-context");
1854
1868
  var EDGE_MARGIN2 = 12;
@@ -1858,13 +1872,13 @@ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
1858
1872
  const { height: windowHeight } = (0, import_react_native30.useWindowDimensions)();
1859
1873
  const insets = (0, import_react_native_safe_area_context3.useSafeAreaInsets)();
1860
1874
  const shouldMeasure = open && auto.measured && zone !== null;
1861
- const [ownBounds, setOwnBounds] = (0, import_react13.useState)(null);
1862
- const [prevShouldMeasure, setPrevShouldMeasure] = (0, import_react13.useState)(shouldMeasure);
1875
+ const [ownBounds, setOwnBounds] = (0, import_react14.useState)(null);
1876
+ const [prevShouldMeasure, setPrevShouldMeasure] = (0, import_react14.useState)(shouldMeasure);
1863
1877
  if (shouldMeasure !== prevShouldMeasure) {
1864
1878
  setPrevShouldMeasure(shouldMeasure);
1865
1879
  setOwnBounds(null);
1866
1880
  }
1867
- (0, import_react13.useEffect)(() => {
1881
+ (0, import_react14.useEffect)(() => {
1868
1882
  if (!shouldMeasure) return;
1869
1883
  auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
1870
1884
  const belowShared = zone.zoneSide === "belowShared";
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-DK3HOZC5.mjs";
23
23
  import {
24
24
  COMPACT_DIALOG_HEIGHT,
25
25
  DialogHeader,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",
@@ -94,7 +94,7 @@
94
94
  "@shopify/react-native-skia": "^2.6.2",
95
95
  "@tastic/achievements": "^0.6.0",
96
96
  "@tastic/core": "^0.14.0",
97
- "@tastic/profile": "^0.12.0",
97
+ "@tastic/profile": "^0.13.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",
@@ -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
  )
@@ -2,7 +2,7 @@ import { 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, ScrollView, 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,7 +95,7 @@ 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>
@@ -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