@tastic/hud 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -71,12 +71,19 @@ var styles = StyleSheet.create({
71
71
  });
72
72
 
73
73
  // src/BaseSettingsDialog.tsx
74
- import { AutoAppearancePicker, Dialog, useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
75
- import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from "@rific/feedback-press";
74
+ import { AutoAppearancePicker, Dialog, useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
75
+ import { Button as Button2, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from "@rific/feedback-press";
76
76
  import { useUpdater } from "@rific/updater";
77
- import { toRotationStyle, useIsTouchPrimaryDevice, useRotation } from "@tastic/core";
78
- import { useContext, useState } from "react";
79
- import { Platform, ScrollView, StyleSheet as StyleSheet3, View as View2 } from "react-native";
77
+ import { toRotationStyle as toRotationStyle2, useIsTouchPrimaryDevice, useRotation } from "@tastic/core";
78
+ import { useContext, useState as useState2 } from "react";
79
+ import { Platform, ScrollView, StyleSheet as StyleSheet4, View as View3 } from "react-native";
80
+ import { Icon as Icon3, Text as Text3 } from "react-native-paper";
81
+
82
+ // src/ConfirmDialog.tsx
83
+ import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
84
+ import { Button } from "@rific/feedback-press";
85
+ import { toRotationStyle } from "@tastic/core";
86
+ import { StyleSheet as StyleSheet3, View as View2 } from "react-native";
80
87
  import { Icon as Icon2, Portal, Text as Text2 } from "react-native-paper";
81
88
 
82
89
  // src/overlayCard.ts
@@ -110,13 +117,100 @@ var overlayStyles = StyleSheet2.create({
110
117
  }
111
118
  });
112
119
 
120
+ // src/ConfirmDialog.tsx
121
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
122
+ function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = "help-circle-outline", destructive = false }) {
123
+ const { dark, colors } = useAutoPaperTheme2();
124
+ if (!visible) return null;
125
+ const { fg, cardBg, cardBorder } = getOverlayCardColors(dark);
126
+ const accent = destructive ? colors.danger : colors.primary;
127
+ const onAccent = destructive ? colors.onDanger : colors.onPrimary;
128
+ return /* @__PURE__ */ jsx2(Portal, { children: /* @__PURE__ */ jsx2(View2, { style: [overlayStyles.overlay, styles2.overlay], children: /* @__PURE__ */ jsxs2(View2, { style: [overlayStyles.card, styles2.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle(rotation)], children: [
129
+ /* @__PURE__ */ jsx2(Icon2, { source: icon, size: 64, color: accent }),
130
+ /* @__PURE__ */ jsx2(Text2, { variant: "headlineLarge", style: [styles2.title, { color: accent }], children: title }),
131
+ /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: [styles2.body, { color: fg }], children: message }),
132
+ /* @__PURE__ */ jsxs2(View2, { style: styles2.actions, children: [
133
+ cancelLabel !== void 0 && onCancel !== void 0 && /* @__PURE__ */ jsx2(Button, { mode: "outlined", onPress: onCancel, style: styles2.actionButton, children: cancelLabel }),
134
+ /* @__PURE__ */ jsx2(Button, { mode: "contained", onPress: onConfirm, style: styles2.actionButton, buttonColor: accent, textColor: onAccent, children: confirmLabel })
135
+ ] })
136
+ ] }) }) });
137
+ }
138
+ var styles2 = StyleSheet3.create({
139
+ // flexBasis (not flex: 1's own 0%) is each button's real floor: whenever both fit side by side,
140
+ // flexGrow still splits any leftover width evenly beyond that floor, same visual result as the
141
+ // old flex: 1 on a roomy screen - but a button can never be squeezed narrower than 128, only
142
+ // wrap to its own row instead (see actions' flexWrap below). react-native-paper's Button
143
+ // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
144
+ // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
145
+ // fixable by just giving the row more width, until confirmed live that these two-word labels
146
+ // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
147
+ // width fix below provides, on a narrow enough screen. flexBasis is the real fix: past a certain
148
+ // width there's no side-by-side arrangement that fits both buttons at their natural label size,
149
+ // so the layout has to give (stack) rather than the label (truncate).
150
+ actionButton: {
151
+ flexBasis: 128,
152
+ flexGrow: 1
153
+ },
154
+ actions: {
155
+ flexDirection: "row",
156
+ flexWrap: "wrap",
157
+ gap: 12,
158
+ width: "100%"
159
+ },
160
+ body: {
161
+ textAlign: "center"
162
+ },
163
+ // Additive on top of overlayCard's own shared `card` shape (borderRadius/borderWidth/gap/maxWidth/
164
+ // padding) — claims its full allowance instead of just maxWidth alone, which only caps how wide the
165
+ // card is allowed to get - without an explicit width, a flex-centered child sizes to its own content
166
+ // instead of stretching, so the card (and therefore actions below) can end up narrower than the
167
+ // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
168
+ card: {
169
+ width: "100%"
170
+ },
171
+ // Additive on top of overlayCard's own shared `overlay` backdrop — gives the card room to breathe
172
+ // from the screen edges now that it reliably stretches to fill its full width allowance (see
173
+ // card's own width comment) - without this, width: '100%' would run the card edge-to-edge on a
174
+ // screen narrower than maxWidth's own 360.
175
+ overlay: {
176
+ paddingHorizontal: 24
177
+ },
178
+ // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
179
+ // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
180
+ // line inside that block still defaults to left-aligned without this.
181
+ title: {
182
+ fontWeight: "bold",
183
+ textAlign: "center"
184
+ }
185
+ });
186
+
187
+ // src/useUpdateConfirmBridge.ts
188
+ import { useCallback, useRef, useState } from "react";
189
+ function useUpdateConfirmBridge() {
190
+ const [manifest, setManifest] = useState(null);
191
+ const resolveRef = useRef(null);
192
+ const onConfirm = useCallback(
193
+ (next) => new Promise((resolve) => {
194
+ resolveRef.current = resolve;
195
+ setManifest(next);
196
+ }),
197
+ []
198
+ );
199
+ const respond = useCallback((confirmed) => {
200
+ setManifest(null);
201
+ resolveRef.current?.(confirmed);
202
+ resolveRef.current = null;
203
+ }, []);
204
+ return { manifest, onConfirm, respond };
205
+ }
206
+
113
207
  // src/BaseSettingsDialog.tsx
