@tastic/hud 0.8.5 → 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.8.5",
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",
@@ -71,14 +78,17 @@
71
78
  "prettier": "@infinitetoken/eslint-config/prettier",
72
79
  "devDependencies": {
73
80
  "@infinitetoken/eslint-config": "^0.2.0",
74
- "@infinitetoken/jest-config": "^0.3.0",
75
- "@infinitetoken/tsconfig": "^0.4.1",
76
- "@rific/auto-paper": "^0.11.0",
77
- "@rific/core": "^0.1.1",
78
- "@rific/feedback-press": "^0.11.1",
81
+ "@infinitetoken/jest-config": "^0.4.0",
82
+ "@infinitetoken/tsconfig": "^0.5.0",
83
+ "@rific/auto-paper": "^0.12.0",
84
+ "@rific/core": "^0.2.0",
85
+ "@rific/feedback-press": "^0.12.0",
79
86
  "@rific/updater": "^0.6.0",
80
87
  "@shopify/react-native-skia": "^2.6.2",
81
- "@tastic/core": "^0.4.1",
88
+ "@tastic/achievements": "^0.2.0",
89
+ "@tastic/core": "^0.8.0",
90
+ "@tastic/profile": "^0.7.0",
91
+ "@tastic/split-screen": "^0.5.0",
82
92
  "@testing-library/dom": "^10.4.1",
83
93
  "@testing-library/react": "^16.3.2",
84
94
  "@types/jest": "^30.0.0",
@@ -94,19 +104,44 @@
94
104
  "react-native": "^0.85.3",
95
105
  "react-native-paper": "^5.15.2",
96
106
  "react-native-reanimated": "^4.5.1",
107
+ "react-native-safe-area-context": "^5.10.0",
97
108
  "tsup": "^8.0.0",
98
109
  "typescript": "^6.0.3"
99
110
  },
100
111
  "peerDependencies": {
101
- "@rific/auto-paper": ">=0.9.0",
102
- "@rific/feedback-press": ">=0.10.0",
112
+ "@rific/auto-paper": ">=0.9.4",
113
+ "@rific/feedback-press": ">=0.10.2",
103
114
  "@rific/updater": ">=0.4.0",
104
115
  "@shopify/react-native-skia": ">=1.5.0",
105
- "@tastic/core": ">=0.3.0",
116
+ "@tastic/achievements": ">=0.2.0",
117
+ "@tastic/core": ">=0.5.0",
118
+ "@tastic/profile": ">=0.4.0",
119
+ "@tastic/split-screen": ">=0.5.0",
106
120
  "react": ">=19.0.0",
107
121
  "react-native": ">=0.76.0",
108
122
  "react-native-paper": ">=5.0.0",
109
- "react-native-reanimated": ">=3.0.0"
123
+ "react-native-reanimated": ">=3.0.0",
124
+ "react-native-safe-area-context": ">=5.0.0"
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
+ }
110
145
  },
