@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/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
+ export { AchievementCatalogSection } from './AchievementCatalogSection'
1
2
  export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
3
+ export { AchievementUnlockList, type AchievementUnlockOwner, type UnlockedAchievement } from './AchievementUnlockList'
4
+ export { ActivityStatSection } from './ActivityStatSection'
2
5
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
6
  export { BaseStatsScreen } from './BaseStatsScreen'
4
7
  export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'
@@ -7,16 +10,18 @@ export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSch
7
10
  export { CornerActionButtons } from './CornerActionButtons'
8
11
  export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
9
12
  export { MONO_FONT } from './fonts'
10
- export { InlineColorPicker } from './InlineColorPicker'
13
+ export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
11
14
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
12
15
  export { loadSkiaWeb } from './loadSkiaWeb'
13
- export { getOverlayCardColors, type OverlayCardColors, overlayStyles } from './overlayCard'
16
+ export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from './overlayCard'
17
+ export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
14
18
  export { PopoverBody } from './PopoverBody'
15
19
  export { PressAwayOverlay } from './PressAwayOverlay'
16
20
  export { ReadyButton } from './ReadyButton'
17
21
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
18
22
  export { SharedActionBand } from './SharedActionBand'
19
23
  export { SkiaGate, type SkiaGateProps } from './SkiaGate'
24
+ export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from './StaggeredWord'
20
25
  export { StatRow } from './StatRow'
21
26
  export { StatSection } from './StatSection'
22
27
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
@@ -29,5 +34,7 @@ export { StatSection } from './StatSection'
29
34
  export type { TriggerGaugeProps } from './TriggerGauge'
30
35
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
31
36
  export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
32
- export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
37
+ export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
33
38
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
39
+ export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
40
+ export { useZoneClampedAlign } from './useZoneClampedAlign'
@@ -22,10 +22,10 @@ export function getOverlayCardColors(dark: boolean): OverlayCardColors {
22
22
  }
23
23
 
