@tastic/hud 0.8.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.8.2",
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,11 +73,12 @@
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
- "@tastic/core": "^0.3.0",
81
+ "@tastic/core": "^0.4.1",
81
82
  "@testing-library/dom": "^10.4.1",
82
83
  "@testing-library/react": "^16.3.2",
83
84
  "@types/jest": "^30.0.0",
@@ -1,10 +1,13 @@
1
1
  import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
3
  import { useUpdater } from '@rific/updater'
4
- import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
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
+
9
+ import { ConfirmDialog } from './ConfirmDialog'
10
+ import { useUpdateConfirmBridge } from './useUpdateConfirmBridge'
8
11
 
9
12
  interface SettingIconProps {
10
13
  source: string
@@ -31,7 +34,7 @@ export interface BaseSettingsDialogProps {
31
34
  // dialog), so it just rotates its own content in place. Defaults to a live ambient read via
32
35
  // useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
33
36
  // ambient one (rare — most callers should simply omit it).
34
- rotation?: number
37
+ rotation?: ViewRotation
35
38
  // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
36
39
  // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
37
40
  // since that's what release.otaVersion actually is in most apps (a bare incrementing counter, not
@@ -78,7 +81,7 @@ export interface BaseSettingsDialogProps {
78
81
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
79
82
  // forking this component.
80
83
  export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
81
- const { dark, colors } = useAutoPaperTheme()
84
+ const { colors } = useAutoPaperTheme()
82
85
  const ambientRotation = useRotation()
83
86
  const rotation = rotationOverride ?? ambientRotation
84
87
  // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
@@ -103,13 +106,18 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
103
106
  // autoCheck: false — every consuming app's own root layout already runs the background check via
104
107
  // its own useUpdater() instance; a second instance with autoCheck's default (true) would set up a
105
108
  // second AppState listener and double every foreground-resume update check. This instance only
106
- // 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()
107
113
  const { check, checking, updateReady } = useUpdater({
108
114
  autoCheck: false,
109
115
  autoPrompt: false,
116
+ onConfirm: onUpdateConfirm,
110
117
  onError: onUpdateError,
111
118
  onInfo: (title, message) => setInfoMessage({ title, message })
112
119
  })
120
+ const updateManifestDate = updateManifest ? new Date(updateManifest.createdAt) : null
113
121
 
114
122
  const showLockOrientation = isTouchPrimary && lockOrientation !== undefined && onLockOrientationChange !== undefined
115
123
  const showEdgeGuard = Platform.OS === 'ios' && deferBottomEdgeGestures !== undefined && onDeferBottomEdgeGestures !== undefined
@@ -118,15 +126,9 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
118
126
  const showAppearance = !hideAppearance
119
127
  const showUpdateCheck = Platform.OS !== 'web' && !hideUpdateCheck
120
128
 
121
- // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
122
- // tinted) background role.
123
- const fg = dark ? '#FFFFFF' : '#000000'
124
- const cardBg = dark ? '#111111' : '#F2F2F2'
125
- const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
126
-
127
129
  return (
128
130
  <>
129
- <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
131
+ <Dialog visible={visible} onDismiss={onDismiss} style={[styles.dialog, toRotationStyle(rotation)]}>
130
132
  <Dialog.Title>Settings</Dialog.Title>
131
133
  {/* react-native-paper's Dialog.ScrollArea has a fixed 24px marginBottom baked in (meant to
132
134
  reserve room for a Dialog.Actions row below it) — overridden to 0 since this dialog has
@@ -263,24 +265,8 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
263
265
  </ScrollView>
264
266
  </Dialog.ScrollArea>
265
267
  </Dialog>
266
- {infoMessage && (
267
- <Portal>
268
- <View style={styles.overlay}>
269
- <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
270
- <Icon source='information-outline' size={64} color={colors.secondary} />
271
- <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
272
- {infoMessage.title}
273
- </Text>
274
- <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
275
- {infoMessage.message}
276
- </Text>
277
- <Button mode='contained' onPress={() => setInfoMessage(null)} style={styles.overlayButton} buttonColor={colors.primary} textColor={colors.onPrimary}>
278
- OK
279
- </Button>
280
- </View>
281
- </View>
282
- </Portal>
283
- )}
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)} />
284
270
  </>
285
271
  )
286
272
  }
@@ -307,27 +293,6 @@ const styles = StyleSheet.create({
307
293
  justifyContent: 'center',
308
294
  width: 36
309
295
  },
310
- overlay: {
311
- alignItems: 'center',
312
- backgroundColor: 'rgba(0,0,0,0.72)',
313
- bottom: 0,
314
- justifyContent: 'center',
315
- left: 0,
316
- position: 'absolute',
317
- right: 0,
318
- top: 0
319
- },
320
- overlayBody: { textAlign: 'center' },
321
- overlayButton: { width: 160 },
322
- overlayCard: {
323
- alignItems: 'center',
324
- borderRadius: 20,
325
- borderWidth: 1,
326
- gap: 16,
327
- maxWidth: 360,
328
- padding: 32
329
- },
330
- overlayTitle: { fontWeight: 'bold' },
331
296
  scrollArea: {
332
297
  marginBottom: 0
333
298
  },
@@ -1,6 +1,6 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, IconButton } from '@rific/feedback-press'
3
- import { useRotation } from '@tastic/core'
3
+ import { useRotation, type ViewRotation } from '@tastic/core'
4
4
  import { ReactNode, useState } from 'react'
5
5
  import { ScrollView, StyleSheet, View } from 'react-native'
6
6
  import { Text } from 'react-native-paper'
@@ -20,7 +20,7 @@ interface Props {
20
20
  // read via useRotation() when omitted — note this does NOT make the header/scrollable content
21
21
  // below rotate; the caller's own screen still needs its own FakeLandscapeView wrap for that
22
22
  // (this component has no transform on its own main body).
23
- rotation?: number
23
+ rotation?: ViewRotation
24
24
  title?: string
25
25
  // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
26
26
  // display tier's own smallest step), then 'headlineLarge', both still read as oversized for a
@@ -1,20 +1,24 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button } from '@rific/feedback-press'
3
+ import { toRotationStyle, type ViewRotation } from '@tastic/core'
4
+ import { type ReactNode } from 'react'
3
5
  import { StyleSheet, View } from 'react-native'
4
6
  import { Icon, Portal, Text } from 'react-native-paper'
5
7
 
8
+ import { getOverlayCardColors, overlayStyles } from './overlayCard'
9
+
6
10
  export interface ConfirmDialogProps {
7
11
  visible: boolean
8
12
  title: string
9
- message: string
13
+ message: ReactNode
10
14
  confirmLabel: string
11
- cancelLabel: string
15
+ cancelLabel?: string
12
16
  onConfirm: () => void
13
- onCancel: () => void
14
- // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) - same convention as
17
+ onCancel?: () => void
18
+ // Live physical-hold rotation (see @tastic/core's getViewRotation) - same convention as
15
19
  // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
16
20
  // match (a portrait-only or single-player game has nothing to stay consistent with).
17
- rotation?: number
21
+ rotation?: ViewRotation
18
22
  // Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
19
23
  // question mark, since this component has no way to know why a caller is asking.
20
24
  icon?: string
@@ -35,18 +39,14 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
35
39
  const { dark, colors } = useAutoPaperTheme()
36
40
  if (!visible) return null
37
41
 
38
- // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
39
- // tinted) background role - same convention BaseSettingsDialog's own overlay card uses.
40
- const fg = dark ? '#FFFFFF' : '#000000'
41
- const cardBg = dark ? '#111111' : '#F2F2F2'
42
- const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
42
+ const { fg, cardBg, cardBorder } = getOverlayCardColors(dark)
43
43
  const accent = destructive ? colors.danger : colors.primary
44
44
  const onAccent = destructive ? colors.onDanger : colors.onPrimary
45
45
 
46
46
  return (
47
47
  <Portal>
48
- <View style={styles.overlay}>
49
- <View style={[styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
48
+ <View style={[overlayStyles.overlay, styles.overlay]}>
49
+ <View style={[overlayStyles.card, styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle(rotation)]}>
50
50
  <Icon source={icon} size={64} color={accent} />
51
51
  <Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
52
52
  {title}
@@ -55,9 +55,11 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
55
55
  {message}
56
56
  </Text>
57
57
  <View style={styles.actions}>
58
- <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
59
- {cancelLabel}
60
- </Button>
58
+ {cancelLabel !== undefined && onCancel !== undefined && (
59
+ <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
60
+ {cancelLabel}
61
+ </Button>
62
+ )}
61
63
  <Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
62
64
  {confirmLabel}
63
65
  </Button>
@@ -93,32 +95,20 @@ const styles = StyleSheet.create({
93
95
  body: {
94
96
  textAlign: 'center'
95
97
  },
98
+ // Additive on top of overlayCard's own shared `card` shape (borderRadius/borderWidth/gap/maxWidth/
99
+ // padding) — claims its full allowance instead of just maxWidth alone, which only caps how wide the
100
+ // card is allowed to get - without an explicit width, a flex-centered child sizes to its own content
101
+ // instead of stretching, so the card (and therefore actions below) can end up narrower than the
102
+ // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
96
103
  card: {
97
- alignItems: 'center',
98
- borderRadius: 20,
99
- borderWidth: 1,
100
- gap: 16,
101
- maxWidth: 360,
102
- padding: 32,
103
- // Claims its full allowance instead of just maxWidth alone, which only caps how wide the card
104
- // is allowed to get - without an explicit width, a flex-centered child sizes to its own content
105
- // instead of stretching, so the card (and therefore actions below) can end up narrower than the
106
- // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
107
104
  width: '100%'
108
105
  },
106
+ // Additive on top of overlayCard's own shared `overlay` backdrop — gives the card room to breathe
107
+ // from the screen edges now that it reliably stretches to fill its full width allowance (see
108
+ // card's own width comment) - without this, width: '100%' would run the card edge-to-edge on a
109
+ // screen narrower than maxWidth's own 360.
109
110
  overlay: {
110
- alignItems: 'center',
111
- backgroundColor: 'rgba(0,0,0,0.72)',
112
- bottom: 0,
113
- justifyContent: 'center',
114
- left: 0,
115
- // Gives the card room to breathe from the screen edges now that it reliably stretches to fill
116
- // its full width allowance (see card's own width comment) - without this, width: '100%' would
117
- // run the card edge-to-edge on a screen narrower than maxWidth's own 360.
118
- paddingHorizontal: 24,
119
- position: 'absolute',
120
- right: 0,
121
- top: 0
111
+ paddingHorizontal: 24
122
112
  },
123
113
  // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
124
114
  // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
@@ -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
@@ -10,6 +10,7 @@ export { MONO_FONT } from './fonts'
10
10
  export { InlineColorPicker } from './InlineColorPicker'
11
11
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
12
12
  export { loadSkiaWeb } from './loadSkiaWeb'
13
+ export { getOverlayCardColors, type OverlayCardColors, overlayStyles } from './overlayCard'
13
14
  export { PopoverBody } from './PopoverBody'
14
15
  export { PressAwayOverlay } from './PressAwayOverlay'
15
16
  export { ReadyButton } from './ReadyButton'
@@ -27,5 +28,6 @@ export { StatSection } from './StatSection'
27
28
  // TriggerGaugeHost is the only supported way to render this.
28
29
  export type { TriggerGaugeProps } from './TriggerGauge'
29
30
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
31
+ export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
30
32
  export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
31
33
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
@@ -0,0 +1,48 @@
1
+ import { StyleSheet } from 'react-native'
2
+
3
+ export interface OverlayCardColors {
4
+ fg: string
5
+ fgMuted: string
6
+ cardBg: string
7
+ cardBorder: string
8
+ }
9
+
10
+ // The literal-black/white-by-appearance "retro" formula this package's own BaseSettingsDialog and
11
+ // ConfirmDialog each independently re-derived — deliberately NOT auto-paper's own (seed-color-tinted)
12
+ // background role, see either component's own "High-contrast retro look" comment for why. Every
13
+ // fleet app's own Game-Over/Round-Over/Match-Over/Quit-confirmation overlay re-derives this same
14
+ // three-or-four-line formula again, which is what this export exists to replace.
15
+ export function getOverlayCardColors(dark: boolean): OverlayCardColors {
16
+ return {
17
+ fg: dark ? '#FFFFFF' : '#000000',
18
+ fgMuted: dark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
19
+ cardBg: dark ? '#111111' : '#F2F2F2',
20
+ cardBorder: dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
21
+ }
22
+ }
23
+
24
+ // Only the shell shape every overlay card shares (backdrop + card geometry) — deliberately not the
25
+ // button/title/body styling, which already legitimately varies by consumer (BaseSettingsDialog's
26
+ // single centered OK button vs. ConfirmDialog's two-button flexBasis-wrap row vs. each app's own
27
+ // score/badge/zone content). A consumer applies `{backgroundColor: cardBg, borderColor: cardBorder}`
28
+ // from getOverlayCardColors on top of `card` itself, same as every existing call site already does.
29
+ export const overlayStyles = StyleSheet.create({
30
+ card: {
31
+ alignItems: 'center',
32
+ borderRadius: 20,
33
+ borderWidth: 1,
34
+ gap: 16,
35
+ maxWidth: 360,
36
+ padding: 32
37
+ },
38
+ overlay: {
39
+ alignItems: 'center',
40
+ backgroundColor: 'rgba(0,0,0,0.72)',
41
+ bottom: 0,
42
+ justifyContent: 'center',
43
+ left: 0,
44
+ position: 'absolute',
45
+ right: 0,
46
+ top: 0
47
+ }
48
+ })
@@ -0,0 +1,30 @@
1
+ import type { UpdateManifest } from '@rific/updater'
2
+ import { useCallback, useRef, useState } from 'react'
3
+
4
+ // Bridges @rific/updater's async onConfirm: (manifest) => Promise<boolean> into plain render
5
+ // state a ConfirmDialog can drive: `manifest` is the pending update to show (or null when there's
6
+ // nothing to confirm), and `respond` both resolves the promise onConfirm handed back to useUpdater
7
+ // and clears `manifest` so the dialog closes. Internal - not exported from index.ts. Shared by
8
+ // UpdateDialog and BaseSettingsDialog's own manual-check flow, so each only owns its own
9
+ // useUpdater() instance (see BaseSettingsDialog's comment on why that instance can't be shared).
10
+ export function useUpdateConfirmBridge() {
11
+ const [manifest, setManifest] = useState<UpdateManifest | null>(null)
12
+ const resolveRef = useRef<((confirmed: boolean) => void) | null>(null)
13
+
14
+ const onConfirm = useCallback(
15
+ (next: UpdateManifest) =>
16
+ new Promise<boolean>((resolve) => {
17
+ resolveRef.current = resolve
18
+ setManifest(next)
19
+ }),
20
+ []
21
+ )
22
+
23
+ const respond = useCallback((confirmed: boolean) => {
24
+ setManifest(null)
25
+ resolveRef.current?.(confirmed)
26
+ resolveRef.current = null
27
+ }, [])
28
+
29
+ return { manifest, onConfirm, respond }
30
+ }