@tastic/hud 0.16.0 → 0.18.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.
@@ -3,9 +3,12 @@ import { Button } from '@rific/feedback-press'
3
3
  import { toRotationStyle, type ViewRotation } from '@tastic/core'
4
4
  import { type ReactNode } from 'react'
5
5
  import { StyleSheet, View } from 'react-native'
6
- import { Icon, Portal, Text } from 'react-native-paper'
6
+ import { Portal, Text } from 'react-native-paper'
7
7
 
8
- import { getOverlayCardColors, overlayActionStyles, overlayStyles } from './overlayCard'
8
+ import { DialogHeader } from './DialogHeader'
9
+ import { overlayActionStyles } from './overlayCard'
10
+ import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from './OverlaySurface'
11
+ import { useCompactDialog } from './useCompactDialog'
9
12
 
10
13
  export interface ConfirmDialogProps {
11
14
  visible: boolean
@@ -19,39 +22,38 @@ export interface ConfirmDialogProps {
19
22
  // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
20
23
  // match (a portrait-only or single-player game has nothing to stay consistent with).
21
24
  rotation?: ViewRotation
22
- // Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
23
- // question mark, since this component has no way to know why a caller is asking.
25
+ // Any react-native-paper Icon `source` name, shown in the header (DialogHeader) - defaults to a
26
+ // plain question mark, since this component has no way to know why a caller is asking.
24
27
  icon?: string
25
28
  // Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
26
29
  // instead of the default primary-colored affirmative action.
27
30
  destructive?: boolean
28
31
  }
29
32
 
30
- // A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern - the
31
- // same "retro card overlay" look BaseSettingsDialog's own update-check info messages already use
32
- // (see its onInfo/infoMessage), generalized here into its own reusable, exported component for any
33
- // caller that needs a real yes/no decision instead of a single OK dismissal. A solid backdrop +
34
- // centered card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
33
+ // A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern, the same
34
+ // card BaseSettingsDialog's own update-check info messages use (see its onInfo/infoMessage),
35
+ // generalized into its own reusable, exported component for any caller that needs a real yes/no
36
+ // decision instead of a single OK dismissal. The card and backdrop come from OverlaySurface.tsx: the
37
+ // theme's raised surface, blurred when the player's Blur setting allows it. A backdrop + centered
38
+ // card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
35
39
  // this renders identically on every platform, including web, where react-native-web's Alert.alert
36
40
  // is a hard no-op (confirmed: its entire implementation is `static alert() {}`) and Dialog's own
37
41
  // Portal-based positioning has its own web quirks BaseSettingsDialog already works around.
38
42
  export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
39
- const { dark, colors } = useAutoPaperTheme()
43
+ const { colors } = useAutoPaperTheme()
44
+ const { fg } = useOverlayCardColors()
45
+ const { compact, onLayout } = useCompactDialog(rotation)
40
46
  if (!visible) return null
41
47
 
42
- const { fg, cardBg, cardBorder } = getOverlayCardColors(dark)
43
48
  const accent = destructive ? colors.danger : colors.primary
44
49
  const onAccent = destructive ? colors.onDanger : colors.onPrimary
45
50
  const showCancel = cancelLabel !== undefined && onCancel !== undefined
46
51
 
47
52
  return (
48
53
  <Portal>
49
- <View style={[overlayStyles.overlay, styles.overlay]}>
50
- <View style={[overlayStyles.card, styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, toRotationStyle(rotation)]}>
51
- <Icon source={icon} size={64} color={accent} />
52
- <Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
53
- {title}
54
- </Text>
54
+ <OverlayBackdrop style={styles.overlay} onLayout={onLayout}>
55
+ <OverlayCard style={[styles.card, toRotationStyle(rotation)]}>
56
+ <DialogHeader icon={icon} title={title} color={accent} compact={compact} />
55
57
  <Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
56
58
  {message}
57
59
  </Text>
@@ -65,8 +67,8 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
65
67
  {confirmLabel}
66
68
  </Button>
67
69
  </View>
68
- </View>
69
- </View>
70
+ </OverlayCard>
71
+ </OverlayBackdrop>
70
72
  </Portal>
71
73
  )
72
74
  }