24
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.
25
+ // title/body styling, which already legitimately varies by consumer (each app's own score/badge/zone
26
+ // content). A consumer applies `{backgroundColor: cardBg, borderColor: cardBorder}` from
27
+ // getOverlayCardColors on top of `card` itself, same as every existing call site already does. The
28
+ // one button row every overlay card with actions shares lives in overlayActionStyles below.
29
29
  export const overlayStyles = StyleSheet.create({
30
30
  card: {
31
31
  alignItems: 'center',
@@ -46,3 +46,50 @@ export const overlayStyles = StyleSheet.create({
46
46
  top: 0
47
47
  }
48
48
  })
49
+
50
+ // The action row ConfirmDialog and guide/HowToPlayDialog share: an outlined secondary (Cancel, Skip,
51
+ // Back) on the left, a contained primary on the right, each growing to split the row but never
52
+ // narrower than 128. Moved here from ConfirmDialog when the how-to-play card needed the identical
53
+ // row, so the two can't drift into different button shapes (the guide briefly shipped text-mode
54
+ // Skip/Back — the only text buttons in the fleet).
55
+ export const overlayActionStyles = StyleSheet.create({
56
+ // minWidth (not flexBasis) is each button's real floor - whenever both fit side by side, flexGrow
57
+ // still splits any leftover width evenly beyond that floor, same visual result as a plain flex: 1
58
+ // on a roomy screen, but a button can never be squeezed narrower than 128, only wrap to its own
59
+ // row instead (see actions' flexWrap below). This used to be flexBasis: 128, which measured
60
+ // correctly on web but rendered every button roughly SQUARE on iOS - confirmed live (see the
61
+ // "Quit Match?" Cancel/Quit buttons and the single-button "OK" dialogs both coming out ~128pt
62
+ // tall instead of wide). Root cause: react-native-paper's Button renders its Surface as two
63
+ // nested views on iOS only (Surface.tsx's SurfaceIOS, for shadow rendering) - only a curated
64
+ // allowlist of style keys (flex/flexGrow/flexShrink/width/height/position/... - notably NOT
65
+ // flexBasis) reaches the outer view, which is the real flex item inside this row; every other
66
+ // key, flexBasis included, falls through to an inner view whose own unspecified flexDirection
67
+ // defaults to column. flexGrow: 1 landed on the (row-context) outer view and grew width
68
+ // correctly; flexBasis: 128 landed on the (column-context) inner view instead, where a flexBasis
69
+ // sets HEIGHT, not width. minWidth isn't axis-relative like flexBasis, so it constrains width
70
+ // correctly regardless of which of the two views it ends up on. react-native-paper's Button also
71
+ // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
72
+ // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
73
+ // fixable by just giving the row more width, until confirmed live that these two-word labels
74
+ // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room ConfirmDialog's own
75
+ // card width fix provides, on a narrow enough screen.
76
+ actionButton: {
77
+ flexGrow: 1,
78
+ minWidth: 128
79
+ },
80
+ // A lone confirm button (no cancel - BaseSettingsDialog's info-only "OK" dialogs, e.g. the
81
+ // update-check "no update found" message) has nothing left to share actions' width with, so
82
+ // actionButton's own flexGrow: 1 would stretch it across the entire row instead of reading as a
83
+ // normal button. Same 128 floor, no flexGrow, so it stays at that natural size instead of filling
84
+ // the card - centered by actions' own justifyContent below.
85
+ actionButtonSingle: {
86
+ minWidth: 128
87
+ },
88
+ actions: {
89
+ flexDirection: 'row',
90
+ flexWrap: 'wrap',
91
+ gap: 12,
92
+ justifyContent: 'center',
93
+ width: '100%'
94
+ }
95
+ })
@@ -1,5 +1,5 @@
1
1
  import { type ViewRotation } from '@tastic/core'
2
- import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
3
3
  import { useWindowDimensions, View } from 'react-native'
4
4
 
5
5
  // Minimum breathing room between a popover's outer edge and the screen edge — matches the general
@@ -13,6 +13,22 @@ export type PopoverVerticalAlign = 'below' | 'above'
13
13
  // rotation" reads clearer at these call sites than the more general core name.
14
14
  export type PopoverRotation = ViewRotation
15
15
 
16
+ // Same shape useAutoAlign itself returns — exported so a caller can substitute this package's own
17
+ // plain, screen-edge-aware placement wholesale via a component's own `alignOverride` prop (see
18
+ // LabeledDropdown's, SectionedDropdown's, and InlineColorPicker's own `alignOverride`), most often
19
+ // with useZoneClampedAlign's result for a popover living inside a @tastic/split-screen zone, where
20
+ // the plain window-relative decision can pick a direction that overflows into the shared row
21
+ // instead of the zone's own boundary. Hand-declared rather than `ReturnType<typeof useAutoAlign>`
22
+ // so this stays a stable, self-documented public type regardless of useAutoAlign's own internal
23
+ // implementation shape.
24
+ export interface AlignResult {
25
+ align: PopoverAlign
26
+ verticalAlign: PopoverVerticalAlign
27
+ maxHeight: number
28
+ measured: boolean
29
+ triggerRef: RefObject<View | null>
30
+ }
31
+
16
32
  // Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
17
33
  // useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
18
34
  // position) into its true, post-rotation position — needed for a trigger sitting inside a
@@ -0,0 +1,61 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ export interface QuitConfirmation {
4
+ /** Wire directly to <ConfirmDialog visible={...}>. */
5
+ confirmVisible: boolean
6
+ /** Wire directly to the back button's onPress (GameActionChips/IconButton onBack/onPress). */
7
+ requestBack: () => void
8
+ /** Wire directly to <ConfirmDialog onCancel={...}>. */
9
+ cancelBack: () => void
10
+ }
11
+
12
+ // Captures the onBackPress + ConfirmDialog "Quit Match?" pattern every fleet game.tsx already
13
+ // hand-wires (Snake, AirHockey, BoxHockey, Pong, LightCycles): interrupt backing out of a match
14
+ // with a confirmation only when there's something worth losing, otherwise back out immediately.
15
+ //
16
+ // `hasProgress` is a lazy () => boolean, not a plain boolean value taken directly - deliberately
17
+ // corrected from this hook's own first version, which took a plain boolean and broke in practice
18
+ // for AirHockey/BoxHockey/Pong: `paused` (derived from this hook's own confirmVisible) has to be
19
+ // computed and handed to useGameState() BEFORE that call returns the very scores/lives/bricks state
20
+ // hasProgress needs to read, so a plain-boolean argument creates a real render-order circular
21
+ // dependency (confirmVisible -> paused -> useGameState -> state -> hasProgress -> confirmVisible).
22
+ // All three apps independently worked around this with their own bespoke shadow-state ("read last
23
+ // render's value, one commit behind"), converging on the same fix by three different names - a sign
24
+ // the hook's own contract was wrong, not that each call site needed its own patch. A lazy getter
25
+ // breaks the cycle by deferring the read to requestBack's own call time, exactly mirroring how the
26
+ // pre-extraction, hand-rolled version of this pattern in every app read scores/bricks/lives fresh
27
+ // from its own onBackPress closure rather than needing the value available earlier in the render.
28
+ //
29
+ // Mirrored into a ref via this fleet's standard effect-based idiom (useEffect with no dependency
30
+ // array, not a bare render-time assignment - see @rific/core's createSettingsContext.tsx for the
31
+ // same convention) rather than read directly, so a stale closure from an earlier render is never
32
+ // what requestBack ends up calling. This also gives requestBack/cancelBack a stable identity across
33
+ // every render (empty deps arrays), which the original plain-boolean version couldn't offer.
34
+ //
35
+ // Deliberately does NOT own or render a <ConfirmDialog> - title/message/icon/confirmLabel/
36
+ // cancelLabel/rotation are all still game-specific (a score pairing, a lives/bricks count, a
37
+ // round-history pip row) and stay authored at each call site; this only owns the open/closed
38
+ // state and the "should this even ask" gate. onConfirmedBack is a plain callback (every current
39
+ // caller passes its own app's safeBack from @/utils/navigation) rather than hardcoded, so this
40
+ // package takes on no expo-router dependency.
41
+ export function useQuitConfirmation(hasProgress: () => boolean, onConfirmedBack: () => void): QuitConfirmation {
42
+ const [confirmVisible, setConfirmVisible] = useState(false)
43
+
44
+ const hasProgressRef = useRef(hasProgress)
45
+ useEffect(() => {
46
+ hasProgressRef.current = hasProgress
47
+ })
48
+ const onConfirmedBackRef = useRef(onConfirmedBack)
49
+ useEffect(() => {
50
+ onConfirmedBackRef.current = onConfirmedBack
51
+ })
52
+
53
+ const requestBack = useCallback(() => {
54
+ if (hasProgressRef.current()) setConfirmVisible(true)
55
+ else onConfirmedBackRef.current()
56
+ }, [])
57
+
58
+ const cancelBack = useCallback(() => setConfirmVisible(false), [])
59
+
60
+ return { confirmVisible, requestBack, cancelBack }
61
+ }
@@ -0,0 +1,135 @@
1
+ import { useZoneBounds } from '@tastic/split-screen'
2
+ import { useEffect, useState } from 'react'
3
+ import { useWindowDimensions } from 'react-native'
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
5
+
6
+ import { PopoverRotation, PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
7
+
8
+ // Matches useAutoAlign's own EDGE_MARGIN convention — not exported from that file, so re-declared
9
+ // here rather than reaching into its internals.
10
+ const EDGE_MARGIN = 12
11
+
12
+ interface OwnBounds {
13
+ verticalAlign: PopoverVerticalAlign
14
+ maxHeight: number
15
+ }
16
+
17
+ // Wraps useAutoAlign with a zone-aware vertical decision for popovers that live inside a
18
+ // @tastic/split-screen zone (see useZoneBounds' own doc) — a no-op outside one (e.g. a solo,
19
+ // non-split-screen screen), where useAutoAlign's own decision is returned untouched.
20
+ //
21
+ // useAutoAlign picks above-vs-below and its own maxHeight by comparing room against the *full
22
+ // device window* — for a trigger inside a zone, that's wrong in a way a simple clamp on top can't
23
+ // fix: capping its chosen direction after the fact still leaves it capable of *choosing* the
24
+ // direction that points into the shared row (or the opposite player's own zone), which, once
25
+ // correctly capped at that boundary, often has hardly any room at all — nearly useless rather than
26
+ // merely mis-sized. The fix has to re-derive the whole vertical decision using the zone's real
27
+ // boundary instead of the window edge, preferring whichever real-world direction is actually safe
28
+ // (away from the shared row — that direction's room really is bounded by the screen edge, same as
29
+ // useAutoAlign already assumes) and only reaching toward the shared row when the content doesn't
30
+ // fit the safe side and the shared side genuinely has more room — the same "only flips when
31
+ // genuinely better" bias useAutoAlign itself uses, just comparing against the zone's own boundary
32
+ // instead of the opposite screen edge.
33
+ //
34
+ // One more twist specific to p2's 180°-rotated zone: PopoverBody positions itself with plain
35
+ // relative CSS (`top:'100%'`/`bottom:'100%'`) against its trigger, resolved in *local*
36
+ // (pre-rotation) space and then rotated along with everything else in the zone — which inverts it,
37
+ // local 'below' the trigger paints as real-world *above* it once the whole zone flips 180°, and
38
+ // vice versa. This hook's result has to be flipped back to the *local* label PopoverBody expects
39
+ // whenever the zone itself is rotated (zone.rotated), or it ends up choosing the real-world
40
+ // direction it meant to avoid — which is exactly the bug an earlier version of this hook had for
41
+ // p2 specifically (it worked correctly for p1, which isn't rotated, and that's what made the bug
42
+ // easy to miss during testing).
43
+ //
44
+ // CORRECTION (2026-09-18): the paragraph above used to also claim "this hook's own direction math
45
+ // is all real-world (from measureInWindow, which — unlike onLayout — already resolves
46
+ // post-transform)" — that's the exact claim useAutoAlign.ts's own doc documents as having already
47
+ // shipped one real, unnoticed bug once (measureInWindow reports the PRE-transform frame, not
48
+ // post-transform; see that file's own comment). This hook still doesn't apply useAutoAlign's own
49
+ // rotateRect(...) correction to its raw measureInWindow read below (awayRoom/towardRoom are
50
+ // computed straight from the uncorrected y/height) — that's a real, currently-unfixed gap, left
51
+ // alone deliberately rather than guessed at: DualZoneLayout's own sharedEdgeY (see
52
+ // React-Native-Split-Screen's DualZoneLayout.tsx, measureShared) has the identical uncorrected-
53
+ // measureInWindow gap, since sharedRef sits under the same live-rotating outer FakeLandscapeView
54
+ // every real caller wraps this in — and zone.rotated alone isn't sufficient to fix just this
55
+ // hook's own half of it (it captures a trigger's own net rotation for p1/p2 specifically, which
56
+ // happens to fully account for the outer rotation there, but sharedEdgeY's own correction needs
57
+ // the outer rotation directly, which differs from zone.rotated for whichever seat zone.rotated is
58
+ // currently true for). Correcting one side without the other risks making the comparison *more*
59
+ // wrong, not less. No current caller in the fleet actually exercises this path today — every real
60
+ // cpuDifficultyAlignOverride call site across the fleet is a confirmed no-op, never actually
61
+ // rendering inside a live DualZoneLayout zone — so nothing currently miscalculates in practice, but
62
+ // this needs real rotated-zone test coverage (ideally live device testing, not just a unit test
63
+ // stubbing measureInWindow) before either hook's room-size/direction math under live device
64
+ // rotation is trustworthy.
65
+ //
66
+ // `rotation` IS threaded through below, though — that part's unambiguous and low-risk on its own:
67
+ // it only ever reaches useAutoAlign's own (rotation-aware) horizontal align decision, which is
68
+ // exactly what a caller gets back verbatim via the `if (!zone) return auto` fallback whenever this
69
+ // hook is used outside a zone at all (its own documented "safe to reach for even in a component
70
+ // that might or might not end up inside one" case) — matching every sibling rotation-aware
71
+ // component's own default of an ambient `useRotation()` read when the caller omits it.
72
+ //
73
+ // The "away" direction's own room also has to stop at the physical safe area, not the raw window
74
+ // edge — useAutoAlign's own windowHeight is the full device height, notch/status bar and home
75
+ // indicator included, so left alone this hook's fix for the shared-row boundary would just trade
76
+ // one overflow (into the other zone) for another (under the status bar, or behind the home
77
+ // indicator). The toward-shared direction doesn't need this: sharedEdgeY already sits well inside
78
+ // the safe content area on its own.
79
+ export function useZoneClampedAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0): ReturnType<typeof useAutoAlign> {
80
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
81
+ const zone = useZoneBounds()
82
+ const { height: windowHeight } = useWindowDimensions()
83
+ const insets = useSafeAreaInsets()
84
+ const shouldMeasure = open && auto.measured && zone !== null
85
+
86
+ // null until this popover's own zone-aware measurement lands (see the effect below) — separate
87
+ // from auto.measured, which only covers useAutoAlign's own (zone-blind) measurement.
88
+ const [ownBounds, setOwnBounds] = useState<OwnBounds | null>(null)
89
+ // Resets synchronously during render, not in the effect below, the instant shouldMeasure flips
90
+ // either direction — same "adjust state during render" pattern useAutoAlign itself uses for its
91
+ // own measured reset (see that hook's own comment), so a stale ownBounds from a previous
92
+ // open/zone never survives into a render it doesn't apply to, not even for the one frame before
93
+ // an effect would otherwise catch up.
94
+ const [prevShouldMeasure, setPrevShouldMeasure] = useState(shouldMeasure)
95
+ if (shouldMeasure !== prevShouldMeasure) {
96
+ setPrevShouldMeasure(shouldMeasure)
97
+ setOwnBounds(null)
98
+ }
99
+
100
+ // Re-measures the same trigger node useAutoAlign itself measures, once *its* measurement has
101
+ // landed (auto.measured, folded into shouldMeasure) so the ref is guaranteed mounted and settled.
102
+ // A second measureInWindow call on the same node, not a shared one with useAutoAlign — that
103
+ // hook's own measurement is fully internal to it, with nothing exposed to piggyback on beyond the
104
+ // ref itself.
105
+ useEffect(() => {
106
+ if (!shouldMeasure) return
107
+ auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
108
+ const belowShared = zone!.zoneSide === 'belowShared'
109
+ // Same shape as useAutoAlign's own roomBelow/roomAbove — this is the direction with nothing
110
+ // else between the popover and the real screen edge — but stopping at the safe area inset
111
+ // instead of the raw window edge (insets.top/insets.bottom are always real physical-device
112
+ // edges, unaffected by any rotation applied further down the tree, so this is correct as-is
113
+ // for p2's rotated zone too, not just p1's).
114
+ const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN : y - insets.top - EDGE_MARGIN
115
+ // The other direction runs into the shared row instead of the screen edge.
116
+ const towardRoom = belowShared ? y - zone!.sharedEdgeY - EDGE_MARGIN : zone!.sharedEdgeY - (y + height) - EDGE_MARGIN
117
+ const useToward = contentHeight > awayRoom && towardRoom > awayRoom
118
+ // Real-world direction, before any rotation compensation.
119
+ let awaySide: PopoverVerticalAlign = belowShared ? 'below' : 'above'
120
+ let towardSide: PopoverVerticalAlign = belowShared ? 'above' : 'below'
121
+ // See this hook's own doc — PopoverBody's local-then-rotate positioning inverts 'above'/
122
+ // 'below' for a rotated zone, so the real-world label chosen above has to flip back to the
123
+ // local one that actually produces it once painted.
124
+ if (zone!.rotated) {
125
+ awaySide = awaySide === 'above' ? 'below' : 'above'
126
+ towardSide = towardSide === 'above' ? 'below' : 'above'
127
+ }
128
+ setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide })
129
+ })
130
+ }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom])
131
+
132
+ if (!zone) return auto
133
+ if (!ownBounds) return { ...auto, measured: false }
134
+ return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign }
135
+ }