@tastic/hud 0.12.0 → 0.13.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.12.0",
3
+ "version": "0.13.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",
@@ -34,6 +34,13 @@
34
34
  "import": "./dist/index.mjs",
35
35
  "require": "./dist/index.js"
36
36
  },
37
+ "./guide": {
38
+ "react-native": "./src/guide/index.ts",
39
+ "browser": "./src/guide/index.ts",
40
+ "types": "./dist/guide/index.d.ts",
41
+ "import": "./dist/guide/index.mjs",
42
+ "require": "./dist/guide/index.js"
43
+ },
37
44
  "./skia-gate": {
38
45
  "react-native": "./src/SkiaGate.tsx",
39
46
  "browser": "./src/SkiaGate.web.tsx",
@@ -102,12 +109,12 @@
102
109
  "typescript": "^6.0.3"
103
110
  },
104
111
  "peerDependencies": {
105
- "@rific/auto-paper": ">=0.9.0",
106
- "@rific/feedback-press": ">=0.10.0",
112
+ "@rific/auto-paper": ">=0.9.4",
113
+ "@rific/feedback-press": ">=0.10.2",
107
114
  "@rific/updater": ">=0.4.0",
108
115
  "@shopify/react-native-skia": ">=1.5.0",
109
- "@tastic/achievements": ">=0.1.2",
110
- "@tastic/core": ">=0.3.0",
116
+ "@tastic/achievements": ">=0.2.0",
117
+ "@tastic/core": ">=0.5.0",
111
118
  "@tastic/profile": ">=0.4.0",
112
119
  "@tastic/split-screen": ">=0.5.0",
113
120
  "react": ">=19.0.0",
@@ -116,6 +123,26 @@
116
123
  "react-native-reanimated": ">=3.0.0",
117
124
  "react-native-safe-area-context": ">=5.0.0"
118
125
  },
126
+ "peerDependenciesMeta": {
127
+ "@rific/updater": {
128
+ "optional": true
129
+ },
130
+ "@shopify/react-native-skia": {
131
+ "optional": true
132
+ },
133
+ "@tastic/achievements": {
134
+ "optional": true
135
+ },
136
+ "@tastic/core": {
137
+ "optional": true
138
+ },
139
+ "@tastic/profile": {
140
+ "optional": true
141
+ },
142
+ "@tastic/split-screen": {
143
+ "optional": true
144
+ }
145
+ },
119
146
  "publishConfig": {
120
147
  "access": "public"
121
148
  }