@@ -89,12 +91,5 @@ const styles = StyleSheet.create({
89
91
  // screen narrower than maxWidth's own 360.
90
92
  overlay: {
91
93
  paddingHorizontal: 24
92
- },
93
- // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
94
- // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
95
- // line inside that block still defaults to left-aligned without this.
96
- title: {
97
- fontWeight: 'bold',
98
- textAlign: 'center'
99
94
  }
100
95
  })
@@ -0,0 +1,72 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
3
+ import { Icon, Text } from 'react-native-paper'
4
+
5
+ // Below this much height every dialog uses the compact header (and the guide its smaller art). It is
6
+ // the height of the space the dialog lays out in, counted along the CARD's own vertical axis: the
7
+ // frame's height for an upright card, the frame's width for a card turned a quarter (see
8
+ // useCompactDialog.ts). The how-to-play guide borrows this file, so it imports nothing heavier than
9
+ // paper and auto-paper (see guideImports.test.ts); the hook, which needs @tastic/core, lives apart. 528 is the guide's old rule (480 of usable height inside its 24pt gutters): a
10
+ // phone held sideways (320-430) is compact, a small phone upright (568+) and an iPad either way are not.
11
+ export const COMPACT_DIALOG_HEIGHT = 528
12
+
13
+ export function isCompactDialogHeight(height: number): boolean {
14
+ return height < COMPACT_DIALOG_HEIGHT
15
+ }
16
+
17
+ export interface DialogHeaderProps {
18
+ // Any react-native-paper Icon `source` name.
19
+ icon: string
20
+ title: string
21
+ // The icon and title color. Defaults to the theme's primary; ConfirmDialog passes its danger role
22
+ // when destructive.
23
+ color?: string
24
+ // A short space (see useCompactDialog.ts): the icon moves beside the title, at half size, so a sheet
25
+ // keeps its reading room.
26
+ compact?: boolean
27
+ style?: StyleProp<ViewStyle>
28
+ }
29
+
30
+ // The one header every dialog in the fleet opens with (Jay, 2026-10-15: "they should all have the same
31
+ // title and maybe an icon"): a 64pt icon over a large bold title, both in the accent color, centered.
32
+ // ConfirmDialog, the How to Play guide, BaseSettingsDialog and every app's own result cards use it,
33
+ // so a dialog never hand-rolls its own title again.
34
+ export function DialogHeader({ icon, title, color, compact = false, style }: DialogHeaderProps) {
35
+ const { colors } = useAutoPaperTheme()
36
+ const accent = color ?? colors.primary
37
+ return (
38
+ <View style={[compact ? styles.compact : styles.full, style]}>
39
+ <Icon source={icon} size={compact ? COMPACT_ICON_SIZE : ICON_SIZE} color={accent} />
40
+ <Text accessibilityRole='header' variant={compact ? 'headlineSmall' : 'headlineLarge'} style={[styles.title, compact && styles.titleCompact, { color: accent }]}>
41
+ {title}
42
+ </Text>
43
+ </View>
44
+ )
45
+ }
46
+
47
+ const ICON_SIZE = 64
48
+ const COMPACT_ICON_SIZE = 32
49
+
50
+ const styles = StyleSheet.create({
51
+ compact: {
52
+ alignItems: 'center',
53
+ alignSelf: 'stretch',
54
+ flexDirection: 'row',
55
+ gap: 12,
56
+ justifyContent: 'center'
57
+ },
58
+ full: {
59
+ alignItems: 'center',
60
+ alignSelf: 'stretch',
61
+ gap: 16
62
+ },
63
+ // textAlign matters once a title wraps: the card centers the Text's block, but each wrapped line
64
+ // still defaults to left-aligned without it.
65
+ title: {
66
+ fontWeight: 'bold',
67
+ textAlign: 'center'
68
+ },
69
+ titleCompact: {
70
+ flexShrink: 1
71
+ }
72
+ })
@@ -0,0 +1,70 @@
1
+ import { BlurView, useAutoPaperTheme, useBlur } from '@rific/auto-paper'
2
+ import { type ReactNode } from 'react'
3
+ import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
4
+
5
+ import { overlayStyles } from './overlayCard'
6
+
7
+ export interface OverlayCardThemeColors {
8
+ fg: string
9
+ fgMuted: string
10
+ cardBorder: string
11
+ backdrop: string
12
+ }
13
+
14
+ // The colors for text and edges on an OverlayCard, taken from the app's own theme (Jay, 2026-10-15:
15
+ // the cards follow each game's palette instead of the old literal black/white "retro" card).
16
+ export function useOverlayCardColors(): OverlayCardThemeColors {
17
+ const { colors } = useAutoPaperTheme()
18
+ return { fg: colors.onSurface, fgMuted: colors.onSurfaceVariant, cardBorder: colors.outlineVariant, backdrop: colors.backdrop }
19
+ }
20
+
21
+ export interface OverlayBackdropProps {
22
+ children?: ReactNode
23
+ style?: StyleProp<ViewStyle>
24
+ // useCompactDialog's onLayout (DialogHeader.tsx): the backdrop fills the space the dialog lays out in.
25
+ onLayout?: (e: LayoutChangeEvent) => void
26
+ }
27
+
28
+ // The full-screen dim behind a card: overlayStyles.overlay's geometry in the theme's own backdrop
29
+ // color (the same scrim auto-paper's Dialog uses), lighter than the old solid 72% black.
30
+ export function OverlayBackdrop({ children, style, onLayout }: OverlayBackdropProps) {
31
+ const { backdrop } = useOverlayCardColors()
32
+ return (
33
+ <View onLayout={onLayout} style={[overlayStyles.overlay, { backgroundColor: backdrop }, style]}>
34
+ {children}
35
+ </View>
36
+ )
37
+ }
38
+
39
+ export interface OverlayCardProps {
40
+ children?: ReactNode
41
+ style?: StyleProp<ViewStyle>
42
+ accessibilityViewIsModal?: boolean
43
+ testID?: string
44
+ }
45
+
46
+ // The card itself: the same surface as auto-paper's Dialog, which BaseSettingsDialog's sheet is drawn
47
+ // on, so every dialog in the fleet looks like one family (Jay, 2026-10-15: the card used to sit at
48
+ // elevation 3, MD3's wash of the theme's primary, and next to the plain Settings sheet it read as a
49
+ // different shade; he preferred the sheet). So: auto-paper's BlurView with no elevation (the theme's
50
+ // plain surface), no border, and the Dialog's own corner radius (7 x the theme's roundness, 28 at MD3's
51
+ // default). It blurs whatever is behind it when the player's Blur setting is on and the app has handed
52
+ // expo-blur to auto-paper's Provider, and otherwise paints that surface on its own: on Android (no blur
53
+ // target here), in an app without expo-blur, or with Blur off. Beyond passing expoBlur to auto-paper's
54
+ // Provider, an app wires nothing for the blur.
55
+ // BlurView itself only follows its `blur` prop (on by default), so the setting is read here with
56
+ // useBlur(), the same way auto-paper's own Dialog and Menu read it.
57
+ // overflow: hidden keeps a native blur inside the card's rounded corners.
58
+ export function OverlayCard({ children, style, ...rest }: OverlayCardProps) {
59
+ const { isV3, roundness } = useAutoPaperTheme()
60
+ const blur = useBlur()
61
+ return (
62
+ <BlurView blur={blur} style={[overlayStyles.card, styles.card, { borderRadius: (isV3 ? 7 : 1) * roundness }, style]} {...rest}>
63
+ {children}
64
+ </BlurView>
65
+ )
66
+ }
67
+
68
+ const styles = StyleSheet.create({
69
+ card: { overflow: 'hidden' }
70
+ })
@@ -77,10 +77,10 @@ const SELECTION_TRANSITION_DURATION_MS = 260
77
77
  // Arc of dashes drawn around a SectionedDropdown trigger, purely decorative-informational - the
