@tastic/hud 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -0,0 +1,132 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Icon, ProgressBar, Text } from 'react-native-paper'
4
+
5
+ // A neutral gray for a locked badge's own background — exported so a caller computing its own
6
+ // badge color (unlocked ? tierColor : LOCKED_BADGE_COLOR, matching LightCycles' own achievements.tsx)
7
+ // doesn't need to invent or duplicate this exact tone.
8
+ export const LOCKED_BADGE_COLOR = 'rgba(128,128,128,0.3)'
9
+
10
+ interface Props {
11
+ icon: string
12
+ title: string
13
+ description: string
14
+ // The badge's own background — pass the achievement's tier color when unlocked, or
15
+ // LOCKED_BADGE_COLOR (this package's own export, above) when it isn't; this component doesn't
16
+ // infer that from unlockedLabel itself, since a locked-vs-unlocked badge color scheme is a
17
+ // caller-level design choice (see checkColor below for the identical reasoning on the status
18
+ // icon's own color).
19
+ badgeColor: string
20
+ // undefined = locked. A precomputed display string (e.g. "Unlocked 2 days ago"), not a raw
21
+ // timestamp — this component has no opinion on date formatting or streak conventions, which vary
22
+ // per app (see LightCycles' own calendar-day-difference convention in its achievements.tsx).
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).
28
+ checkColor?: string
29
+ // Only rendered while locked (unlockedLabel is undefined) — 0..1.
30
+ progress?: number
31
+ // The "Same for every profile" note LightCycles shows for a device-scoped achievement viewed
32
+ // from a specific profile's own tab — optional since only an app with per-profile stat views has
33
+ // this concept at all.
34
+ deviceMarker?: boolean
35
+ // Independently optional overrides for this row's own text/background colors — each defaults to
36
+ // the same dark-mode-derived formula as StatRow/StatSection/BaseStatsScreen (see that component's
37
+ // own doc for why) when omitted, so every existing caller renders identically to before. A caller
38
+ // with its own app-wide chrome palette passes whichever of the three it needs.
39
+ fg?: string
40
+ fgMuted?: string
41
+ sectionBg?: string
42
+ }
43
+
44
+ // One row per achievement — a tier-colored (or locked-gray) badge, title/description, an optional
45
+ // progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
46
+ // verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
47
+ // "ALL ACHIEVEMENTS" list. fg/fgMuted/its own section background default to the same
48
+ // useAutoPaperTheme()-derived formula as StatRow/StatSection/BaseStatsScreen (see BaseStatsScreen's
49
+ // own doc for why), each independently overridable via the matching prop for a caller with its own
50
+ // chrome palette. Has no opinion on what an achievement actually IS (id, unlock predicate, catalog)
51
+ // — that stays entirely app-local; this is purely the presentational row.
52
+ export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props) {
53
+ const { dark } = useAutoPaperTheme()
54
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
55
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
56
+ const sectionBg = sectionBgOverride ?? (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
57
+ const unlocked = unlockedLabel !== undefined
58
+
59
+ return (
60
+ <View style={[styles.row, { backgroundColor: sectionBg }]}>
61
+ <View style={[styles.badge, { backgroundColor: badgeColor }]}>
62
+ <Icon source={icon} size={20} color={unlocked ? '#000000' : fgMuted} />
63
+ </View>
64
+ <View style={styles.text}>
65
+ <Text variant='bodyLarge' style={[styles.boldText, { color: fg }]}>
66
+ {title}
67
+ </Text>
68
+ <Text variant='bodySmall' style={{ color: fgMuted }}>
69
+ {description}
70
+ </Text>
71
+ {deviceMarker && (
72
+ <View style={styles.deviceMarker}>
73
+ <Icon source='earth' size={11} color={fgMuted} />
74
+ <Text variant='labelSmall' style={{ color: fgMuted }}>
75
+ Same for every profile
76
+ </Text>
77
+ </View>
78
+ )}
79
+ {!unlocked && progress !== undefined && <ProgressBar progress={progress} color={checkColor ?? badgeColor} style={styles.progressBar} />}
80
+ </View>
81
+ {unlocked ? (
82
+ <View style={styles.status}>
83
+ <Icon source='check-circle' size={20} color={checkColor ?? badgeColor} />
84
+ <Text variant='labelSmall' style={{ color: fgMuted }}>
85
+ {unlockedLabel}
86
+ </Text>
87
+ </View>
88
+ ) : (
89
+ <Icon source='lock-outline' size={20} color={fgMuted} />
90
+ )}
91
+ </View>
92
+ )
93
+ }
94
+
95
+ const styles = StyleSheet.create({
96
+ badge: {
97
+ alignItems: 'center',
98
+ borderRadius: 10,
99
+ height: 36,
100
+ justifyContent: 'center',
101
+ width: 36
102
+ },
103
+ boldText: {
104
+ fontWeight: 'bold'
105
+ },
106
+ deviceMarker: {
107
+ alignItems: 'center',
108
+ flexDirection: 'row',
109
+ gap: 4
110
+ },
111
+ progressBar: {
112
+ borderRadius: 4,
113
+ height: 6,
114
+ marginTop: 6
115
+ },
116
+ row: {
117
+ alignItems: 'center',
118
+ borderRadius: 12,
119
+ flexDirection: 'row',
120
+ gap: 12,
121
+ padding: 12
122
+ },
123
+ status: {
124
+ alignItems: 'center',
125
+ gap: 2,
126
+ width: 64
127
+ },
128
+ text: {
129
+ flex: 1,
130
+ gap: 2
131
+ }
132
+ })
@@ -0,0 +1,192 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button, IconButton } from '@rific/feedback-press'
3
+ import { ReactNode, useState } from 'react'
4
+ import { ScrollView, StyleSheet, View } from 'react-native'
5
+ import { Icon, Portal, Text } from 'react-native-paper'
6
+
7
+ // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
8
+ // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point) just
9
+ // to type one optional prop.
10
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
11
+
12
+ interface Props {
13
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this dialog renders
14
+ // as a centered Portal modal, unaffected by whichever screen's own FakeLandscapeView wraps its
15
+ // trigger, so it has to rotate its own card content to read right-side-up for whichever way the
16
+ // phone is actually being held. Defaults to 0 for a caller that doesn't track one — matches
17
+ // BaseSettingsDialog's own identical prop, though the first apps consuming this screen shell
18
+ // don't wrap it in a FakeLandscapeView at all and so never have a nonzero one to give.
19
+ rotation?: number
20
+ title?: string
21
+ // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
22
+ // display tier's own smallest step), then 'headlineLarge', both still read as oversized for a
23
+ // header that's paired with a small 24px back-button glyph rather than standing alone the way a
24
+ // title screen's own wordmark does; headlineSmall sits close to that icon's own size instead of
25
+ // towering over it. Any react-native-paper MD3 Text variant, not just the headline tier, for a
26
+ // caller that wants something else entirely.
27
+ titleVariant?: MD3TextVariant
28
+ onBack: () => void
29
+ insets: { top: number; left: number; bottom: number; right: number }
30
+ // Optional, and always passed as a pair — a caller with nothing resettable (or that deliberately
31
+ // doesn't want a reset action on this screen) omits both rather than one alone. Confirmation copy
32
+ // is caller-owned since "erases all stats and achievements" is only accurate for a caller that
33
+ // actually tracks both; a caller with only high scores, say, would want its own wording.
34
+ onReset?: () => void
35
+ resetLabel?: string
36
+ resetConfirmTitle?: string
37
+ resetConfirmBody?: string
38
+ // Independently optional overrides for this screen's own background/text colors — each defaults
39
+ // to the literal-black/white-by-appearance formula described below when omitted, so every
40
+ // existing caller (none of which pass these) renders identically to before. A caller with its own
41
+ // app-wide chrome palette (e.g. a tinted background rather than neutral black/white) passes
42
+ // whichever of the three it needs; cardBorder on the reset-confirm overlay stays fixed regardless,
43
+ // since its low alpha already reads fine against any cardBg.
44
+ fg?: string
45
+ bg?: string
46
+ cardBg?: string
47
+ // Overrides the confirm-overlay's alert icon/title, which otherwise default to auto-paper's
48
+ // colors.secondary. That default is tuned to read against auto-paper's OWN background role, not
49
+ // necessarily against a cardBg override above — a caller passing cardBg should generally pass a
50
+ // matching accentColor too, rather than risk colors.secondary landing close in hue/lightness to
51
+ // its own custom card background (this is what actually happened with a tinted felt cardBg in the
52
+ // app this was first overridden for). Every button on this screen (the reset trigger included)
53
+ // fills with colors.secondary/onSecondary as a self-contained, always-legible pair instead of
54
+ // relying on a text-only color read against the surrounding chrome, so none of them need this —
55
+ // only the bare icon/title do.
56
+ accentColor?: string
57
+ children: ReactNode
58
+ }
59
+
60
+ // The stats/achievements screen shell every @tastic game shares — back button + title header,
61
+ // scrollable content area, and an optional reset-everything action with its own confirm-before-
62
+ // destroying overlay — extracted after the same header/scroll/reset-confirm shape was independently
63
+ // built into LightCycles' own achievements.tsx. Colors default to the same literal-black/white-by-
64
+ // appearance formula BaseSettingsDialog hardcodes internally (same as this package's own
65
+ // BaseSettingsDialog) — zero-config for every existing caller — but fg/bg/cardBg are each
66
+ // independently overridable for a caller whose own app-wide chrome palette isn't that literal
67
+ // black/white convention. `children` is the seam for whatever isn't shared: this component has no
68
+ // opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
69
+ // AchievementRow for the smaller presentational pieces built to go inside it.
70
+ export function BaseStatsScreen({ rotation = 0, 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, cardBg: cardBgOverride, accentColor: accentColorOverride, children }: Props) {
71
+ const { dark, colors } = useAutoPaperTheme()
72
+ const [confirmResetVisible, setConfirmResetVisible] = useState(false)
73
+ const showReset = !!onReset
74
+
75
+ // High-contrast retro look by default: literal black/white by appearance, matching every other
76
+ // screen in this ecosystem (title/loadout/settings all use this identical formula) rather than
77
+ // auto-paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
78
+ // fg/cardBg/cardBorder use internally. Overridable per-instance via the props above for a caller
79
+ // whose own chrome isn't that literal black/white convention; cardBorder has no override (see the
80
+ // Props doc above for why).
81
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
82
+ const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
83
+ const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
84
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
85
+ const accentColor = accentColorOverride ?? colors.secondary
86
+
87
+ return (
88
+ <View style={[styles.container, { backgroundColor: bg }]}>
89
+ <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
90
+ <IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
91
+ <Text variant={titleVariant} style={[styles.title, { color: fg }]}>
92
+ {title}
93
+ </Text>
94
+ </View>
95
+
96
+ <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
97
+ {children}
98
+
99
+ {showReset && (
100
+ <Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.secondary} textColor={colors.onSecondary} style={styles.resetButton}>
101
+ {resetLabel}
102
+ </Button>
103
+ )}
104
+ </ScrollView>
105
+
106
+ {confirmResetVisible && (
107
+ <Portal>
108
+ <View style={[styles.overlay, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
109
+ <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
110
+ <Icon source='alert-outline' size={64} color={accentColor} />
111
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: accentColor }]}>
112
+ {resetConfirmTitle}
113
+ </Text>
114
+ <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
115
+ {resetConfirmBody}
116
+ </Text>
117
+ <Button mode='contained' onPress={() => setConfirmResetVisible(false)} style={styles.overlayButton}>
118
+ Cancel
119
+ </Button>
120
+ <Button
121
+ mode='contained'
122
+ onPress={() => {
123
+ onReset?.()
124
+ setConfirmResetVisible(false)
125
+ }}
126
+ style={styles.overlayButton}
127
+ buttonColor={colors.secondary}
128
+ textColor={colors.onSecondary}
129
+ >
130
+ Reset
131
+ </Button>
132
+ </View>
133
+ </View>
134
+ </Portal>
135
+ )}
136
+ </View>
137
+ )
138
+ }
139
+
140
+ const styles = StyleSheet.create({
141
+ container: {
142
+ flex: 1
143
+ },
144
+ content: {
145
+ gap: 12,
146
+ paddingHorizontal: 20
147
+ },
148
+ header: {
149
+ alignItems: 'center',
150
+ flexDirection: 'row',
151
+ gap: 4,
152
+ paddingBottom: 8
153
+ },
154
+ // Same overlay shape BaseSettingsDialog's own info overlay uses (backdrop + centered card),
155
+ // rotated in place the same way rather than via Portal-root transform for the same reason — see
156
+ // that component's own identical styles for the full rationale.
157
+ overlay: {
158
+ alignItems: 'center',
159
+ backgroundColor: 'rgba(0,0,0,0.72)',
160
+ bottom: 0,
161
+ justifyContent: 'center',
162
+ left: 0,
163
+ position: 'absolute',
164
+ right: 0,
165
+ top: 0
166
+ },
167
+ overlayBody: { textAlign: 'center' },
168
+ overlayButton: { width: 160 },
169
+ overlayCard: {
170
+ alignItems: 'center',
171
+ borderRadius: 20,
172
+ borderWidth: 1,
173
+ gap: 16,
174
+ maxWidth: 360,
175
+ padding: 32
176
+ },
177
+ overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
178
+ resetButton: {
179
+ marginTop: 16
180
+ },
181
+ // Bounds the ScrollView to the space `container`'s flex:1 actually gives it — without this, a
182
+ // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
183
+ // can render at its full unclipped content height instead of the screen's), which leaves the
184
+ // header's back button touch target unreliable underneath it.
185
+ scrollView: {
186
+ flex: 1
187
+ },
188
+ title: {
189
+ flexShrink: 1,
190
+ fontWeight: 'bold'
191
+ }
192
+ })
@@ -0,0 +1,49 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Text } from 'react-native-paper'
4
+
5
+ interface Props {
6
+ label: string
7
+ value: string
8
+ // Independently optional overrides for this row's own text colors — default to the same
9
+ // useAutoPaperTheme()-derived formula as BaseStatsScreen (see that component's own doc for why)
10
+ // when omitted, so every existing caller renders identically to before.
11
+ fg?: string
12
+ fgMuted?: string
13
+ }
14
+
15
+ // A single labeled stat — label on the left (muted), value on the right (bold, full-contrast).
16
+ // Extracted from LightCycles' own achievements.tsx, where this exact row shape backed every
17
+ // section (Overall/Vs CPU/Two Player/Activity). fg/fgMuted default to the same
18
+ // useAutoPaperTheme()-derived formula as BaseStatsScreen (see that component's own doc for why),
19
+ // each independently overridable via the matching prop for a caller with its own chrome palette.
20
+ // Deliberately just label+value text, not a color swatch or profile chip — an app needing a row
21
+ // with a leading visual builds its own on top of this same dark-mode formula (see LightCycles' own
22
+ // ColorStatRow/ProfileRankingRow for an example), since what that visual even is varies per game.
23
+ export function StatRow({ label, value, fg: fgOverride, fgMuted: fgMutedOverride }: Props) {
24
+ const { dark } = useAutoPaperTheme()
25
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
27
+
28
+ return (
29
+ <View style={styles.row}>
30
+ <Text variant='bodyMedium' style={{ color: fgMuted }}>
31
+ {label}
32
+ </Text>
33
+ <Text variant='bodyMedium' style={[styles.value, { color: fg }]}>
34
+ {value}
35
+ </Text>
36
+ </View>
37
+ )
38
+ }
39
+
40
+ const styles = StyleSheet.create({
41
+ row: {
42
+ alignItems: 'center',
43
+ flexDirection: 'row',
44
+ justifyContent: 'space-between'
45
+ },
46
+ value: {
47
+ fontWeight: 'bold'
48
+ }
49
+ })
@@ -0,0 +1,52 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { ReactNode } from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Text } from 'react-native-paper'
5
+
6
+ interface Props {
7
+ label: string
8
+ // Independently optional overrides for this section's own label color/background — default to
9
+ // the same useAutoPaperTheme()-derived formula as StatRow/BaseStatsScreen (see BaseStatsScreen's
10
+ // own doc for why) when omitted, so every existing caller renders identically to before.
11
+ fg?: string
12
+ sectionBg?: string
13
+ children: ReactNode
14
+ }
15
+
16
+ // A labeled, tinted-background grouping box — the "OVERALL"/"VS CPU"/"ACTIVITY"-style section
17
+ // shape from LightCycles' own achievements.tsx, generalized to any content (typically a stack of
18
+ // StatRow). fg/sectionBg default to the same useAutoPaperTheme()-derived formula as
19
+ // StatRow/BaseStatsScreen (see BaseStatsScreen's own doc for why), each independently overridable
20
+ // via the matching prop for a caller with its own chrome palette. The label itself carries the
21
+ // section's own meaning (what it's a section OF), so this component has no opinion on that beyond
22
+ // rendering the string it's given.
23
+ export function StatSection({ label, fg: fgOverride, sectionBg: sectionBgOverride, children }: Props) {
24
+ const { dark } = useAutoPaperTheme()
25
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
+ const sectionBg = sectionBgOverride ?? (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
27
+
28
+ return (
29
+ <View style={[styles.section, { backgroundColor: sectionBg }]}>
30
+ <Text variant='labelMedium' style={[styles.label, { color: fg }]}>
31
+ {label}
32
+ </Text>
33
+ {children}
34
+ </View>
35
+ )
36
+ }
37
+
38
+ const styles = StyleSheet.create({
39
+ // Bold and full-contrast (fg), not the original fontless/fgMuted treatment — that read at the
40
+ // same visual weight as StatRow's own row labels below it (both used the identical muted color;
41
+ // letterSpacing/variant alone didn't separate them enough to read as "this is the group header"
42
+ // at a glance). Matches AchievementRow's own title styling (bold + fg) below it in the same list.
43
+ label: {
44
+ fontWeight: 'bold',
45
+ letterSpacing: 2
46
+ },
47
+ section: {
48
+ borderRadius: 12,
49
+ gap: 8,
50
+ padding: 16
51
+ }
52
+ })
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
+ export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
1
2
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
+ export { BaseStatsScreen } from './BaseStatsScreen'
2
4
  export { CornerActionButtons } from './CornerActionButtons'
3
5
  export { MONO_FONT } from './fonts'
4
6
  export { InlineColorPicker } from './InlineColorPicker'
@@ -9,6 +11,8 @@ export { PressAwayOverlay } from './PressAwayOverlay'
9
11
  export { ReadyButton } from './ReadyButton'
10
12
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
11
13
  export { SharedActionBand } from './SharedActionBand'
14
+ export { StatRow } from './StatRow'
15
+ export { StatSection } from './StatSection'
12
16
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
13
17
  // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
14
18
  // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import