@@ -0,0 +1,98 @@
1
+ import { getContrastColor, useAutoPaperTheme } from '@rific/auto-paper'
2
+ import type { AchievementTier } from '@tastic/achievements'
3
+ import { type Profile, ProfileChip } from '@tastic/profile'
4
+ import { StyleSheet, View, type ViewStyle } from 'react-native'
5
+ import { Icon, Text } from 'react-native-paper'
6
+
7
+ // The only fields a row reads - structural, so any app's own AchievementDefinition<Stats> fits
8
+ // without this package caring about its stats type parameter.
9
+ export interface UnlockedAchievement {
10
+ id: string
11
+ icon: string
12
+ title: string
13
+ tier: AchievementTier
14
+ }
15
+
16
+ // Who a seat's rows belong to: their ProfileChip when a profile is selected, otherwise a plain
17
+ // avatar in the seat's own color (a guest seat has no tag to show). An app that draws profile colors
18
+ // muted wraps its tree in @tastic/profile's ProfileColorProvider (ProfileChip reads it) and passes
19
+ // an already-muted `color` here.
20
+ export interface AchievementUnlockOwner {
21
+ profile?: Profile | null
22
+ color: string
23
+ }
24
+
25
+ interface Props<Seat extends number> {
26
+ unlocks: Partial<Record<Seat, UnlockedAchievement[]>>
27
+ // Row order: every unlock of the first seat, then the next.
28
+ seats: readonly Seat[]
29
+ owners: Record<Seat, AchievementUnlockOwner>
30
+ // Taken as a prop rather than imported: @tastic/achievements stays a type-only dependency of
31
+ // this package. Callers pass its ACHIEVEMENT_TIER_COLORS (or their own palette).
32
+ tierColors: Record<AchievementTier, string>
33
+ fg?: string
34
+ style?: ViewStyle
35
+ }
36
+
37
+ const AVATAR_SIZE = 18
38
+
39
+ // The newly-unlocked achievements on a game-over / round-over card, one row per achievement:
40
+ // owner avatar, tier-colored icon badge, title. Extracted from the per-app copies in Snake,
41
+ // LightCycles, Pong, BoxHockey and AirHockey, which had drifted to three different owner markers;
42
+ // this is the ProfileChip-or-color-avatar one most of them used. One row per achievement rather
43
+ // than a per-seat summary, so the list stays aligned when only one seat unlocked anything.
44
+ // Renders nothing when there are no unlocks at all.
45
+ export function AchievementUnlockList<Seat extends number>({ unlocks, seats, owners, tierColors, fg: fgOverride, style }: Props<Seat>) {
46
+ const { dark } = useAutoPaperTheme()
47
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
48
+ const rows = seats.flatMap((seat) => (unlocks[seat] ?? []).map((achievement) => ({ seat, achievement })))
49
+ if (rows.length === 0) return null
50
+
51
+ return (
52
+ <View style={[styles.list, style]}>
53
+ {rows.map(({ seat, achievement }) => {
54
+ const owner = owners[seat]
55
+ return (
56
+ <View key={`${seat}-${achievement.id}`} style={styles.row}>
57
+ {owner.profile ? (
58
+ <ProfileChip profile={owner.profile} size={AVATAR_SIZE} />
59
+ ) : (
60
+ <View style={[styles.circle, { backgroundColor: owner.color }]}>
61
+ <Icon source='account' size={10} color={getContrastColor(owner.color)} />
62
+ </View>
63
+ )}
64
+ <View style={[styles.circle, { backgroundColor: tierColors[achievement.tier] }]}>
65
+ <Icon source={achievement.icon} size={12} color='#000000' />
66
+ </View>
67
+ <Text style={[styles.label, { color: fg }]} numberOfLines={1}>
68
+ {achievement.title}
69
+ </Text>
70
+ </View>
71
+ )
72
+ })}
73
+ </View>
74
+ )
75
+ }
76
+
77
+ const styles = StyleSheet.create({
78
+ circle: {
79
+ alignItems: 'center',
80
+ borderRadius: AVATAR_SIZE / 2,
81
+ height: AVATAR_SIZE,
82
+ justifyContent: 'center',
83
+ width: AVATAR_SIZE
84
+ },
85
+ label: {
86
+ flexShrink: 1,
87
+ fontSize: 12,
88
+ fontWeight: '700'
89
+ },
90
+ list: {
91
+ gap: 6
92
+ },
93
+ row: {
94
+ alignItems: 'center',
95
+ flexDirection: 'row',
96
+ gap: 6
97
+ }
98
+ })
@@ -67,6 +67,10 @@ export interface BaseSettingsDialogProps {
67
67
  // toast system wired up; the check itself still runs and still reports success/no-update through
68
68
  // the ordinary info overlay either way. Meaningless (never called) when hideUpdateCheck is set.
69
69
  onUpdateError?: (message: string) => void
70
+ // Omit to hide the row entirely (a game with no how-to-play flow, or a screen where replaying one
71
+ // would be disruptive — e.g. over a live match). Called AFTER this dialog's own onDismiss, so the
72
+ // two overlays never stack: wire it straight to @tastic/hud/guide's `useGuide().open`.
73
+ onShowHowToPlay?: () => void
70
74
  // App-specific extra settings sections (board options, CPU difficulty, stats backup, ...),
71
75
  // rendered between Appearance and Check for Updates — this component only owns the settings
72
76
  // every game shares, never anything about how a particular game plays.
@@ -80,7 +84,7 @@ export interface BaseSettingsDialogProps {
80
84
  // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
81
85
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
82
86
  // forking this component.
83
- export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
87
+ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, onShowHowToPlay, children }: BaseSettingsDialogProps) {
84
88
  const { colors } = useAutoPaperTheme()
85
89
  const ambientRotation = useRotation()
86
90
  const rotation = rotationOverride ?? ambientRotation
@@ -235,6 +239,33 @@ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverr
235
239
  )}
