@tastic/hud 0.18.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.
@@ -207,9 +210,11 @@ the fleet with real Redux Toolkit state - reads `lockOrientation`/`deferBottomEd
207
210
  `useSelector` and writes them back via `dispatch(gameActions.setLockOrientation(...))` directly
208
211
  inside the adapter, with nothing threaded through props at all. `BaseSettingsDialog` doesn't care
209
212
  which of these an app uses: sound, haptics, appearance, and update-checking are identical across
210
- every app and live entirely inside this package now, so `lockOrientation`/`onLockOrientationChange`
211
- and `deferBottomEdgeGestures`/`onDeferBottomEdgeGestures` are the only two settings an adapter
212
- actually needs to source itself, however it does that.
213
+ every app and live entirely inside this package now, so `lockOrientation`/`onLockOrientationChange`,
214
+ `deferBottomEdgeGestures`/`onDeferBottomEdgeGestures` and `keepAwake`/`onKeepAwakeChange` are the only
215
+ settings an adapter actually needs to source itself, however it does that. Each pair is optional: omit
216
+ one and its row doesn't show. The Keep Awake row only toggles the value; the app keeps the screen awake
217
+ itself while it's on (for example with `expo-keep-awake` on its match screen).
213
218
 
214
219
  Every adapter also passes `version={release.otaVersion}` (so the dialog's own update-check button
215
220
  knows what it's currently running) and `onUpdateError`, wired to a real `useToast()` call. Wire this
@@ -234,6 +239,8 @@ export function SettingsDialog({ visible, onDismiss, rotation = 0, settings, set
234
239
  onLockOrientationChange={(value) => setSettings({ lockOrientation: value })}
235
240
  deferBottomEdgeGestures={settings.deferBottomEdgeGestures}
236
241
  onDeferBottomEdgeGestures={(value) => setSettings({ deferBottomEdgeGestures: value })}
242
+ keepAwake={settings.keepAwake}
243
+ onKeepAwakeChange={(value) => setSettings({ keepAwake: value })}
237
244
  onUpdateError={(message) => error('Update check failed', message)}
238
245
  />
239
246
  )
@@ -466,7 +473,9 @@ row. Returns a `Fragment`, not a `View`: every call site renders the heading and
466
473
  siblings inside `BaseStatsScreen`'s own gap-spaced `ScrollView` content, and a wrapping container
467
474
  here would double up that spacing. Resolves each row's locked-vs-unlocked `badgeColor` itself
468
475
  (`row.tierColor` once `row.unlockedAt` is set, else the locked color) rather than upstream, since
469
- `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.
470
479
 