114
- import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
208
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
115
209
  function SettingIcon({ source, color, containerColor }) {
116
- return /* @__PURE__ */ jsx2(View2, { style: [styles2.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ jsx2(Icon2, { source, size: 18, color }) });
210
+ return /* @__PURE__ */ jsx3(View3, { style: [styles3.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ jsx3(Icon3, { source, size: 18, color }) });
117
211
  }
118
212
  function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }) {
119
- const { dark, colors } = useAutoPaperTheme2();
213
+ const { colors } = useAutoPaperTheme3();
120
214
  const ambientRotation = useRotation();
121
215
  const rotation = rotationOverride ?? ambientRotation;
122
216
  const isTouchPrimary = useIsTouchPrimaryDevice();
@@ -124,41 +218,43 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
124
218
  const { settings: soundSettings, set: setSoundSettings } = useSoundSettings();
125
219
  const sound = useContext(SoundContext);
126
220
  const { forceShort: forceHapticFeedback } = useVibration();
127
- const [infoMessage, setInfoMessage] = useState(null);
221
+ const [infoMessage, setInfoMessage] = useState2(null);
222
+ const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge();
128
223
  const { check, checking, updateReady } = useUpdater({
129
224
  autoCheck: false,
130
225
  autoPrompt: false,
226
+ onConfirm: onUpdateConfirm,
131
227
  onError: onUpdateError,
132
228
  onInfo: (title, message) => setInfoMessage({ title, message })
133
229
  });
230
+ const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null;
134
231
  const showLockOrientation = isTouchPrimary && lockOrientation !== void 0 && onLockOrientationChange !== void 0;
135
232
  const showEdgeGuard = Platform.OS === "ios" && deferBottomEdgeGestures !== void 0 && onDeferBottomEdgeGestures !== void 0;
136
233
  const showHaptics = Platform.OS !== "web" && !hideHaptics;
137
234
  const showSound = !hideSound;
138
235
  const showAppearance = !hideAppearance;
139
236
  const showUpdateCheck = Platform.OS !== "web" && !hideUpdateCheck;
140
- const { fg, cardBg, cardBorder } = getOverlayCardColors(dark);
141
- return /* @__PURE__ */ jsxs2(Fragment, { children: [
142
- /* @__PURE__ */ jsxs2(Dialog, { visible, onDismiss, style: [styles2.dialog, toRotationStyle(rotation)], children: [
143
- /* @__PURE__ */ jsx2(Dialog.Title, { children: "Settings" }),
144
- /* @__PURE__ */ jsx2(Dialog.ScrollArea, { style: styles2.scrollArea, children: /* @__PURE__ */ jsxs2(ScrollView, { contentContainerStyle: styles2.content, showsVerticalScrollIndicator: false, children: [
145
- /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleGroup, children: [
146
- showLockOrientation && /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => onLockOrientationChange(!lockOrientation), style: styles2.toggleButton, accessibilityLabel: `Lock orientation ${lockOrientation ? "on" : "off"}`, children: /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleContent, children: [
147
- /* @__PURE__ */ jsx2(SettingIcon, { source: lockOrientation ? "lock" : "lock-open-variant-outline", color: lockOrientation ? colors.secondary : colors.onSurfaceVariant, containerColor: lockOrientation ? colors.secondaryContainer : colors.surfaceVariant }),
148
- /* @__PURE__ */ jsxs2(View2, { style: styles2.flexShrink, children: [
149
- /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Lock Orientation" }),
150
- /* @__PURE__ */ jsx2(Text2, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Pins the current layout" })
237
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
238
+ /* @__PURE__ */ jsxs3(Dialog, { visible, onDismiss, style: [styles3.dialog, toRotationStyle2(rotation)], children: [
239
+ /* @__PURE__ */ jsx3(Dialog.Title, { children: "Settings" }),
240
+ /* @__PURE__ */ jsx3(Dialog.ScrollArea, { style: styles3.scrollArea, children: /* @__PURE__ */ jsxs3(ScrollView, { contentContainerStyle: styles3.content, showsVerticalScrollIndicator: false, children: [
241
+ /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleGroup, children: [
242
+ showLockOrientation && /* @__PURE__ */ jsx3(TouchableRipple, { onPress: () => onLockOrientationChange(!lockOrientation), style: styles3.toggleButton, accessibilityLabel: `Lock orientation ${lockOrientation ? "on" : "off"}`, children: /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleContent, children: [
243
+ /* @__PURE__ */ jsx3(SettingIcon, { source: lockOrientation ? "lock" : "lock-open-variant-outline", color: lockOrientation ? colors.secondary : colors.onSurfaceVariant, containerColor: lockOrientation ? colors.secondaryContainer : colors.surfaceVariant }),
244
+ /* @__PURE__ */ jsxs3(View3, { style: styles3.flexShrink, children: [
245
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Lock Orientation" }),
246
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Pins the current layout" })
151
247
  ] })
152
248
  ] }) }),
153
- showEdgeGuard && /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => onDeferBottomEdgeGestures(!deferBottomEdgeGestures), style: styles2.toggleButton, accessibilityLabel: `Edge guard ${deferBottomEdgeGestures ? "on" : "off"}`, children: /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleContent, children: [
154
- /* @__PURE__ */ jsx2(SettingIcon, { source: deferBottomEdgeGestures ? "shield-check-outline" : "shield-off-outline", color: deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant, containerColor: deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant }),
155
- /* @__PURE__ */ jsxs2(View2, { style: styles2.flexShrink, children: [
156
- /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Edge Guard" }),
157
- /* @__PURE__ */ jsx2(Text2, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Requires a second swipe" })
249
+ showEdgeGuard && /* @__PURE__ */ jsx3(TouchableRipple, { onPress: () => onDeferBottomEdgeGestures(!deferBottomEdgeGestures), style: styles3.toggleButton, accessibilityLabel: `Edge guard ${deferBottomEdgeGestures ? "on" : "off"}`, children: /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleContent, children: [
250
+ /* @__PURE__ */ jsx3(SettingIcon, { source: deferBottomEdgeGestures ? "shield-check-outline" : "shield-off-outline", color: deferBottomEdgeGestures ? colors.secondary : colors.onSurfaceVariant, containerColor: deferBottomEdgeGestures ? colors.secondaryContainer : colors.surfaceVariant }),
251
+ /* @__PURE__ */ jsxs3(View3, { style: styles3.flexShrink, children: [
252
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Edge Guard" }),
253
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodySmall", numberOfLines: 1, style: { color: colors.onSurfaceVariant }, children: "Requires a second swipe" })
158
254
  ] })
159
255
  ] }) }),
160
- (showSound || showHaptics) && /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleRow, children: [
161
- showSound && /* @__PURE__ */ jsx2(
256
+ (showSound || showHaptics) && /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleRow, children: [
257
+ showSound && /* @__PURE__ */ jsx3(
162
258
  TouchableRipple,
163
259
  {
164
260
  soundDisabled: true,
@@ -167,15 +263,15 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
167
263
  setSoundSettings({ enabled });
168
264
  if (enabled) sound.selection?.();
169
265
  },
170
- style: styles2.toggleButtonInRow,
266
+ style: styles3.toggleButtonInRow,
171
267
  accessibilityLabel: `Sound ${soundSettings.enabled ? "on" : "off"}`,
172
- children: /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleContent, children: [
173
- /* @__PURE__ */ jsx2(SettingIcon, { source: soundSettings.enabled ? "volume-high" : "volume-off", color: soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant, containerColor: soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant }),
174
- /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Sound" })
268
+ children: /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleContent, children: [
269
+ /* @__PURE__ */ jsx3(SettingIcon, { source: soundSettings.enabled ? "volume-high" : "volume-off", color: soundSettings.enabled ? colors.tertiary : colors.onSurfaceVariant, containerColor: soundSettings.enabled ? colors.tertiaryContainer : colors.surfaceVariant }),
270
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Sound" })
175
271
  ] })
176
272
  }
177
273
  ),
178
- showHaptics && /* @__PURE__ */ jsx2(
274
+ showHaptics && /* @__PURE__ */ jsx3(
179
275
  TouchableRipple,
180
276
  {
181
277
  hapticDisabled: true,
@@ -184,40 +280,36 @@ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, ve
184
280
  setHapticSettings({ vibrate });
185
281
  if (vibrate) forceHapticFeedback();
186
282
  },
187
- style: styles2.toggleButtonInRow,
283
+ style: styles3.toggleButtonInRow,
188
284
  accessibilityLabel: `Haptics ${hapticSettings.vibrate ? "on" : "off"}`,
189
- children: /* @__PURE__ */ jsxs2(View2, { style: styles2.toggleContent, children: [
190
- /* @__PURE__ */ jsx2(SettingIcon, { source: hapticSettings.vibrate ? "vibrate" : "vibrate-off", color: hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant, containerColor: hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant }),
191
- /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Haptics" })
285
+ children: /* @__PURE__ */ jsxs3(View3, { style: styles3.toggleContent, children: [
286
+ /* @__PURE__ */ jsx3(SettingIcon, { source: hapticSettings.vibrate ? "vibrate" : "vibrate-off", color: hapticSettings.vibrate ? colors.tertiary : colors.onSurfaceVariant, containerColor: hapticSettings.vibrate ? colors.tertiaryContainer : colors.surfaceVariant }),
287
+ /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: colors.onSurface }, children: "Haptics" })
192
288
  ] })
193
289
  }
194
290
  )
195
291
  ] })
196
292
  ] }),
197
- showAppearance && /* @__PURE__ */ jsxs2(View2, { style: styles2.section, children: [
198
- /* @__PURE__ */ jsx2(Text2, { variant: "labelMedium", style: [styles2.sectionLabel, { color: colors.onSurfaceVariant }], children: "APPEARANCE" }),
199
- /* @__PURE__ */ jsx2(AutoAppearancePicker, { showLabels: false })
293
+ showAppearance && /* @__PURE__ */ jsxs3(View3, { style: styles3.section, children: [
294
+ /* @__PURE__ */ jsx3(Text3, { variant: "labelMedium", style: [styles3.sectionLabel, { color: colors.onSurfaceVariant }], children: "APPEARANCE" }),
295
+ /* @__PURE__ */ jsx3(AutoAppearancePicker, { showLabels: false })
200
296
  ] }),
201
297
  children,
202
- showUpdateCheck && /* @__PURE__ */ jsxs2(View2, { style: styles2.section, children: [
203
- /* @__PURE__ */ jsxs2(Text2, { variant: "labelSmall", style: [styles2.sectionLabel, { color: colors.onSurfaceVariant }], children: [
298
+ showUpdateCheck && /* @__PURE__ */ jsxs3(View3, { style: styles3.section, children: [
299
+ /* @__PURE__ */ jsxs3(Text3, { variant: "labelSmall", style: [styles3.sectionLabel, { color: colors.onSurfaceVariant }], children: [
204
300
  "VERSION ",
205
301
  version,
206
302
  updateReady ? " \xB7 UPDATE READY" : ""
207
303
  ] }),
208
- /* @__PURE__ */ jsx2(Button, { mode: "outlined", onPress: check, loading: checking, disabled: checking, children: "Check for Updates" })
304
+ /* @__PURE__ */ jsx3(Button2, { mode: "outlined", onPress: check, loading: checking, disabled: checking, children: "Check for Updates" })
209
305
  ] })
210
306
  ] }) })
211
307
  ] }),
212
- infoMessage && /* @__PURE__ */ jsx2(Portal, { children: /* @__PURE__ */ jsx2(View2, { style: overlayStyles.overlay, children: /* @__PURE__ */ jsxs2(View2, { style: [overlayStyles.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle(rotation)], children: [
213
- /* @__PURE__ */ jsx2(Icon2, { source: "information-outline", size: 64, color: colors.secondary }),
214
- /* @__PURE__ */ jsx2(Text2, { variant: "headlineLarge", style: [styles2.overlayTitle, { color: colors.secondary }], children: infoMessage.title }),
215
- /* @__PURE__ */ jsx2(Text2, { variant: "bodyLarge", style: [styles2.overlayBody, { color: fg }], children: infoMessage.message }),
216
- /* @__PURE__ */ jsx2(Button, { mode: "contained", onPress: () => setInfoMessage(null), style: styles2.overlayButton, buttonColor: colors.primary, textColor: colors.onPrimary, children: "OK" })
217
- ] }) }) })
308
+ /* @__PURE__ */ jsx3(ConfirmDialog, { visible: !!updateManifest, title: "Update Available", message: updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : "", confirmLabel: "Restart", cancelLabel: "Later", icon: "update", rotation, onConfirm: () => respondToUpdateConfirm(true), onCancel: () => respondToUpdateConfirm(false) }),
309
+ /* @__PURE__ */ jsx3(ConfirmDialog, { visible: !!infoMessage, title: infoMessage?.title ?? "", message: infoMessage?.message ?? "", confirmLabel: "OK", icon: "information-outline", rotation, onConfirm: () => setInfoMessage(null) })
218
310
  ] });
219
311
  }
220
- var styles2 = StyleSheet3.create({
312
+ var styles3 = StyleSheet4.create({
221
313
  content: {
222
314
  gap: 24,
223
315
  paddingVertical: 20
@@ -239,9 +331,6 @@ var styles2 = StyleSheet3.create({
239
331
  justifyContent: "center",
240
332
  width: 36
241
333
  },
242
- overlayBody: { textAlign: "center" },
243
- overlayButton: { width: 160 },
244
- overlayTitle: { fontWeight: "bold" },
245
334
  scrollArea: {
246
335
  marginBottom: 0
247
336
  },
@@ -291,89 +380,15 @@ var styles2 = StyleSheet3.create({
291
380
  import { useAutoPaperTheme as useAutoPaperTheme4 } from "@rific/auto-paper";
292
381
  import { Button as Button3, IconButton } from "@rific/feedback-press";
293
382
  import { useRotation as useRotation2 } from "@tastic/core";
294
- import { useState as useState2 } from "react";
383
+ import { useState as useState3 } from "react";
295
384
  import { ScrollView as ScrollView2, StyleSheet as StyleSheet5, View as View4 } from "react-native";
296
385
  import { Text as Text4 } from "react-native-paper";
297
-
298
- // src/ConfirmDialog.tsx
299
- import { useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
300
- import { Button as Button2 } from "@rific/feedback-press";
301
- import { toRotationStyle as toRotationStyle2 } from "@tastic/core";
302
- import { StyleSheet as StyleSheet4, View as View3 } from "react-native";
303
- import { Icon as Icon3, Portal as Portal2, Text as Text3 } from "react-native-paper";
304
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
305
- function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = "help-circle-outline", destructive = false }) {
306
- const { dark, colors } = useAutoPaperTheme3();
307
- if (!visible) return null;
308
- const { fg, cardBg, cardBorder } = getOverlayCardColors(dark);
309
- const accent = destructive ? colors.danger : colors.primary;
310
- const onAccent = destructive ? colors.onDanger : colors.onPrimary;
311
- return /* @__PURE__ */ jsx3(Portal2, { children: /* @__PURE__ */ jsx3(View3, { style: [overlayStyles.overlay, styles3.overlay], children: /* @__PURE__ */ jsxs3(View3, { style: [overlayStyles.card, styles3.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle2(rotation)], children: [
312
- /* @__PURE__ */ jsx3(Icon3, { source: icon, size: 64, color: accent }),
313
- /* @__PURE__ */ jsx3(Text3, { variant: "headlineLarge", style: [styles3.title, { color: accent }], children: title }),
314
- /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: [styles3.body, { color: fg }], children: message }),
315
- /* @__PURE__ */ jsxs3(View3, { style: styles3.actions, children: [
316
- /* @__PURE__ */ jsx3(Button2, { mode: "outlined", onPress: onCancel, style: styles3.actionButton, children: cancelLabel }),
317
- /* @__PURE__ */ jsx3(Button2, { mode: "contained", onPress: onConfirm, style: styles3.actionButton, buttonColor: accent, textColor: onAccent, children: confirmLabel })
318
- ] })
319
- ] }) }) });
320
- }
321
- var styles3 = StyleSheet4.create({
322
- // flexBasis (not flex: 1's own 0%) is each button's real floor: whenever both fit side by side,
323
- // flexGrow still splits any leftover width evenly beyond that floor, same visual result as the
324
- // old flex: 1 on a roomy screen - but a button can never be squeezed narrower than 128, only
325
- // wrap to its own row instead (see actions' flexWrap below). react-native-paper's Button
326
- // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
327
- // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
328
- // fixable by just giving the row more width, until confirmed live that these two-word labels
329
- // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
330
- // width fix below provides, on a narrow enough screen. flexBasis is the real fix: past a certain
331
- // width there's no side-by-side arrangement that fits both buttons at their natural label size,
332
- // so the layout has to give (stack) rather than the label (truncate).
333
- actionButton: {
334
- flexBasis: 128,
335
- flexGrow: 1
336
- },
337
- actions: {
338
- flexDirection: "row",
339
- flexWrap: "wrap",
340
- gap: 12,
341
- width: "100%"
342
- },
343
- body: {
344
- textAlign: "center"
345
- },
346
- // Additive on top of overlayCard's own shared `card` shape (borderRadius/borderWidth/gap/maxWidth/
347
- // padding) — claims its full allowance instead of just maxWidth alone, which only caps how wide the
348
- // card is allowed to get - without an explicit width, a flex-centered child sizes to its own content
349
- // instead of stretching, so the card (and therefore actions below) can end up narrower than the
350
- // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
351
- card: {
352
- width: "100%"
353
- },
354
- // Additive on top of overlayCard's own shared `overlay` backdrop — gives the card room to breathe
355
- // from the screen edges now that it reliably stretches to fill its full width allowance (see
356
- // card's own width comment) - without this, width: '100%' would run the card edge-to-edge on a
357
- // screen narrower than maxWidth's own 360.
358
- overlay: {
359
- paddingHorizontal: 24
360
- },
361
- // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
362
- // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
363
- // line inside that block still defaults to left-aligned without this.
364
- title: {
365
- fontWeight: "bold",
366
- textAlign: "center"
367
- }
368
- });
369
-
370
- // src/BaseStatsScreen.tsx
371
386
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
372
387
  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 }) {
373
388
  const { dark, colors } = useAutoPaperTheme4();
374
389
  const ambientRotation = useRotation2();
375
390
  const rotation = rotationOverride ?? ambientRotation;
376
- const [confirmResetVisible, setConfirmResetVisible] = useState2(false);
391
+ const [confirmResetVisible, setConfirmResetVisible] = useState3(false);
377
392
  const showReset = !!onReset;
378
393
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
379
394
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
@@ -549,7 +564,7 @@ function TriggerGaugeHost(props) {
549
564
  }
550
565
 
551
566
  // src/useAutoAlign.ts
552
- import { useCallback, useEffect, useRef, useState as useState3 } from "react";
567
+ import { useCallback as useCallback2, useEffect, useRef as useRef2, useState as useState4 } from "react";
553
568
  import { useWindowDimensions as useWindowDimensions2 } from "react-native";
554
569
  var EDGE_MARGIN = 12;
555
570
  function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
@@ -562,18 +577,18 @@ function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
562
577
  return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight };
563
578
  }
564
579
  function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
565
- const triggerRef = useRef(null);
580
+ const triggerRef = useRef2(null);
566
581
  const { width: windowWidth, height: windowHeight } = useWindowDimensions2();
567
- const [align, setAlign] = useState3("center");
568
- const [verticalAlign, setVerticalAlign] = useState3("below");
569
- const [maxHeight, setMaxHeight] = useState3(contentHeight);
570
- const [measured, setMeasured] = useState3(false);
571
- const [prevOpen, setPrevOpen] = useState3(open);
582
+ const [align, setAlign] = useState4("center");
583
+ const [verticalAlign, setVerticalAlign] = useState4("below");
584
+ const [maxHeight, setMaxHeight] = useState4(contentHeight);
585
+ const [measured, setMeasured] = useState4(false);
586
+ const [prevOpen, setPrevOpen] = useState4(open);
572
587
  if (open !== prevOpen) {
573
588
  setPrevOpen(open);
574
589
  if (open) setMeasured(false);
575
590
  }
576
- const measure = useCallback(() => {
591
+ const measure = useCallback2(() => {
577
592
  triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
578
593
  const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight);
579
594
  const centerX = x + triggerWidth / 2;
@@ -1198,14 +1213,24 @@ var styles15 = StyleSheet17.create({
1198
1213
  }
1199
1214
  });
1200
1215
 
1216
+ // src/UpdateDialog.tsx
1217
+ import { useUpdater as useUpdater2 } from "@rific/updater";
1218
+ import { jsx as jsx19 } from "react/jsx-runtime";
1219
+ function UpdateDialog({ rotation = 0, autoCheck = true, autoPrompt = true, onError }) {
1220
+ const { manifest, onConfirm, respond } = useUpdateConfirmBridge();
1221
+ useUpdater2({ autoCheck, autoPrompt, onConfirm, onError });
1222
+ const date = manifest ? new Date(manifest.createdAt) : null;
1223
+ return /* @__PURE__ */ jsx19(ConfirmDialog, { visible: !!manifest, title: "Update Available", message: date ? `Released ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}` : "", confirmLabel: "Restart", cancelLabel: "Later", icon: "update", rotation, onConfirm: () => respond(true), onCancel: () => respond(false) });
1224
+ }
1225
+
1201
1226
  // src/usePopoverHost.ts
1202
- import { useCallback as useCallback2, useState as useState4 } from "react";
1227
+ import { useCallback as useCallback3, useState as useState5 } from "react";
1203
1228
  function usePopoverHost() {
1204
- const [openId, setOpenId] = useState4(null);
1205
- const toggle = useCallback2((id) => {
1229
+ const [openId, setOpenId] = useState5(null);
1230
+ const toggle = useCallback3((id) => {
1206
1231
  setOpenId((prev) => prev === id ? null : id);
1207
1232
  }, []);
1208
- const close = useCallback2(() => setOpenId(null), []);
1233
+ const close = useCallback3(() => setOpenId(null), []);
1209
1234
  return { openId, toggle, close };
1210
1235
  }
1211
1236
  export {
@@ -1231,6 +1256,7 @@ export {
1231
1256
  StatRow,
1232
1257
  StatSection,
1233
1258
  TriggerGaugeHost,
1259
+ UpdateDialog,
1234
1260
  getLabeledDropdownContentHeight,
1235
1261
  getOverlayCardColors,
1236
1262
  loadSkiaWeb,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
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",
@@ -73,8 +73,9 @@
73
73
  "@infinitetoken/eslint-config": "^0.2.0",
74
74
  "@infinitetoken/jest-config": "^0.3.0",
75
75
  "@infinitetoken/tsconfig": "^0.4.1",
76
- "@rific/auto-paper": "^0.10.3",
77
- "@rific/feedback-press": "^0.10.8",
76
+ "@rific/auto-paper": "^0.11.0",
77
+ "@rific/core": "^0.1.1",
78
+ "@rific/feedback-press": "^0.11.1",
78
79
  "@rific/updater": "^0.6.0",
79
80
  "@shopify/react-native-skia": "^2.6.2",
80
81
  "@tastic/core": "^0.4.1",
@@ -4,9 +4,10 @@ import { useUpdater } from '@rific/updater'
4
4
  import { toRotationStyle, useIsTouchPrimaryDevice, useRotation, type ViewRotation } from '@tastic/core'
5
5
  import { ReactNode, useContext, useState } from 'react'
6
6
  import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
- import { Icon, Portal, Text } from 'react-native-paper'
7
+ import { Icon, Text } from 'react-native-paper'
8
8
 
9
- import { getOverlayCardColors, overlayStyles } from './overlayCard'
9
+ import { ConfirmDialog } from './ConfirmDialog'
10
+ import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
10
11
 
11
12
  interface SettingIconProps {
12
13
  source: string
@@ -80,7 +81,7 @@ export interface BaseSettingsDialogProps {
80
81
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
81
82
  // forking this component.
82
83
  export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
83
- const { dark, colors } = useAutoPaperTheme()
84
+ const { colors } = useAutoPaperTheme()
84
85
  const ambientRotation = useRotation()
85
86
  const rotation = rotationOverride ?? ambientRotation
86
87
  // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
@@ -105,13 +106,18 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
105
106
  // autoCheck: false — every consuming app's own root layout already runs the background check via
106
107
  // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
107
108
  // second AppState listener and double every foreground-resume update check. This instance only
108
- // ever checks on an explicit tap of the button below.
109
+ // ever checks on an explicit tap of the button below. Deliberately its own useUpdater() instance
110
+ // rather than rendering UpdateDialog directly here, for the same reason — UpdateDialog owns a
111
+ // second AppState listener of its own.
112
+ const { manifest: updateManifest, onConfirm: onUpdateConfirm, respond: respondToUpdateConfirm } = useUpdateConfirmBridge()
109
113
  const { check, checking, updateReady } = useUpdater({
110
114
  autoCheck: false,
111
115
  autoPrompt: false,
116
+ onConfirm: onUpdateConfirm,
112
117
  onError: onUpdateError,
113
118
  onInfo: (title, message) => setInfoMessage({ title, message })
114
119
  })
120
+ const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null
115
121
 
116
122
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
117
123
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
@@ -120,8 +126,6 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
120
126
  const showAppearance = !hideAppearance
121
127
  const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
122
128
 
123
- const { fg, cardBg, cardBorder } = getOverlayCardColors(dark)
124
-
125
129
  return (
126
130
  <>
127
131
  <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, toRotationStyle(rotation)]}>
@@ -261,24 +265,8 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
261
265
  </ScrollView>
262
266
  </Dialog.ScrollArea>
263
267
  </Dialog>
264
- {infoMessage && (
265
- <Portal>
266
- <View style={overlayStyles.overlay}>
267
- <View style={[overlayStyles.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle(rotation)]}>
268
- <Icon source='information-outline' size={64} color={colors.secondary} />
269
- <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
270
- {infoMessage.title}
271
- </Text>
272
- <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
273
- {infoMessage.message}
274
- </Text>
275
- <Button mode='contained' onPress={() => setInfoMessage(null)} style={styles.overlayButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
276
- OK
277
- </Button>
278
- </View>
279
- </View>
280
- </Portal>
281
- )}
268
+ <ConfirmDialog visible={!!updateManifest} title='Update Available' message={updateManifestDate ? `Released ${updateManifestDate.toLocaleDateString()} at ${updateManifestDate.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={rotation} onConfirm={() => respondToUpdateConfirm(true)} onCancel={() => respondToUpdateConfirm(false)} />
269
+ <ConfirmDialog visible={!!infoMessage} title={infoMessage?.title ?? ''} message={infoMessage?.message ?? ''} confirmLabel='OK' icon='information-outline' rotation={rotation} onConfirm={() => setInfoMessage(null)} />
282
270
  </>
283
271
  )
284
272
  }
@@ -305,9 +293,6 @@ const styles = StyleSheet.create({
305
293
  justifyContent: 'center',
306
294
  width: 36
307
295
  },
308
- overlayBody: { textAlign: 'center' },
309
- overlayButton: { width: 160 },
310
- overlayTitle: { fontWeight: 'bold' },
311
296
  scrollArea: {
312
297
  marginBottom: 0
313
298
  },
@@ -1,6 +1,7 @@
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'
4
5
  import { StyleSheet, View } from 'react-native'
5
6
  import { Icon, Portal, Text } from 'react-native-paper'
6
7
 
@@ -9,11 +10,11 @@ import { getOverlayCardColors, overlayStyles } from './overlayCard'
9
10
  export interface ConfirmDialogProps {
10
11
  visible: boolean
11
12
  title: string
12
- message: string
13
+ message: ReactNode
13
14
  confirmLabel: string
14
- cancelLabel: string
15
+ cancelLabel?: string
15
16
  onConfirm: () => void
16
- onCancel: () => void
17
+ onCancel?: () => void
17
18
  // Live physical-hold rotation (see @tastic/core's getViewRotation) - same convention as
18
19
  // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
19
20
  // match (a portrait-only or single-player game has nothing to stay consistent with).
@@ -54,9 +55,11 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
54
55
  {message}
55
56
  </Text>
56
57
  <View style={styles.actions}>
57
- <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
58
- {cancelLabel}
59
- </Button>
58
+ {cancelLabel !== undefined && onCancel !== undefined && (
59
+ <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
60
+ {cancelLabel}
61
+ </Button>
62
+ )}
60
63
  <Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
61
64
  {confirmLabel}
62
65
  </Button>
@@ -0,0 +1,28 @@
1
+ import { useUpdater } from '@rific/updater'
2
+ import { type ViewRotation } from '@tastic/core'
3
+
4
+ import { ConfirmDialog } from './ConfirmDialog'
5
+ import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
6
+
7
+ export interface UpdateDialogProps {
8
+ // Same live physical-hold rotation convention as ConfirmDialog/BaseSettingsDialog's own rotation
9
+ // prop - defaults to 0 for a caller with no per-player zone to match.
10
+ rotation?: ViewRotation
11
+ autoCheck?: boolean
12
+ autoPrompt?: boolean
13
+ onError?: (message: string) => void
14
+ }
15
+
16
+ // Drop-in background updater: mount once (e.g. an app's root layout) and it owns its own
17
+ // useUpdater() instance end to end, rendering a themed ConfirmDialog instead of useUpdater's
18
+ // default Alert-based prompt whenever an update is found. BaseSettingsDialog's own manual "Check
19
+ // for Updates" button deliberately does NOT render this component - see its own comment on why
20
+ // that flow keeps a second, separate useUpdater() instance instead of reusing this one.
21
+ export function UpdateDialog({ rotation = 0, autoCheck = true, autoPrompt = true, onError }: UpdateDialogProps) {
22
+ const { manifest, onConfirm, respond } = useUpdateConfirmBridge()
23
+ useUpdater({ autoCheck, autoPrompt, onConfirm, onError })
24
+
25
+ const date = manifest ? new Date(manifest.createdAt) : null
26
+
27
+ return <ConfirmDialog visible={!!manifest} title='Update Available' message={date ? `Released ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}` : ''} confirmLabel='Restart' cancelLabel='Later' icon='update' rotation={rotation} onConfirm={() => respond(true)} onCancel={() => respond(false)} />
28
+ }
package/src/index.ts CHANGED
@@ -28,5 +28,6 @@ export { StatSection } from './StatSection'
28
28
  // TriggerGaugeHost is the only supported way to render this.
29
29
  export type { TriggerGaugeProps } from './TriggerGauge'
30
30
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
31
+ export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
31
32
  export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
32
33
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'