236
240
  </View>
237
241
  )}
242
+
243
+ {/* Not a toggle, but it reads like one of the rows above and belongs with them rather than
244
+ down in `children` (which sits below Appearance) — this is the "replay the intro"
245
+ entry point every game's own how-to-play flow shares. Dismisses this dialog first so
246
+ the guide's own overlay never opens on top of a still-visible Settings card. */}
247
+ {onShowHowToPlay && (
248
+ <TouchableRipple
249
+ onPress={() => {
250
+ onDismiss()
251
+ onShowHowToPlay()
252
+ }}
253
+ style={styles.toggleButton}
254
+ accessibilityLabel='How to Play'
255
+ >
256
+ <View style={styles.toggleContent}>
257
+ <SettingIcon source='help-circle-outline' color={colors.secondary} containerColor={colors.secondaryContainer} />
258
+ <View style={styles.flexShrink}>
259
+ <Text variant='bodyLarge' style={{ color: colors.onSurface }}>
260
+ How to Play
261
+ </Text>
262
+ <Text variant='bodySmall' numberOfLines={1} style={{ color: colors.onSurfaceVariant }}>
263
+ Replay the quick intro
264
+ </Text>
265
+ </View>
266
+ </View>
267
+ </TouchableRipple>
268
+ )}
238
269
  </View>
239
270
 