78
78
  // trigger's own icon stays exactly as it is (see SectionedDropdown's triggerIcon), this just frames
79
79
  // it with an at-a-glance read of *which* option is active without opening the popover. Each dash is
80
- // a real Skia arc segment (Path.addArc), not a straight bar approximating one - a straight bar wide
81
- // enough to look bold started visibly crossing its neighbors on a sparse (e.g. 3-option) gauge,
82
- // since a chord that long diverges a lot from the circle it's meant to trace; an actual arc stays on
83
- // the circle at any width. Skia measures angles clockwise from 3 o'clock, so a center angle here
80
+ // a real Skia arc segment (PathBuilder.addArc), not a straight bar approximating one - a straight
81
+ // bar wide enough to look bold started visibly crossing its neighbors on a sparse (e.g. 3-option)
82
+ // gauge, since a chord that long diverges a lot from the circle it's meant to trace; an actual arc
83
+ // stays on the circle at any width. Skia measures angles clockwise from 3 o'clock, so a center angle here
84
84
  // (measured clockwise from 12, like startDeg/endDeg) is shifted by -90° before being handed to
85
85
  // addArc.
86
86
  export function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, dashWidth, startDeg = DEFAULT_START_DEG, endDeg = startDeg }: TriggerGaugeProps) {
@@ -201,7 +201,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
201
201
  // there the instant a lit dash starts shrinking off of it, rather than popping in only once the
202
202
  // shrink finishes.
203
203
  const unlitPath = useDerivedValue(() => {
204
- const path = Skia.Path.Make()
204
+ const path = Skia.PathBuilder.Make()
205
205
  for (let i = 0; i < segments; i++) {
206
206
  if (litIndices.includes(i)) continue
207
207
  let local = 1
@@ -217,7 +217,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
217
217
  const dashCenterDeg = startDeg + (i + 0.5) * step
218
218
  path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
219
219
  }
220
- return path
220
+ return path.detach()
221
221
  // litIndicesKey, not litIndices - the array itself is a brand-new reference on every render
222
222
  // (including ones triggered by a totally unrelated sibling gauge or settings field), which would
223
223
  // otherwise force this worklet to rebuild on every such render. The stable string key only
@@ -226,7 +226,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
226
226
  }, [segments, litIndicesKey, step, startDeg, dashAngleDeg, totalDurationMs, oval.x, oval.y, oval.width, oval.height])
227
227
 
228
228
  const litPath = useDerivedValue(() => {
229
- const path = Skia.Path.Make()
229
+ const path = Skia.PathBuilder.Make()
230
230
  // The common case - a single-select trigger swapping from one tier to another - is just one
231
231
  // full-width arc sitting at angleDeg's current value, which useLayoutEffect above retargets
232
232
  // (never resets) on every change; this alone gives a real rotating handoff, including smoothly
@@ -234,7 +234,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
234
234
  // is done - before that, the sweep-width stagger below owns the reveal instead.
235
235
  if (mountProgress.value >= 1 && singleSelect) {
236
236
  path.addArc(oval, angleDeg.value - 90 - dashAngleDeg / 2, dashAngleDeg)
237
- return path
237
+ return path.detach()
238
238
  }
239
239
 
240
240
  for (let i = 0; i < segments; i++) {
@@ -261,7 +261,7 @@ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedCol
261
261
  const dashCenterDeg = startDeg + (i + 0.5) * step
262
262
  path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
263
263
  }
264
- return path
264
+ return path.detach()
265
265
  // litIndicesKey/transitionFromKey, not the arrays themselves - see unlitPath's identical comment.
266
266
  }, [segments, litIndicesKey, transitionFromKey, singleSelect, step, startDeg, dashAngleDeg, totalDurationMs, oval.x, oval.y, oval.width, oval.height])
267
267
 
@@ -13,10 +13,13 @@ export { BaseStatsScreen } from '../BaseStatsScreen'
13
13
  export { ConfirmDialog, type ConfirmDialogProps } from '../ConfirmDialog'
14
14
  export { ContentGutter } from '../ContentGutter'
15
15
  export { CornerActionButtons } from '../CornerActionButtons'
16
+ export { COMPACT_DIALOG_HEIGHT, DialogHeader, type DialogHeaderProps, isCompactDialogHeight } from '../DialogHeader'
16
17
  export { MONO_FONT } from '../fonts'
17
- export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from '../overlayCard'
18
+ export { overlayActionStyles, overlayStyles } from '../overlayCard'
19
+ export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from '../OverlaySurface'
18
20
  export { RotatedFrame, type RotatedFrameProps } from '../RotatedFrame'
19
21
  export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from '../StaggeredWord'
20
22
  export { StatRow } from '../StatRow'
21
23
  export { StatSection } from '../StatSection'
22
24
  export { UpdateDialog, type UpdateDialogProps } from '../UpdateDialog'
25
+ export { useCompactDialog } from '../useCompactDialog'
@@ -5,7 +5,9 @@ import { BackHandler, type NativeScrollEvent, type NativeSyntheticEvent, ScrollV
5
5
  import { Portal, Text } from 'react-native-paper'
6
6
  import { useReducedMotion } from 'react-native-reanimated'
7
7
 
8
- import { getOverlayCardColors, overlayActionStyles, overlayStyles } from '../overlayCard'
8
+ import { DialogHeader, isCompactDialogHeight } from '../DialogHeader'
9
+ import { overlayActionStyles } from '../overlayCard'
10
+ import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from '../OverlaySurface'
9
11
  import type { GuideRotation, GuideStep } from './types'
10
12
 
11
13
  // The overlay's own breathing room and the card's own inner padding. Together with the card's max
@@ -18,9 +20,11 @@ const MAX_CARD_WIDTH = 360
18
20
  // lines mean fewer of them, which is what actually has to fit (see sizing below).
19
21
  const MAX_CARD_WIDTH_TURNED = 520
20
22
  const ART_HEIGHT = 132
21
- // Below this much usable height the illustration box shrinks. Sized so the full card (padding, art,
22
- // title, two lines of body, dots, buttons) fits a 360pt-wide phone held sideways.
23
- const COMPACT_BELOW = 480
23
+ // On a short screen (isCompactDialogHeight, the rule every dialog's header follows) the illustration
24
+ // box shrinks. Before the dialog header, that fit the whole card (padding, art, title, two lines of
25
+ // body, dots, buttons) on a 360pt-wide phone held sideways; the compact header adds 48pt, so a page
26
+ // there now scrolls a little (see the pager below). The art can't shrink further: the apps' own art
27
+ // is drawn at fixed sizes (Solitaire's is 72-98pt tall). The page's own gaps tighten instead (pageCompact).
24
28
  const ART_HEIGHT_COMPACT = 88
25
29
 
26
30
  // Where the card has to fit. The fleet's rotation is FAKE: the OS window stays portrait and the card
@@ -33,11 +37,13 @@ export function getGuideCardSize(windowWidth: number, windowHeight: number, rota
33
37
  const availableWidth = (turned ? windowHeight : windowWidth) - OVERLAY_GUTTER * 2
34
38
  const availableHeight = (turned ? windowWidth : windowHeight) - OVERLAY_GUTTER * 2
35
39
  const width = Math.min(turned ? MAX_CARD_WIDTH_TURNED : MAX_CARD_WIDTH, availableWidth)
40
+ const compact = isCompactDialogHeight(turned ? windowWidth : windowHeight)
36
41
  return {
37
42
  width,
38
43
  pageWidth: width - CARD_PADDING * 2,
39
44
  maxHeight: availableHeight,
40
- artHeight: availableHeight < COMPACT_BELOW ? ART_HEIGHT_COMPACT : ART_HEIGHT
45
+ artHeight: compact ? ART_HEIGHT_COMPACT : ART_HEIGHT,
46
+ compact
41
47
  }
42
48
  }
43
49
 
@@ -52,11 +58,15 @@ export interface HowToPlayDialogProps {
52
58
  // The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated
53
59
  // ancestor, so the card rotates itself, and sizes itself for the turn (see getGuideCardSize).
54
60
  rotation?: GuideRotation
61
+ // The dialog's own header (DialogHeader), above the pages: the guide opens unprompted on a first
62
+ // launch, so it says what it is before the first card's own title (Jay, 2026-10-15).
63
+ title?: string
64
+ icon?: string
55
65
  }
56
66
 
57
67
  type CardProps = Omit<HowToPlayDialogProps, 'visible'>
58
68
 
59
- function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
69
+ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0, title = 'How to Play', icon = 'help-circle-outline' }: CardProps) {
60
70
  const { dark, colors } = useAutoPaperTheme()
61
71
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
62
72
  const reducedMotion = useReducedMotion()
@@ -73,8 +83,8 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
73
83
  return () => subscription.remove()
74
84
  }, [onSkip])