111
146
  "publishConfig": {
112
147
  "access": "public"
@@ -0,0 +1,60 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import type { AchievementCatalogRow } from '@tastic/achievements'
3
+ import { Fragment } from 'react'
4
+ import { StyleSheet } from 'react-native'
5
+ import { Text } from 'react-native-paper'
6
+
7
+ import { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
8
+ import { MONO_FONT } from './fonts'
9
+
10
+ // AchievementCatalogRow now comes straight from @tastic/achievements' own getAchievementCatalogRows
11
+ // (added in the same 2026-09-18 achievements-scaffolding pass this component belongs to) — no
12
+ // longer duplicated locally now that package has actually published the export (confirmed the two
13
+ // shapes match exactly before wiring this real, type-only peer dependency).
14
+
15
+ interface Props {
16
+ rows: AchievementCatalogRow[]
17
+ label?: string
18
+ // Independently optional overrides for this section's own heading/row colors - default to the
19
+ // same useAutoPaperTheme()-derived formula as AchievementRow/StatSection/StatRow/BaseStatsScreen
20
+ // (see BaseStatsScreen's own doc for why) when omitted, so every existing caller renders
21
+ // identically to before. Passed straight through to each <AchievementRow>.
22
+ fg?: string
23
+ fgMuted?: string
24
+ }
25
+
26
+ // The "ALL ACHIEVEMENTS" heading + one <AchievementRow> per row - extracted verbatim from every
27
+ // fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles), where this
28
+ // exact heading/list pair backed the bottom of the stats screen. Returns a Fragment, not a View:
29
+ // every existing call site rendered the heading and each row as flat siblings inside
30
+ // BaseStatsScreen's own gap-spaced ScrollView content, and wrapping them in a container here would
31
+ // change that spacing (BaseStatsScreen's own `content` style already supplies the gap between
32
+ // every child, this component included).
33
+ //
34
+ // Resolves each row's own locked-vs-unlocked badgeColor (tierColor when unlocked,
35
+ // LOCKED_BADGE_COLOR when not) here rather than upstream in row computation, matching
36
+ // AchievementRow's own "badge color is a rendering decision" doc comment - @tastic/achievements'
37
+ // (pending) getAchievementCatalogRows deliberately leaves tierColor unresolved-to-badgeColor for
38
+ // the same reason.
39
+ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
40
+ const { dark } = useAutoPaperTheme()
41
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
42
+
43
+ return (
44
+ <Fragment>
45
+ <Text variant='labelMedium' style={[styles.label, { color: fgMuted, fontFamily: MONO_FONT }]}>
46
+ {label}
47
+ </Text>
48
+ {rows.map((row) => (
49
+ <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={row.tierColor} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
50
+ ))}
51
+ </Fragment>
52
+ )
53
+ }
54
+
55
+ const styles = StyleSheet.create({
56
+ label: {
57
+ letterSpacing: 2,
58
+ marginTop: 8
59
+ }
60
+ })
@@ -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
+ })
@@ -0,0 +1,29 @@
1
+ import { StatRow } from './StatRow'
2
+ import { StatSection } from './StatSection'
3
+
4
+ interface ActivityStats {
5
+ distinctDaysPlayed: number
6
+ currentDayStreak: number
7
+ bestDayStreak: number
8
+ }
9
+
10
+ interface Props {
11
+ stats: ActivityStats
12
+ }
13
+
14
+ // The "ACTIVITY" StatSection (Days Played / Day Streak / Best Day Streak) - extracted verbatim
15
+ // from every fleet app's own achievements.tsx (Snake, AirHockey, BoxHockey, Pong, LightCycles),
16
+ // where this exact three-row block was byte-for-byte identical. ActivityStats is a plain
17
+ // structural type (matching @tastic/achievements' own DayStreakState shape - distinctDaysPlayed/
18
+ // currentDayStreak/bestDayStreak) rather than importing DayStreakState itself, so this component
19
+ // has no dependency on @tastic/achievements at all: any caller's own stats/statsView object that
20
+ // happens to carry these three fields already satisfies it structurally.
21
+ export function ActivityStatSection({ stats }: Props) {
22
+ return (
23
+ <StatSection label='ACTIVITY'>
24
+ <StatRow label='Days Played' value={String(stats.distinctDaysPlayed)} />
25
+ <StatRow label='Day Streak' value={String(stats.currentDayStreak)} />
26
+ <StatRow label='Best Day Streak' value={String(stats.bestDayStreak)} />
27
+ </StatSection>
28
+ )
29
+ }
@@ -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 (
@@ -2,6 +2,7 @@ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
2
  import { Platform } from 'react-native'
3
3
 
4
4
  import { MenuOption, SectionedDropdown } from './SectionedDropdown'
5
+ import { AlignResult, PopoverRotation } from './useAutoAlign'
5
6
  import { PopoverHost } from './usePopoverHost'
6
7
 
7
8
  export interface ControlSchemePickerProps<T extends string | number> {
@@ -21,6 +22,14 @@ export interface ControlSchemePickerProps<T extends string | number> {
21
22
  onAccentColor?: string
22
23
  dark: boolean
23
24
  align?: 'left' | 'right' | 'center'
25
+ // Passed straight through to the underlying SectionedDropdown — see that component's own
26
+ // `alignOverride` doc. Lets a caller inside a @tastic/split-screen zone substitute
27
+ // useZoneClampedAlign's result for this picker's own popover placement.
28
+ alignOverride?: AlignResult
29
+ // Passed straight through to the underlying SectionedDropdown — see that component's own
30
+ // `rotation` doc. Defaults to a live ambient useRotation() read when omitted, same as
31
+ // SectionedDropdown itself.
32
+ rotation?: PopoverRotation
24
33
  labelFontFamily?: string
25
34
  }
26
35
 
@@ -45,9 +54,9 @@ export interface ControlSchemePickerProps<T extends string | number> {
45
54
  // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
46
55
  // caller always supplies its own `options` (and typically its own `T`) rather than this component
47
56
  // importing `@tastic/input` and choosing a set for every consumer.
48
- export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
57
+ export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, alignOverride, rotation, labelFontFamily }: ControlSchemePickerProps<T>) {
49
58
  const isTouchPrimary = useIsTouchPrimaryDevice()
50
59
  if (Platform.OS !== 'web' || isTouchPrimary) return null
51
60
 
52
- return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
61
+ return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} alignOverride={alignOverride} rotation={rotation} labelFontFamily={labelFontFamily} />
53
62
  }