240
271
  {showAppearance && (
@@ -5,7 +5,7 @@ import { type ReactNode } from 'react'
5
5
  import { StyleSheet, View } from 'react-native'
6
6
  import { Icon, Portal, Text } from 'react-native-paper'
7
7
 
8
- import { getOverlayCardColors, overlayStyles } from './overlayCard'
8
+ import { getOverlayCardColors, overlayActionStyles, overlayStyles } from './overlayCard'
9
9
 
10
10
  export interface ConfirmDialogProps {
11
11
  visible: boolean
@@ -55,13 +55,13 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
55
55
  <Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
56
56
  {message}
57
57
  </Text>
58
- <View style={styles.actions}>
58
+ <View style={overlayActionStyles.actions}>
59
59
  {showCancel && (
60
- <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
60
+ <Button mode='outlined' onPress={onCancel} style={overlayActionStyles.actionButton}>
61
61
  {cancelLabel}
62
62
  </Button>
63
63
  )}
64
- <Button mode='contained' onPress={onConfirm} style={showCancel ? styles.actionButton : styles.actionButtonSingle} buttonColor={accent} textColor={onAccent}>
64
+ <Button mode='contained' onPress={onConfirm} style={showCancel ? overlayActionStyles.actionButton : overlayActionStyles.actionButtonSingle} buttonColor={accent} textColor={onAccent}>
65
65
  {confirmLabel}
66
66
  </Button>
67
67
  </View>
@@ -72,45 +72,6 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
72
72
  }
73
73
 
74
74
  const styles = StyleSheet.create({
75
- // minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
76
- // still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
77
- // on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
78
- // row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
79
- // correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
80
- // "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
81
- // tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
82
- // nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
83
- // allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
84
- // flexBasis) reaches the outer view, which is the real flex item inside this row; every other
85
- // key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
86
- // defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
87
- // correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
88
- // sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
89
- // correctly regardless of which of the two views it ends up on. react-native-paper's Button also
90
- // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
91
- // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
92
- // fixable by just giving the row more width, until confirmed live that these two-word labels
93
- // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
94
- // width fix below provides, on a narrow enough screen.
95
- actionButton: {
96
- flexGrow: 1,
97
- minWidth: 128
98
- },
99
- // A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
100
- // update-check "no update found" message) has nothing left to share actions' width with, so
101
- // actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
102
- // normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
103
- // the card - centered by actions' own justifyContent below.
104
- actionButtonSingle: {
105
- minWidth: 128
106
- },
107
- actions: {
108
- flexDirection: 'row',
109
- flexWrap: 'wrap',
110
- gap: 12,
111
- justifyContent: 'center',
112
- width: '100%'
113
- },
114
75
  body: {
115
76
  textAlign: 'center'
116
77
  },
@@ -1,6 +1,6 @@
1
- import { computeContentBounds } from '@tastic/core'
1
+ import { computeContentBounds, useRotatedWindowDimensions } from '@tastic/core'
2
2
  import { ReactNode } from 'react'
3
- import { StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native'
3
+ import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
4
4
 
5
5
  interface Props {
6
6
  // Upper bound on the centered content region's width, in px. Omitted (the default) means no
@@ -25,13 +25,20 @@ interface Props {
25
25
  // any further stops being useful. Below maxContentWidth - or with it omitted entirely - this is a
26
26
  // no-op passthrough: children render at the full window width, no gutters.
27
27
  //
28
- // See @tastic/core's computeContentBounds for the actual clamp math this just wires up to a live
29
- // useWindowDimensions() read - deliberately the only geometry source this component owns itself; a
28
+ // The width it clamps against is @tastic/core's useRotatedWindowDimensions(), not a raw
29
+ // useWindowDimensions(): under a fake-landscape ancestor (FakeLandscapeView - the OS window stays
30
+ // portrait-locked, the screen is only visually rotated) the raw window width never changes, so a
31
+ // raw read would cap a rotated play area at the portrait width while everything inside laid out for
32
+ // the swapped landscape footprint, clipping it. That hook is an exact identity with
33
+ // useWindowDimensions() whenever no rotation applies, so nothing changes at rotation 0.
34
+ //
35
+ // See @tastic/core's computeContentBounds for the actual clamp math this just wires up to that live
36
+ // read - deliberately the only geometry source this component owns itself; a
30
37
  // caller that also needs to fold in device safe-area insets or a split-screen zone boundary
31
38
  // composes those together on its own side, the same way LightCycles' useZoneClampedAlign composes
32
39
  // useAutoAlign with its own extra geometry (see that hook for the pattern this follows).
33
40
  export function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style, children }: Props) {
34
- const { width: windowWidth } = useWindowDimensions()
41
+ const { width: windowWidth } = useRotatedWindowDimensions()
35
42
  const { contentWidth, gutterWidth } = computeContentBounds(windowWidth, maxContentWidth ?? Infinity)
36
43
 
37
44
  return (
@@ -0,0 +1,96 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { Icon, Text } from 'react-native-paper'
4
+
5
+ export interface DirectionKeyLabels {
6
+ up: string
7
+ left: string
8
+ down: string
9
+ right: string
10
+ }
11
+
12
+ export type DirectionKeysAxis = 'both' | 'vertical'
13
+
14
+ export interface DirectionKeysHintProps {
15
+ // What's printed on each cap — e.g. { up: 'W', left: 'A', down: 'S', right: 'D' }. Omit for the
16
+ // arrow keys themselves, drawn as arrow icons (so does any single cap left out). Pass whatever the
17
+ // seat's own key scheme actually is, not a guess: the fleet games let each seat pick WASD, arrows,
18
+ // IJKL and so on.
19
+ labels?: Partial<DirectionKeyLabels>
20
+ // 'both' (default) draws the four-key inverted T. 'vertical' draws only up above down, for a game
21
+ // whose controls only ever read those two (Pong's paddles).
22
+ axis?: DirectionKeysAxis
23
+ // Defaults to the theme's own primary — i.e. player 1's seat color in the fleet games.
24
+ color?: string
25
+ }
26
+
27
+ const CAP_SIZE = 34
28
+
29
+ const ARROWS: DirectionKeyLabels = { up: 'arrow-up', left: 'arrow-left', down: 'arrow-down', right: 'arrow-right' }
30
+
31
+ function KeyCap({ label, icon, color }: { label?: string; icon: string; color: string }) {
32
+ return (
33
+ <View style={[styles.cap, { borderColor: color }]}>
34
+ {label !== undefined ? (
35
+ <Text variant='titleMedium' style={[styles.capLabel, { color }]}>
36
+ {label}
37
+ </Text>
38
+ ) : (
39
+ <Icon source={icon} size={20} color={color} />
40
+ )}
41
+ </View>
42
+ )
43
+ }
44
+
45
+ // Key caps in the inverted-T every keyboard player already knows (or just up over down, see `axis`) —
46
+ // the desktop counterpart to SwipeHint for the "how do I steer" card, since a desktop browser steers by
47
+ // key, not by swipe. Plain Views, no animation, so there's nothing for reduced motion to switch off.
48
+ export function DirectionKeysHint({ labels, axis = 'both', color }: DirectionKeysHintProps) {
49
+ const { colors } = useAutoPaperTheme()
50
+ const tint = color ?? colors.primary
51
+
52
+ if (axis === 'vertical') {
53
+ const accessibilityLabel = labels?.up && labels.down ? `Keys ${labels.up} and ${labels.down}` : 'Up and down arrow keys'
54
+ return (
55
+ <View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
56
+ <KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
57
+ <KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
58
+ </View>
59
+ )
60
+ }
61
+
62
+ const accessibilityLabel = labels?.up && labels.left && labels.down && labels.right ? `Keys ${labels.up}, ${labels.left}, ${labels.down}, ${labels.right}` : 'Arrow keys'
63
+
64
+ return (
65
+ <View accessible accessibilityLabel={accessibilityLabel} style={styles.cluster}>
66
+ <KeyCap label={labels?.up} icon={ARROWS.up} color={tint} />
67
+ <View style={styles.row}>
68
+ <KeyCap label={labels?.left} icon={ARROWS.left} color={tint} />
69
+ <KeyCap label={labels?.down} icon={ARROWS.down} color={tint} />
70
+ <KeyCap label={labels?.right} icon={ARROWS.right} color={tint} />
71
+ </View>
72
+ </View>
73
+ )
74
+ }
75
+
76
+ const styles = StyleSheet.create({
77
+ cap: {
78
+ alignItems: 'center',
79
+ borderRadius: 8,
80
+ borderWidth: 2,
81
+ height: CAP_SIZE,
82
+ justifyContent: 'center',
83
+ width: CAP_SIZE
84
+ },
85
+ capLabel: {
86
+ fontWeight: 'bold'
87
+ },
88
+ cluster: {
89
+ alignItems: 'center',
90
+ gap: 6
91
+ },
92
+ row: {
93
+ flexDirection: 'row',
94
+ gap: 6
95
+ }
96
+ })
@@ -0,0 +1,87 @@
1
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
2
+
3
+ import { HowToPlayDialog } from './HowToPlayDialog'
4
+ import type { GuideRotation, GuideStep } from './types'
5
+
6
+ interface GuideContextValue {
7
+ isOpen: boolean
8
+ // Opens the guide unconditionally — the Settings "How to play" row's whole job. Never persists
9
+ // anything on its own: only finishing or skipping does, and only while the player hasn't already
10
+ // seen this version.
11
+ open: () => void
12
+ close: () => void
13
+ // Opens it once per session, and only if this player hasn't seen `currentVersion` yet — see
14
+ // useAutoShowGuide.
15
+ requestAutoShow: () => void
16
+ }
17
+
18
+ const noop = () => {}
19
+
20
+ // A default that does nothing rather than throwing when there's no provider above — an app's own
21
+ // existing tests render its Settings dialog or Home screen without one, and a "How to play" row that
22
+ // silently does nothing in that harness is a far better failure than every such test breaking.
23
+ const GuideContext = createContext<GuideContextValue>({ isOpen: false, open: noop, close: noop, requestAutoShow: noop })
24
+
25
+ export interface GuideProviderProps {
26
+ steps: GuideStep[]
27
+ // Bumped by the app only for a genuine controls/rules change, to re-prompt everyone.
28
+ currentVersion: number
29
+ // Read from the app's own persisted store (see createGuideSlice's selectVersionSeen).
30
+ seenVersion: number
31
+ // Called with `currentVersion` when the player finishes or skips a guide they hadn't seen yet.
32
+ onSeen: (version: number) => void
33
+ // The app's live fake-landscape rotation (see GuideRotation). The card rotates and sizes itself for it.
34
+ rotation?: GuideRotation
35
+ children?: ReactNode
36
+ }
37
+
38
+ // Mounted once at the app root, next to <UpdateDialog /> — the fleet's existing "self-presenting
39
+ // app-level dialog" seat — so any screen can open it through useGuide() without prop-drilling, and
40
+ // the Portal it renders sits above every route. Inside PersistGate, so `seenVersion` is already the
41
+ // rehydrated value by the time this first renders.
42
+ export function GuideProvider({ steps, currentVersion, seenVersion, onSeen, rotation, children }: GuideProviderProps) {
43
+ const [isOpen, setIsOpen] = useState(false)
44
+ // Once per session, not once per Home mount — Home remounts every time the player backs out of a
45
+ // match, and skipping shouldn't be something they have to do again a minute later just because the
46
+ // persisted flag hadn't been written back through the store yet.
47
+ const autoShownRef = useRef(false)
48
+ const unseen = seenVersion < currentVersion
49
+
50
+ const open = useCallback(() => setIsOpen(true), [])
51
+
52
+ const close = useCallback(() => {
53
+ setIsOpen(false)
54
+ if (unseen) onSeen(currentVersion)
55
+ }, [unseen, onSeen, currentVersion])
56
+
57
+ const requestAutoShow = useCallback(() => {
58
+ if (!unseen || autoShownRef.current) return
59
+ autoShownRef.current = true
60
+ setIsOpen(true)
61
+ }, [unseen])
62
+
63
+ const value = useMemo(() => ({ isOpen, open, close, requestAutoShow }), [isOpen, open, close, requestAutoShow])
64
+
65
+ return (
66
+ <GuideContext.Provider value={value}>
67
+ {children}
68
+ <HowToPlayDialog visible={isOpen} steps={steps} onFinish={close} onSkip={close} rotation={rotation} />
69
+ </GuideContext.Provider>
70
+ )
71
+ }
72
+
73
+ export function useGuide(): GuideContextValue {
74
+ return useContext(GuideContext)
75
+ }
76
+
77
+ // Call this from the Home screen and nowhere else — that placement, not any route check in here, is
78
+ // what keeps it from ever firing over a live match or a two-seat loadout. Home is the one screen
79
+ // with a single reader, before any seating, so a face-to-face two-player phone doesn't need per-seat
80
+ // rotated copies of the card. `enabled` lets a caller hold it back (e.g. while a shared-link import
81
+ // is being handled) without breaking the rules of hooks by conditionally calling this.
82
+ export function useAutoShowGuide(enabled = true) {
83
+ const { requestAutoShow } = useGuide()
84
+ useEffect(() => {
85
+ if (enabled) requestAutoShow()
86
+ }, [enabled, requestAutoShow])
87
+ }