75
85
 
76
- const { fg, cardBg, cardBorder, fgMuted } = getOverlayCardColors(dark)
77
- const { width: cardWidth, pageWidth, maxHeight, artHeight } = getGuideCardSize(windowWidth, windowHeight, rotation)
86
+ const { fg, fgMuted } = useOverlayCardColors()
87
+ const { width: cardWidth, pageWidth, maxHeight, artHeight, compact } = getGuideCardSize(windowWidth, windowHeight, rotation)
78
88
  const rotationStyle = rotation === 0 ? undefined : { transform: [{ rotate: `${rotation}deg` }] }
79
89
  const isFirst = index === 0
80
90
  const isLast = index === steps.length - 1
@@ -119,14 +129,15 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
119
129
 
120
130
  return (
121
131
  <Portal>
122
- <View style={[overlayStyles.overlay, styles.overlay]}>
123
- <View accessibilityViewIsModal style={[overlayStyles.card, styles.card, { width: cardWidth, maxHeight, backgroundColor: cardBg, borderColor: cardBorder }, rotationStyle]}>
132
+ <OverlayBackdrop style={styles.overlay}>
133
+ <OverlayCard accessibilityViewIsModal style={[styles.card, { width: cardWidth, maxHeight }, rotationStyle]}>
134
+ <DialogHeader icon={icon} title={title} compact={compact} />
124
135
  {/* The pager shrinks (flexShrink) when the card hits maxHeight, and each page is its own
125
136
  vertical ScrollView, so a page that still doesn't fit (a long translation, large text)
126
137
  scrolls instead of being clipped. In the normal case nothing scrolls. */}
127
138
  <ScrollView ref={scrollRef} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={16} onMomentumScrollEnd={handleMomentumEnd} style={[styles.pager, { width: pageWidth }]}>
128
139
  {steps.map((step, i) => (
129
- <ScrollView key={i} nestedScrollEnabled showsVerticalScrollIndicator={false} style={{ width: pageWidth }} contentContainerStyle={styles.page}>
140
+ <ScrollView key={i} nestedScrollEnabled showsVerticalScrollIndicator={false} style={{ width: pageWidth }} contentContainerStyle={[styles.page, compact && styles.pageCompact]}>
130
141
  {step.art !== undefined && <View style={[styles.art, { height: artHeight, backgroundColor: artBackground }]}>{step.art}</View>}
131
142
  <Text variant='headlineSmall' style={[styles.title, { color: fg }]}>
132
143
  {step.title}
@@ -140,8 +151,10 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
140
151
 
141
152
  {steps.length > 1 && (
142
153
  <View accessible accessibilityLabel={`Step ${index + 1} of ${steps.length}`} style={styles.dots}>
154
+ {/* fgMuted is the full-strength onSurfaceVariant, so the inactive dots are dimmed to half or
155
+ they'd stand out more than the active primary one. */}
143
156
  {steps.map((_, i) => (
144
- <View key={i} style={[styles.dot, { backgroundColor: i === index ? colors.primary : fgMuted }]} />
157
+ <View key={i} style={i === index ? [styles.dot, { backgroundColor: colors.primary }] : [styles.dot, styles.dotInactive, { backgroundColor: fgMuted }]} />
145
158
  ))}
146
159
  </View>
147
160
  )}
@@ -161,14 +174,14 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
161
174
  {isLast ? 'Ready' : 'Next'}
162
175
  </Button>
163
176
  </View>
164
- </View>
165
- </View>
177
+ </OverlayCard>
178
+ </OverlayBackdrop>
166
179
  </Portal>
167
180
  )
168
181
  }
169
182
 
170
183
  // A short, skippable, paged "how to play" card - the one shared shape every fleet game's intro takes,
171
- // built on the same retro overlay-card look as ConfirmDialog (see overlayCard.ts) so it reads as
184
+ // built on the same overlay card as ConfirmDialog (see OverlaySurface.tsx) so it reads as
172
185
  // part of the same family. Controlled and presentational: whether it's visible, and what happens on
173
186
  // finish/skip (persisting "seen", mostly), is the caller's - see GuideProvider for the wiring nearly
174
187
  // every app wants. Buttons are the primary way through; the paging ScrollView underneath is what
@@ -193,8 +206,7 @@ const styles = StyleSheet.create({
193
206
  body: {
194
207
  textAlign: 'center'
195
208
  },
196
- // Additive on top of overlayCard's own shared `card` shape - its default 32px padding is tighter
197
- // here (a paged card wants more width for its art) and the shared 16px gap is right.
209
+ // overlayCard's shared `card` padding, restated: the page width above is computed from it.
198
210
  card: {
199
211
  padding: CARD_PADDING
200
212
  },
@@ -203,6 +215,9 @@ const styles = StyleSheet.create({
203
215
  height: 8,
204
216
  width: 8
205
217
  },
218
+ dotInactive: {
219
+ opacity: 0.5
220
+ },
206
221
  dots: {
207
222
  flexDirection: 'row',
208
223
  gap: 8
@@ -214,6 +229,11 @@ const styles = StyleSheet.create({
214
229
  alignItems: 'center',
215
230
  gap: 12
216
231
  },
232
+ // The 8pt this saves is what the compact dialog header cost a page on a 402pt-tall window (a
233
+ // Pro-size iPhone held sideways, measured on web): without it the body's second line scrolled.
234
+ pageCompact: {
235
+ gap: 8
236
+ },
217
237
  pager: {
218
238
  flexShrink: 1
219
239
  },
package/src/index.ts CHANGED
@@ -9,11 +9,13 @@ export { ContentGutter } from './ContentGutter'
9
9
  export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSchemePicker'
10
10
  export { CornerActionButtons } from './CornerActionButtons'
11
11
  export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
12
+ export { COMPACT_DIALOG_HEIGHT, DialogHeader, type DialogHeaderProps, isCompactDialogHeight } from './DialogHeader'
12
13
  export { MONO_FONT } from './fonts'
13
14
  export { getInlineColorPickerContentSize, InlineColorPicker } from './InlineColorPicker'
14
15
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
15
16
  export { loadSkiaWeb } from './loadSkiaWeb'
16
- export { getOverlayCardColors, overlayActionStyles, type OverlayCardColors, overlayStyles } from './overlayCard'
17
+ export { overlayActionStyles, overlayStyles } from './overlayCard'
18
+ export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from './OverlaySurface'
17
19
  export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
18
20
  export { PopoverBody } from './PopoverBody'
19
21
  export { PressAwayOverlay } from './PressAwayOverlay'
@@ -36,6 +38,7 @@ export type { TriggerGaugeProps } from './TriggerGauge'
36
38
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
37
39
  export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
38
40
  export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
41
+ export { useCompactDialog } from './useCompactDialog'
39
42
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
40
43
  export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
41
44
  export { useZoneClampedAlign } from './useZoneClampedAlign'
@@ -1,39 +1,21 @@
1
1
  import { StyleSheet } from 'react-native'
2
2
 
3
- export interface OverlayCardColors {
4
- fg: string
5
- fgMuted: string
6
- cardBg: string
7
- cardBorder: string
8
- }
9
-
10
- // The literal-black/white-by-appearance "retro" formula this package's own BaseSettingsDialog and
11
- // ConfirmDialog each independently re-derived - deliberately NOT auto-paper's own (seed-color-tinted)
12
- // background role, see either component's own "High-contrast retro look" comment for why. Every
13
- // fleet app's own Game-Over/Round-Over/Match-Over/Quit-confirmation overlay re-derives this same
14
- // three-or-four-line formula again, which is what this export exists to replace.
15
- export function getOverlayCardColors(dark: boolean): OverlayCardColors {
16
- return {
17
- fg: dark ? '#FFFFFF' : '#000000',
18
- fgMuted: dark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
19
- cardBg: dark ? '#111111' : '#F2F2F2',
20
- cardBorder: dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
21
- }
22
- }
23
-
24
- // Only the shell shape every overlay card shares (backdrop + card geometry) - deliberately not the
25
- // 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.
3
+ // Only the shell shape every overlay card shares (backdrop + card geometry). The title is
4
+ // DialogHeader's; the body still legitimately varies by consumer (each app's own score/badge/zone
5
+ // content). Cards should use OverlayBackdrop and OverlayCard (OverlaySurface.tsx), which apply
6
+ // `overlay` and `card` underneath and set the theme's backdrop color and corner radius themselves
7
+ // (the backdrop replaces `overlay`'s rgba(0,0,0,0.72)). The one button row every overlay card with
8
+ // actions shares lives in overlayActionStyles below.
9
+ // No border and 24pt of padding, like the Settings sheet (auto-paper's Dialog, 24pt insets on its
10
+ // title and scroll area), so the two families match (Jay, 2026-10-15). borderRadius is that Dialog's
11
+ // at MD3's default roundness; OverlayCard derives it from the live theme.
29
12
  export const overlayStyles = StyleSheet.create({
30
13
  card: {
31
14
  alignItems: 'center',
32
- borderRadius: 20,
33
- borderWidth: 1,
15
+ borderRadius: 28,
34
16
  gap: 16,
35
17
  maxWidth: 360,
36
- padding: 32
18
+ padding: 24
37
19
  },
38
20
  overlay: {
39
21
  alignItems: 'center',
@@ -0,0 +1,23 @@
1
+ import { useRotatedWindowDimensions, type ViewRotation } from '@tastic/core'
2
+ import { useCallback, useState } from 'react'
3
+ import { type LayoutChangeEvent, useWindowDimensions } from 'react-native'
4
+
5
+ import { isCompactDialogHeight } from './DialogHeader'
6
+
7
+ // Whether a dialog should use the compact header, and the onLayout to put on its OverlayBackdrop. The
8
+ // backdrop fills whatever the dialog lays out in, so its measured size is the real answer: a turned
9
+ // game frame, an upright root, either one with a card that turns itself. Before that first layout it
10
+ // guesses: a dialog that turns itself (rotation ±90 or 180) sits at the root, in the upright window; one
11
+ // that doesn't sits in a frame the app has already turned by the live rotation.
12
+ export function useCompactDialog(rotation: ViewRotation = 0): { compact: boolean; onLayout: (e: LayoutChangeEvent) => void } {
13
+ const window = useWindowDimensions()
14
+ const framed = useRotatedWindowDimensions()
15
+ const [measured, setMeasured] = useState<{ width: number; height: number } | null>(null)
16
+ const onLayout = useCallback((e: LayoutChangeEvent) => {
17
+ const { width, height } = e.nativeEvent.layout
18
+ setMeasured((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height }))
19
+ }, [])
20
+ const box = measured ?? (rotation === 0 ? framed : window)
21
+ const turned = rotation === 90 || rotation === -90
22
+ return { compact: isCompactDialogHeight(turned ? box.width : box.height), onLayout }
23
+ }