@@ -6,7 +6,7 @@ import { Icon, Text } from 'react-native-paper'
6
6
 
7
7
  import { MONO_FONT } from './fonts'
8
8
  import { PopoverBody } from './PopoverBody'
9
- import { PopoverRotation, useAutoAlign } from './useAutoAlign'
9
+ import { AlignResult, PopoverRotation, useAutoAlign } from './useAutoAlign'
10
10
  import { PopoverHost } from './usePopoverHost'
11
11
 
12
12
  // Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
@@ -35,6 +35,26 @@ function widthForColumns(columns: number) {
35
35
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1)
36
36
  }
37
37
 
38
+ // Exported so a caller that needs to independently compute this popover's own content size — to
39
+ // feed useZoneClampedAlign's contentWidth/contentHeight params with, ahead of producing a
40
+ // colorAlignOverride for a picker living inside a @tastic/split-screen zone — can match this
41
+ // component's real rendered size exactly, rather than re-deriving or hardcoding its internal
42
+ // column/row math. Same job as LabeledDropdown's own getLabeledDropdownContentHeight, but this
43
+ // picker's size also depends on window width (via the auto-column clamp below), so windowWidth is
44
+ // a required parameter here rather than an ambient useWindowDimensions() read — a caller computing
45
+ // this ahead of render (to decide alignment before the picker itself has even mounted) has no
46
+ // component instance to read that hook from, so it has to pass the value in instead. The component
47
+ // below calls this same function with its own live useWindowDimensions() read, so there is exactly
48
+ // one place this formula lives.
49
+ export function getInlineColorPickerContentSize(swatchCount: number, windowWidth: number, columns?: number): { width: number; height: number } {
50
+ const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
51
+ const resolvedColumns = columns ?? autoColumns
52
+ const width = widthForColumns(resolvedColumns)
53
+ const rows = Math.ceil(swatchCount / resolvedColumns)
54
+ const height = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * rows + SWATCHES_GAP * (rows - 1)
55
+ return { width, height }
56
+ }
57
+
38
58
  interface Props {
39
59
  id: string
40
60
  host: PopoverHost
@@ -59,9 +79,16 @@ interface Props {
59
79
  // tradeoff.
60
80
  allowSwapTaken?: boolean
61
81
  dark: boolean
62
- // Manual override — omit to let the popover measure its own trigger and pick whichever alignment
63
- // keeps it from overflowing the screen edge (see useAutoAlign).
82
+ // Forces a specific horizontal alignment instead of letting whichever placement result applies
83
+ // (see alignOverride below) decide.
64
84
  align?: 'left' | 'right' | 'center'
85
+ // Substitutes this package's own plain useAutoAlign-based placement wholesale (align,
86
+ // verticalAlign, maxHeight, measured, triggerRef) with a caller-supplied one — e.g.
87
+ // useZoneClampedAlign's result, for a picker living inside a @tastic/split-screen zone, where the
88
+ // plain window-relative decision can pick a direction that overflows into the shared row instead
89
+ // of the zone's own boundary. Same shape LabeledDropdown's/SectionedDropdown's own alignOverride
90
+ // takes; omit for the ordinary case (a popover with nothing but the screen edge to avoid).
91
+ alignOverride?: AlignResult
65
92
  // Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
66
93
  // equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
67
94
  // full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
@@ -93,20 +120,20 @@ interface Props {
93
120
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
94
121
  // picker would block the whole screen for one player while another can't touch their own panel at
95
122
  // the same time, which defeats the point of a split-screen lobby.
96
- export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
123
+ export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: forcedAlign, alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
97
124
  const ambientRotation = useRotation()
98
125
  const rotation = rotationOverride ?? ambientRotation
99
126
  const menuBg = dark ? '#000000' : '#FFFFFF'
100
127
  const { width: windowWidth } = useWindowDimensions()
101
- const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
102
- const resolvedColumns = columns ?? autoColumns
103
- const swatchesWidth = widthForColumns(resolvedColumns)
104
- const swatchRows = Math.ceil(swatches.length / resolvedColumns)
105
- const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
128
+ const { width: swatchesWidth, height: swatchesHeight } = getInlineColorPickerContentSize(swatches.length, windowWidth, columns)
106
129
 
107
130
  const open = host.openId === id
108
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
109
- const align = alignOverride ?? autoAlign
131
+ // Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
132
+ // called conditionally. See LabeledDropdown's identical comment for the same tradeoff.
133
+ const auto = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
134
+ const placement = alignOverride ?? auto
135
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement
136
+ const align = forcedAlign ?? placement.align
110
137
  const displayFor = previewValue ?? ((hex: string) => hex)
111
138
  const displayValue = displayFor(value)
112
139
  const contrastColor = getContrastColor(displayValue)
@@ -1,11 +1,10 @@
1
1
  import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { RefObject } from 'react'
4
3
  import { ScrollView, StyleSheet, View } from 'react-native'
5
4
  import { Icon, Text } from 'react-native-paper'
6
5
 
7
6
  import { PopoverBody } from './PopoverBody'
8
- import { PopoverAlign, PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
7
+ import { AlignResult, useAutoAlign } from './useAutoAlign'
9
8
  import { PopoverHost } from './usePopoverHost'
10
9
 
11
10
  const TRIGGER_HEIGHT = 28
@@ -29,17 +28,6 @@ export interface LabeledDropdownOption<T extends string> {
29
28
  icon?: string
30
29
  }
31
30
 
32
- // Same shape useAutoAlign itself returns — lets a caller inside a @tastic/split-screen zone (or
33
- // any other layout this package doesn't know about) substitute its own alignment decision via
34
- // `alignOverride` below, without this package taking a dependency on that caller's own hook.
35
- interface AlignResult {
36
- align: PopoverAlign
37
- verticalAlign: PopoverVerticalAlign
38
- maxHeight: number
39
- measured: boolean
40
- triggerRef: RefObject<View | null>
41
- }
42
-
43
31
  interface Props<T extends string> {
44
32
  id: string
45
33
  host: PopoverHost
@@ -74,10 +62,10 @@ export function LabeledDropdown<T extends string>({ id, host, options, value, on
74
62
  const selected = options.find((o) => o.value === value) ?? null
75
63
  const contentHeight = LIST_PADDING * 2 + options.length * ROW_HEIGHT
76
64
  // Always called, even when alignOverride is supplied and this result goes unused — hooks can't be
77
- // called conditionally. A caller passing alignOverride (e.g. LightCycles' own useZoneClampedAlign,
78
- // which already wraps this same hook) does end up measuring the trigger twice; an acceptable
79
- // tradeoff for keeping this component's own hook usage unconditional and override-shaped rather
80
- // than needing a second, hook-free code path.
65
+ // called conditionally. A caller passing alignOverride (e.g. this package's own
66
+ // useZoneClampedAlign, which already wraps this same hook) does end up measuring the trigger
67
+ // twice; an acceptable tradeoff for keeping this component's own hook usage unconditional and
68
+ // override-shaped rather than needing a second, hook-free code path.
81
69
  const auto = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
82
70
  const placement = alignOverride ?? auto
83
71
  const { maxHeight, measured, triggerRef, verticalAlign } = placement