471
480
  ```ts
472
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[];
@@ -47,6 +47,8 @@ interface BaseSettingsDialogProps {
47
47
  onLockOrientationChange?: (value: boolean) => void;
48
48
  deferBottomEdgeGestures?: boolean;
49
49
  onDeferBottomEdgeGestures?: (value: boolean) => void;
50
+ keepAwake?: boolean;
51
+ onKeepAwakeChange?: (value: boolean) => void;
50
52
  hideSound?: boolean;
51
53
  hideHaptics?: boolean;
52
54
  hideAppearance?: boolean;
@@ -55,7 +57,7 @@ interface BaseSettingsDialogProps {
55
57
  onShowHowToPlay?: () => void;
56
58
  children?: ReactNode;
57
59
  }
58
- declare function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound, hideHaptics, hideAppearance, hideUpdateCheck, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps): react.JSX.Element | null;
60
+ declare function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound, hideHaptics, hideAppearance, hideUpdateCheck, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps): react.JSX.Element | null;
59
61
 
60
62
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
61
63
  interface Props$4 {
@@ -63,6 +65,8 @@ interface Props$4 {
63
65
  title?: string;
64
66
  titleVariant?: MD3TextVariant;
65
67
  onBack: () => void;
68
+ backLabel?: string;
69
+ hitSlop?: number | Insets;
66
70
  insets: {
67
71
  top: number;
68
72
  left: number;
@@ -77,7 +81,7 @@ interface Props$4 {
77
81
  bg?: string;
78
82
  children: ReactNode;
79
83
  }
80
- 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;
81
85
 
82
86
  interface ConfirmDialogProps {
83
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[];
@@ -47,6 +47,8 @@ interface BaseSettingsDialogProps {
47
47
  onLockOrientationChange?: (value: boolean) => void;
48
48
  deferBottomEdgeGestures?: boolean;
49
49
  onDeferBottomEdgeGestures?: (value: boolean) => void;
50
+ keepAwake?: boolean;
51
+ onKeepAwakeChange?: (value: boolean) => void;
50
52
  hideSound?: boolean;
51
53
  hideHaptics?: boolean;
52
54
  hideAppearance?: boolean;
@@ -55,7 +57,7 @@ interface BaseSettingsDialogProps {
55
57
  onShowHowToPlay?: () => void;
56
58
  children?: ReactNode;
57
59
  }
58
- declare function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound, hideHaptics, hideAppearance, hideUpdateCheck, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps): react.JSX.Element | null;
60
+ declare function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound, hideHaptics, hideAppearance, hideUpdateCheck, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps): react.JSX.Element | null;
59
61
 
60
62
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
61
63
  interface Props$4 {
@@ -63,6 +65,8 @@ interface Props$4 {
63
65
  title?: string;
64
66
  titleVariant?: MD3TextVariant;
65
67
  onBack: () => void;
68
+ backLabel?: string;
69
+ hitSlop?: number | Insets;
66
70
  insets: {
67
71
  top: number;
68
72
  left: number;
@@ -77,7 +81,7 @@ interface Props$4 {
77
81
  bg?: string;
78
82
  children: ReactNode;
79
83
  }
80
- 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;
81
85
 
82
86
  interface ConfirmDialogProps {
83
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;
@@ -474,7 +488,7 @@ var import_jsx_runtime10 = require("react/jsx-runtime");
474
488
  function SettingIcon({ source, color, containerColor }) {
475
489
  return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native12.View, { style: [styles9.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_paper8.Icon, { source, size: 18, color }) });
476
490
  }
477
- function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
491
+ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
478
492
  const { colors } = (0, import_auto_paper8.useAutoPaperTheme)();
479
493
  const ambientRotation = (0, import_core4.useRotation)();
480
494
  const rotation = rotationOverride ?? ambientRotation;
@@ -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,10 +514,11 @@ 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;
521
+ const showKeepAwake = isTouchPrimary && keepAwake !== void 0 && onKeepAwakeChange !== void 0;
507
522
  const showHaptics = import_react_native12.Platform.OS !== "web" && !hideHaptics;
508
523
  const showSound = !hideSound;
509
524
  const showAppearance = !hideAppearance;
@@ -530,6 +545,13 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
530
545
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_paper8.Text, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Require second swipe" })
531
546
  ] })
532
547
  ] }) }),
548
+ showKeepAwake && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => onKeepAwakeChange(!keepAwake), style: styles9.toggleButton, accessibilityLabel: `Keep awake ${keepAwake ? "on" : "off"}`, children: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_react_native12.View, { style: styles9.toggleContent, children: [
549
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(SettingIcon, { source: keepAwake ? "eye" : "eye-closed", color: keepAwake ? colors.tertiary : colors.onSurfaceVariant, containerColor: keepAwake ? colors.tertiaryContainer : colors.surfaceVariant }),
550
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_react_native12.View, { style: styles9.flexShrink, children: [
551
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_paper8.Text, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Keep Awake" }),
552
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_react_native_paper8.Text, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Stop auto-lock mid-match" })
553
+ ] })
554
+ ] }) }),
533
555
  (showSound || showHaptics) && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(import_react_native12.View, { style: styles9.toggleRow, children: [
534
556
  showSound && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
535
557
  import_feedback_press2.TouchableRipple,
@@ -610,7 +632,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
610
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" }) })
611
633
  ] }),
612
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) }),
613
- /* @__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) })
614
636
  ] }) });
615
637
  }
616
638
  var SHEET_GUTTER = 24;
@@ -701,21 +723,21 @@ var styles9 = import_react_native12.StyleSheet.create({
701
723
  var import_auto_paper9 = require("@rific/auto-paper");
702
724
  var import_feedback_press3 = require("@rific/feedback-press");
703
725
  var import_core5 = require("@tastic/core");
704
- var import_react6 = require("react");
726
+ var import_react7 = require("react");
705
727
  var import_react_native13 = require("react-native");
706
728
  var import_react_native_paper9 = require("react-native-paper");
707
729
  var import_jsx_runtime11 = require("react/jsx-runtime");
708
- 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 }) {
709
731
  const { dark, colors } = (0, import_auto_paper9.useAutoPaperTheme)();
710
732
  const ambientRotation = (0, import_core5.useRotation)();
711
733
  const rotation = rotationOverride ?? ambientRotation;
712
- const [confirmResetVisible, setConfirmResetVisible] = (0, import_react6.useState)(false);
734
+ const [confirmResetVisible, setConfirmResetVisible] = (0, import_react7.useState)(false);
713
735
  const showReset = !!onReset;
714
736
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
715
737
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
716
738
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: [styles10.container, { backgroundColor: bg }], children: [
717
739
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: [styles10.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
718
- /* @__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 }),
719
741
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: titleVariant, style: [styles10.title, { color: fg }], children: title })
720
742
  ] }),
721
743
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.ScrollView, { style: styles10.scrollView, contentContainerStyle: [styles10.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -816,7 +838,7 @@ var styles12 = import_react_native15.StyleSheet.create({
816
838
  });
817
839
 
818
840
  // src/StaggeredWord.tsx
819
- var import_react7 = require("react");
841
+ var import_react8 = require("react");
820
842
  var import_react_native16 = require("react-native");
821
843
  var import_react_native_reanimated = __toESM(require("react-native-reanimated"));
822
844
  var import_jsx_runtime14 = require("react/jsx-runtime");
@@ -827,7 +849,7 @@ function getStaggeredWordDuration(letterCount) {
827
849
  }
828
850
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
829
851
  const progress = (0, import_react_native_reanimated.useSharedValue)(reducedMotion ? 1 : 0);
830
- (0, import_react7.useEffect)(() => {
852
+ (0, import_react8.useEffect)(() => {
831
853
  if (reducedMotion) return;
832
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)) }));
833
855
  }, [reducedMotion]);
@@ -16,7 +16,7 @@ import {
16
16
  UpdateDialog,
17
17
  getStaggeredWordDuration,
18
18
  useCompactDialog
19
- } from "../chunk-Z2WUUYTE.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;
@@ -293,7 +307,7 @@ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
293
307
  function SettingIcon({ source, color, containerColor }) {
294
308
  return /* @__PURE__ */ jsx8(View6, { style: [styles7.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ jsx8(Icon2, { source, size: 18, color }) });
295
309
  }
296
- function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
310
+ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
297
311
  const { colors } = useAutoPaperTheme6();
298
312
  const ambientRotation = useRotation2();
299
313
  const rotation = rotationOverride ?? ambientRotation;
@@ -323,6 +337,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
323
337
  if (visible && !opened) setOpened(true);
324
338
  const showLockOrientation = isTouchPrimary && lockOrientation !== void 0 && onLockOrientationChange !== void 0;
325
339
  const showEdgeGuard = Platform2.OS === "ios" && deferBottomEdgeGestures !== void 0 && onDeferBottomEdgeGestures !== void 0;
340
+ const showKeepAwake = isTouchPrimary && keepAwake !== void 0 && onKeepAwakeChange !== void 0;
326
341
  const showHaptics = Platform2.OS !== "web" && !hideHaptics;
327
342
  const showSound = !hideSound;
328
343
  const showAppearance = !hideAppearance;
@@ -349,6 +364,13 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
349
364
  /* @__PURE__ */ jsx8(Text6, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Require second swipe" })
350
365
  ] })
351
366
  ] }) }),
367
+ showKeepAwake && /* @__PURE__ */ jsx8(TouchableRipple, { onPress: () => onKeepAwakeChange(!keepAwake), style: styles7.toggleButton, accessibilityLabel: `Keep awake ${keepAwake ? "on" : "off"}`, children: /* @__PURE__ */ jsxs7(View6, { style: styles7.toggleContent, children: [
368
+ /* @__PURE__ */ jsx8(SettingIcon, { source: keepAwake ? "eye" : "eye-closed", color: keepAwake ? colors.tertiary : colors.onSurfaceVariant, containerColor: keepAwake ? colors.tertiaryContainer : colors.surfaceVariant }),
369
+ /* @__PURE__ */ jsxs7(View6, { style: styles7.flexShrink, children: [
370
+ /* @__PURE__ */ jsx8(Text6, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Keep Awake" }),
371
+ /* @__PURE__ */ jsx8(Text6, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Stop auto-lock mid-match" })
372
+ ] })
373
+ ] }) }),
352
374
  (showSound || showHaptics) && /* @__PURE__ */ jsxs7(View6, { style: styles7.toggleRow, children: [
353
375
  showSound && /* @__PURE__ */ jsx8(
354
376
  TouchableRipple,
@@ -429,7 +451,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
429
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" }) })
430
452
  ] }),
431
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) }),
432
- /* @__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) })
433
455
  ] }) });
434
456
  }
435
457
  var SHEET_GUTTER = 24;
@@ -524,7 +546,7 @@ import { useState as useState4 } from "react";
524
546
  import { ScrollView, StyleSheet as StyleSheet8, View as View7 } from "react-native";
525
547
  import { Text as Text7 } from "react-native-paper";
526
548
  import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
527
- 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 }) {
528
550
  const { dark, colors } = useAutoPaperTheme7();
529
551
  const ambientRotation = useRotation3();
530
552
  const rotation = rotationOverride ?? ambientRotation;
@@ -534,7 +556,7 @@ function BaseStatsScreen({ rotation: rotationOverride, title = "Achievements", t
534
556
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
535
557
  return /* @__PURE__ */ jsxs8(View7, { style: [styles8.container, { backgroundColor: bg }], children: [
536
558
  /* @__PURE__ */ jsxs8(View7, { style: [styles8.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
537
- /* @__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 }),
538
560
  /* @__PURE__ */ jsx9(Text7, { variant: titleVariant, style: [styles8.title, { color: fg }], children: title })
539
561
  ] }),
540
562
  /* @__PURE__ */ jsxs8(ScrollView, { style: styles8.scrollView, contentContainerStyle: [styles8.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -635,7 +657,7 @@ var styles10 = StyleSheet10.create({
635
657
  });
636
658
 
637
659
  // src/StaggeredWord.tsx
638
- import { useEffect } from "react";
660
+ import { useEffect as useEffect2 } from "react";
639
661
  import { StyleSheet as StyleSheet11, View as View9 } from "react-native";
640
662
  import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming } from "react-native-reanimated";
641
663
  import { jsx as jsx12 } from "react/jsx-runtime";
@@ -646,7 +668,7 @@ function getStaggeredWordDuration(letterCount) {
646
668
  }
647
669
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
648
670
  const progress = useSharedValue(reducedMotion ? 1 : 0);
649
- useEffect(() => {
671
+ useEffect2(() => {
650
672
  if (reducedMotion) return;
651
673
  progress.value = withDelay(index * STAGGER_MS, withTiming(1, { duration: LETTER_DURATION_MS, easing: Easing.out(Easing.back(1.5)) }));
652
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;
@@ -678,7 +692,7 @@ var import_jsx_runtime11 = require("react/jsx-runtime");
678
692
  function SettingIcon({ source, color, containerColor }) {
679
693
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native13.View, { style: [styles10.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Icon, { source, size: 18, color }) });
680
694
  }
681
- function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
695
+ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }) {
682
696
  const { colors } = (0, import_auto_paper9.useAutoPaperTheme)();
683
697
  const ambientRotation = (0, import_core4.useRotation)();
684
698
  const rotation = rotationOverride ?? ambientRotation;
@@ -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,10 +718,11 @@ 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;
725
+ const showKeepAwake = isTouchPrimary && keepAwake !== void 0 && onKeepAwakeChange !== void 0;
711
726
  const showHaptics = import_react_native13.Platform.OS !== "web" && !hideHaptics;
712
727
  const showSound = !hideSound;
713
728
  const showAppearance = !hideAppearance;
@@ -734,6 +749,13 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
734
749
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Require second swipe" })
735
750
  ] })
736
751
  ] }) }),
752
+ showKeepAwake && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => onKeepAwakeChange(!keepAwake), style: styles10.toggleButton, accessibilityLabel: `Keep awake ${keepAwake ? "on" : "off"}`, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: styles10.toggleContent, children: [
753
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(SettingIcon, { source: keepAwake ? "eye" : "eye-closed", color: keepAwake ? colors.tertiary : colors.onSurfaceVariant, containerColor: keepAwake ? colors.tertiaryContainer : colors.surfaceVariant }),
754
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: styles10.flexShrink, children: [
755
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Keep Awake" }),
756
+ /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(import_react_native_paper9.Text, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Stop auto-lock mid-match" })
757
+ ] })
758
+ ] }) }),
737
759
  (showSound || showHaptics) && /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_react_native13.View, { style: styles10.toggleRow, children: [
738
760
  showSound && /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(
739
761
  import_feedback_press2.TouchableRipple,
@@ -814,7 +836,7 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
814
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" }) })
815
837
  ] }),
816
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) }),
817
- /* @__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) })
818
840
  ] }) });
819
841
  }
820
842
  var SHEET_GUTTER = 24;
@@ -905,21 +927,21 @@ var styles10 = import_react_native13.StyleSheet.create({
905
927
  var import_auto_paper10 = require("@rific/auto-paper");
906
928
  var import_feedback_press3 = require("@rific/feedback-press");
907
929
  var import_core5 = require("@tastic/core");
908
- var import_react6 = require("react");
930
+ var import_react7 = require("react");
909
931
  var import_react_native14 = require("react-native");
910
932
  var import_react_native_paper10 = require("react-native-paper");
911
933
  var import_jsx_runtime12 = require("react/jsx-runtime");
912
- 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 }) {
913
935
  const { dark, colors } = (0, import_auto_paper10.useAutoPaperTheme)();
914
936
  const ambientRotation = (0, import_core5.useRotation)();
915
937
  const rotation = rotationOverride ?? ambientRotation;
916
- const [confirmResetVisible, setConfirmResetVisible] = (0, import_react6.useState)(false);
938
+ const [confirmResetVisible, setConfirmResetVisible] = (0, import_react7.useState)(false);
917
939
  const showReset = !!onReset;
918
940
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
919
941
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
920
942
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.View, { style: [styles11.container, { backgroundColor: bg }], children: [
921
943
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.View, { style: [styles11.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
922
- /* @__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 }),
923
945
  /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(import_react_native_paper10.Text, { variant: titleVariant, style: [styles11.title, { color: fg }], children: title })
924
946
  ] }),
925
947
  /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(import_react_native14.ScrollView, { style: styles11.scrollView, contentContainerStyle: [styles11.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, children: [
@@ -1078,11 +1100,11 @@ var styles13 = import_react_native16.StyleSheet.create({
1078
1100
  });
1079
1101
 
1080
1102
  // src/SkiaGate.tsx
1081
- var import_react7 = require("react");
1103
+ var import_react8 = require("react");
1082
1104
  var import_jsx_runtime15 = require("react/jsx-runtime");
1083
1105
  function SkiaGate({ getComponent, componentProps, fallback = null }) {
1084
- const [Component, setComponent] = (0, import_react7.useState)(null);
1085
- (0, import_react7.useEffect)(() => {
1106
+ const [Component, setComponent] = (0, import_react8.useState)(null);
1107
+ (0, import_react8.useEffect)(() => {
1086
1108
  let cancelled = false;
1087
1109
  getComponent().then((mod) => {
1088
1110
  if (!cancelled) setComponent(() => mod.default);
@@ -1103,7 +1125,7 @@ function TriggerGaugeHost(props) {
1103
1125
  }
1104
1126
 
1105
1127
  // src/useAutoAlign.ts
1106
- var import_react9 = require("react");
1128
+ var import_react10 = require("react");
1107
1129
  var import_react_native18 = require("react-native");
1108
1130
  var EDGE_MARGIN = 12;
1109
1131
  function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
@@ -1116,18 +1138,18 @@ function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
1116
1138
  return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight };
1117
1139
  }
1118
1140
  function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
1119
- const triggerRef = (0, import_react9.useRef)(null);
1141
+ const triggerRef = (0, import_react10.useRef)(null);
1120
1142
  const { width: windowWidth, height: windowHeight } = (0, import_react_native18.useWindowDimensions)();
1121
- const [align, setAlign] = (0, import_react9.useState)("center");
1122
- const [verticalAlign, setVerticalAlign] = (0, import_react9.useState)("below");
1123
- const [maxHeight, setMaxHeight] = (0, import_react9.useState)(contentHeight);
1124
- const [measured, setMeasured] = (0, import_react9.useState)(false);
1125
- 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);
1126
1148
  if (open !== prevOpen) {
1127
1149
  setPrevOpen(open);
1128
1150
  if (open) setMeasured(false);
1129
1151
  }
1130
- const measure = (0, import_react9.useCallback)(() => {
1152
+ const measure = (0, import_react10.useCallback)(() => {
1131
1153
  triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
1132
1154
  const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight);
1133
1155
  const centerX = x + triggerWidth / 2;
@@ -1144,7 +1166,7 @@ function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
1144
1166
  setMeasured(true);
1145
1167
  });
1146
1168
  }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation]);
1147
- (0, import_react9.useEffect)(() => {
1169
+ (0, import_react10.useEffect)(() => {
1148
1170
  if (open) measure();
1149
1171
  }, [open, measure]);
1150
1172
  return { align, maxHeight, measured, triggerRef, verticalAlign };
@@ -1673,13 +1695,13 @@ var styles20 = import_react_native25.StyleSheet.create({
1673
1695
  });
1674
1696
 
1675
1697
  // src/usePopoverHost.ts
1676
- var import_react10 = require("react");
1698
+ var import_react11 = require("react");
1677
1699
  function usePopoverHost() {
1678
- const [openId, setOpenId] = (0, import_react10.useState)(null);
1679
- const toggle = (0, import_react10.useCallback)((id) => {
1700
+ const [openId, setOpenId] = (0, import_react11.useState)(null);
1701
+ const toggle = (0, import_react11.useCallback)((id) => {
1680
1702
  setOpenId((prev) => prev === id ? null : id);
1681
1703
  }, []);
1682
- const close = (0, import_react10.useCallback)(() => setOpenId(null), []);
1704
+ const close = (0, import_react11.useCallback)(() => setOpenId(null), []);
1683
1705
  return { openId, toggle, close };
1684
1706
  }
1685
1707
 
@@ -1778,7 +1800,7 @@ var styles22 = import_react_native28.StyleSheet.create({
1778
1800
  });
1779
1801
 
1780
1802
  // src/StaggeredWord.tsx
1781
- var import_react11 = require("react");
1803
+ var import_react12 = require("react");
1782
1804
  var import_react_native29 = require("react-native");
1783
1805
  var import_react_native_reanimated2 = __toESM(require("react-native-reanimated"));
1784
1806
  var import_jsx_runtime28 = require("react/jsx-runtime");
@@ -1789,7 +1811,7 @@ function getStaggeredWordDuration(letterCount) {
1789
1811
  }
1790
1812
  function AnimatedLetter({ char, index, color, reducedMotion, fontFamily, fontSize, lineHeight, fontWeight, letterSpacing }) {
1791
1813
  const progress = (0, import_react_native_reanimated2.useSharedValue)(reducedMotion ? 1 : 0);
1792
- (0, import_react11.useEffect)(() => {
1814
+ (0, import_react12.useEffect)(() => {
1793
1815
  if (reducedMotion) return;
1794
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)) }));
1795
1817
  }, [reducedMotion]);
@@ -1819,28 +1841,28 @@ function UpdateDialog({ rotation = 0, autoCheck = true, autoPrompt = true, onErr
1819
1841
  }
1820
1842
 
1821
1843
  // src/useQuitConfirmation.ts
1822
- var import_react12 = require("react");
1844
+ var import_react13 = require("react");
1823
1845
  function useQuitConfirmation(hasProgress, onConfirmedBack) {
1824
- const [confirmVisible, setConfirmVisible] = (0, import_react12.useState)(false);
1825
- const hasProgressRef = (0, import_react12.useRef)(hasProgress);
1826
- (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)(() => {
1827
1849
  hasProgressRef.current = hasProgress;
1828
1850
  });
1829
- const onConfirmedBackRef = (0, import_react12.useRef)(onConfirmedBack);
1830
- (0, import_react12.useEffect)(() => {
1851
+ const onConfirmedBackRef = (0, import_react13.useRef)(onConfirmedBack);
1852
+ (0, import_react13.useEffect)(() => {
1831
1853
  onConfirmedBackRef.current = onConfirmedBack;
1832
1854
  });
1833
- const requestBack = (0, import_react12.useCallback)(() => {
1855
+ const requestBack = (0, import_react13.useCallback)(() => {
1834
1856
  if (hasProgressRef.current()) setConfirmVisible(true);
1835
1857
  else onConfirmedBackRef.current();
1836
1858
  }, []);
1837
- const cancelBack = (0, import_react12.useCallback)(() => setConfirmVisible(false), []);
1859
+ const cancelBack = (0, import_react13.useCallback)(() => setConfirmVisible(false), []);
1838
1860
  return { confirmVisible, requestBack, cancelBack };
1839
1861
  }
1840
1862
 
1841
1863
  // src/useZoneClampedAlign.ts
1842
1864
  var import_split_screen = require("@tastic/split-screen");
1843
- var import_react13 = require("react");
1865
+ var import_react14 = require("react");
1844
1866
  var import_react_native30 = require("react-native");
1845
1867
  var import_react_native_safe_area_context3 = require("react-native-safe-area-context");
1846
1868
  var EDGE_MARGIN2 = 12;
@@ -1850,13 +1872,13 @@ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
1850
1872
  const { height: windowHeight } = (0, import_react_native30.useWindowDimensions)();
1851
1873
  const insets = (0, import_react_native_safe_area_context3.useSafeAreaInsets)();
1852
1874
  const shouldMeasure = open && auto.measured && zone !== null;
1853
- const [ownBounds, setOwnBounds] = (0, import_react13.useState)(null);
1854
- 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);
1855
1877
  if (shouldMeasure !== prevShouldMeasure) {
1856
1878
  setPrevShouldMeasure(shouldMeasure);
1857
1879
  setOwnBounds(null);
1858
1880
  }
1859
- (0, import_react13.useEffect)(() => {
1881
+ (0, import_react14.useEffect)(() => {
1860
1882
  if (!shouldMeasure) return;
1861
1883
  auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
1862
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-Z2WUUYTE.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.18.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",
@@ -87,15 +87,15 @@
87
87
  "@infinitetoken/eslint-config": "^0.2.0",
88
88
  "@infinitetoken/jest-config": "^0.4.0",
89
89
  "@infinitetoken/tsconfig": "^0.5.0",
90
- "@rific/auto-paper": "^0.13.0",
91
- "@rific/core": "^0.3.0",
90
+ "@rific/auto-paper": "^0.13.2",
91
+ "@rific/core": "^0.3.1",
92
92
  "@rific/feedback-press": "^0.13.0",
93
- "@rific/updater": "^0.6.0",
93
+ "@rific/updater": "^0.7.1",
94
94
  "@shopify/react-native-skia": "^2.6.2",
95
- "@tastic/achievements": "^0.5.0",
96
- "@tastic/core": "^0.13.0",
97
- "@tastic/profile": "^0.12.0",
98
- "@tastic/split-screen": "^0.5.0",
95
+ "@tastic/achievements": "^0.6.0",
96
+ "@tastic/core": "^0.14.0",
97
+ "@tastic/profile": "^0.13.0",
98
+ "@tastic/split-screen": "^0.5.2",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
101
101
  "@types/jest": "^30.0.0",
@@ -1,4 +1,4 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { getBlendedColor, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import type { AchievementCatalogRow } from '@tastic/achievements'
3
3
  import { Fragment } from 'react'
4
4
  import { StyleSheet } from 'react-native'
@@ -36,9 +36,16 @@ interface Props {
36
36
  // AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
37
37
  // (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
38
38
  // the same reason.
39
+ //
40
+ // The check mark and progress bar (AchievementRow's checkColor) are the tier color in dark mode, and
41
+ // the tier color mixed half with black in light mode. Raw gold (#FFD54F) and silver (#C0C0C8) are
42
+ // about 1.4:1 and 1.8:1 against the light screen's white, so a bar or check in either barely shows;
43
+ // mixed, they're about 5:1 and 6:1 (bronze goes from about 3:1 to 9:1). The badge keeps the true tier
44
+ // color: its glyph is black, which reads on all three.
39
45
  export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
40
46
  const { dark } = useAutoPaperTheme()
41
47
  const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
48
+ const tierAccent = (tierColor: string) => (dark ? tierColor : getBlendedColor('#000000', tierColor, 0.5))
42
49
 
43
50
  return (
44
51
  <Fragment>
@@ -46,7 +53,7 @@ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg
46
53
  {label}
47
54
  </Text>
48
55
  {rows.map((row) => (
49
- <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
56
+ <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={tierAccent(row.tierColor)} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
50
57
  ))}
51
58
  </Fragment>
52
59
  )
@@ -21,10 +21,10 @@ interface Props {
21
21
  // timestamp - this component has no opinion on date formatting or streak conventions, which vary
22
22
  // per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
23
23
  unlockedLabel?: string
24
- // Color for the unlocked check-circle icon/badge glyph - typically the same tier color passed to
25
- // badgeColor, kept as its own prop rather than reused internally so a caller can special-case it
26
- // independently if its own tier scheme ever needs to (matching LightCycles' own call site, which
27
- // does pass the identical value to both today).
24
+ // Color for the unlocked check-circle icon and the locked progress bar - defaults to badgeColor.
25
+ // Its own prop so a caller can tune it apart from the badge: AchievementCatalogSection passes the
26
+ // tier color darkened in light mode, where raw gold and silver barely show on white, while the badge
27
+ // keeps the true tier color behind its black glyph.
28
28
  checkColor?: string
29
29
  // Only rendered while locked (unlockedLabel is undefined) - 0..1.
30
30
  progress?: number
@@ -56,6 +56,13 @@ export interface BaseSettingsDialogProps {
56
56
  // @tastic/edge-guard's own swizzle is a no-op on every other platform.
57
57
  deferBottomEdgeGestures?: boolean
58
58
  onDeferBottomEdgeGestures?: (value: boolean) => void
59
+ // Optional, and always passed as a pair - omit both for an app that doesn't keep the screen awake.
60
+ // This dialog only shows and reports the toggle; the app itself keeps the screen on while it's set
61
+ // (expo-keep-awake on its match screen), so this package needs no native dependency for it. Shown
62
+ // on the same devices as Lock Orientation (see isTouchPrimary below): a desktop/laptop browser
63
+ // stays awake on its own while someone is playing with its keyboard or mouse.
64
+ keepAwake?: boolean
65
+ onKeepAwakeChange?: (value: boolean) => void
59
66
  // Explicit opt-outs for the rows that pull their own state straight from @rific/feedback-press,
60
67
  // @rific/auto-paper, and @rific/updater's own shared contexts/hooks rather than from anything the
61
68
  // caller passes in - unlike Lock Orientation/Edge Guard above, there's no "value" to omit for
@@ -83,14 +90,14 @@ export interface BaseSettingsDialogProps {
83
90
  children?: ReactNode
84
91
  }
85
92
 
86
- // The settings shell every @tastic game shares - orientation lock, the optional edge-guard toggle,
87
- // sound/haptics, appearance, and update checking - extracted after the same six rows were
93
+ // The settings shell every @tastic game shares - orientation lock, the optional edge-guard and
94
+ // keep-awake toggles, sound/haptics, appearance, and update checking - extracted after the same six rows were
88
95
  // independently copied into LightCycles, AirHockey, BoxHockey, Pong, and Snake and then needed
89
96
  // hand-editing in every one of them, twice, for what were meant to be identical fixes. `children`
90
97
  // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
91
98
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
92
99
  // forking this component.
93
- export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
100
+ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, keepAwake, onKeepAwakeChange, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
94
101
  const { colors } = useAutoPaperTheme()
95
102
  const ambientRotation = useRotation()
96
103
  const rotation = rotationOverride ?? ambientRotation
@@ -106,7 +113,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
106
113
  // Dialog's own 32pt padding is simply overhung.
107
114
  const insets = rotateInsets(useSafeAreaInsets(), rotation)
108
115
  const sheetWidth = Math.min(overlayStyles.card.maxWidth, (turned ? window.height : window.width) - 2 * Math.max(insets.left, insets.right, SHEET_GUTTER))
109
- // Also gates the Lock Orientation row below (alongside showLockOrientation itself) - true
116
+ // Also gates the Lock Orientation and Keep Awake rows below (alongside their own props) - true
110
117
  // unconditionally on native (see the hook's own doc), so this only actually excludes a
111
118
  // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
112
119
  // against; a resize there would just make the setting silently stop reflowing the layout instead
@@ -153,6 +160,7 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
153
160
 
154
161
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
155
162
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
163
+ const showKeepAwake = isTouchPrimary && keepAwake !== undefined && onKeepAwakeChange !== undefined
156
164
  const showHaptics = Platform.OS !== 'web' && !hideHaptics
157
165
  const showSound = !hideSound
158
166
  const showAppearance = !hideAppearance
@@ -235,6 +243,23 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
235
243
  </TouchableRipple>
236
244
  )}
237
245
 
246
+ {/* See the keepAwake prop's own doc: the app does the keeping awake, this only toggles it. */}
247
+ {showKeepAwake && (
248
+ <TouchableRipple onPress={() => onKeepAwakeChange!(!keepAwake)} style={styles.toggleButton} accessibilityLabel={`Keep awake ${keepAwake ? 'on' : 'off'}`}>
249
+ <View style={styles.toggleContent}>
250
+ <SettingIcon source={keepAwake ? 'eye' : 'eye-closed'} color={keepAwake ? colors.tertiary : colors.onSurfaceVariant} containerColor={keepAwake ? colors.tertiaryContainer : colors.surfaceVariant} />
251
+ <View style={styles.flexShrink}>
252
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
253
+ Keep Awake
254
+ </Text>
255
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
256
+ Stop auto-lock mid-match
257
+ </Text>
258
+ </View>
259
+ </View>
260
+ </TouchableRipple>
261
+ )}
262
+
238
263
  {/* Side by side, not stacked - neither needs a full row's width (single-line label,
239
264
  no description under it the way Lock Orientation has), so sharing one row reads
240
265
  just as clearly and takes half the vertical space. toggleRow carries the same
@@ -375,7 +400,9 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
375
400
  </View>
376
401
  </Dialog>
377
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)} />
378
- <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)} />
379
406
  </RotatedFrame>
380
407
  </Portal>
381
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