@tastic/hud 0.17.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.
@@ -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
+ })
@@ -1,6 +1,6 @@
1
1
  import { BlurView, useAutoPaperTheme, useBlur } from '@rific/auto-paper'
2
2
  import { type ReactNode } from 'react'
3
- import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
3
+ import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
4
4
 
5
5
  import { overlayStyles } from './overlayCard'
6
6
 
@@ -12,8 +12,7 @@ export interface OverlayCardThemeColors {
12
12
  }
13
13
 
14
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" formula that
16
- // getOverlayCardColors still returns for apps that haven't moved over yet).
15
+ // the cards follow each game's palette instead of the old literal black/white "retro" card).
17
16
  export function useOverlayCardColors(): OverlayCardThemeColors {
18
17
  const { colors } = useAutoPaperTheme()
19
18
  return { fg: colors.onSurface, fgMuted: colors.onSurfaceVariant, cardBorder: colors.outlineVariant, backdrop: colors.backdrop }
@@ -22,13 +21,19 @@ export function useOverlayCardColors(): OverlayCardThemeColors {
22
21
  export interface OverlayBackdropProps {
23
22
  children?: ReactNode
24
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
25
26
  }
26
27
 
27
28
  // The full-screen dim behind a card: overlayStyles.overlay's geometry in the theme's own backdrop
28
29
  // color (the same scrim auto-paper's Dialog uses), lighter than the old solid 72% black.
29
- export function OverlayBackdrop({ children, style }: OverlayBackdropProps) {
30
+ export function OverlayBackdrop({ children, style, onLayout }: OverlayBackdropProps) {
30
31
  const { backdrop } = useOverlayCardColors()
31
- return <View style={[overlayStyles.overlay, { backgroundColor: backdrop }, style]}>{children}</View>
32
+ return (
33
+ <View onLayout={onLayout} style={[overlayStyles.overlay, { backgroundColor: backdrop }, style]}>
34
+ {children}
35
+ </View>
36
+ )
32
37
  }
33
38
 
34
39
  export interface OverlayCardProps {
@@ -38,20 +43,23 @@ export interface OverlayCardProps {
38
43
  testID?: string
39
44
  }
40
45
 
41
- // The card itself, on auto-paper's BlurView at elevation 3 (MD3's dialog surface: the theme's surface
42
- // with a light wash of its primary). It blurs whatever is behind it when the player's Blur setting is
43
- // on and the app has handed expo-blur to auto-paper's Provider, and otherwise falls back to that
44
- // raised surface color on its own: on Android (no blur target here), in an app without expo-blur, or
45
- // with Blur off. So every game gets the same card, and beyond passing expoBlur to auto-paper's
46
- // Provider, it wires nothing for the blur.
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.
47
55
  // BlurView itself only follows its `blur` prop (on by default), so the setting is read here with
48
56
  // useBlur(), the same way auto-paper's own Dialog and Menu read it.
49
57
  // overflow: hidden keeps a native blur inside the card's rounded corners.
50
58
  export function OverlayCard({ children, style, ...rest }: OverlayCardProps) {
51
- const { cardBorder } = useOverlayCardColors()
59
+ const { isV3, roundness } = useAutoPaperTheme()
52
60
  const blur = useBlur()
53
61
  return (
54
- <BlurView blur={blur} elevation={3} style={[overlayStyles.card, styles.card, { borderColor: cardBorder }, style]} {...rest}>
62
+ <BlurView blur={blur} style={[overlayStyles.card, styles.card, { borderRadius: (isV3 ? 7 : 1) * roundness }, style]} {...rest}>
55
63
  {children}
56
64
  </BlurView>
57
65
  )
@@ -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,11 +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'
18
19
  export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from '../OverlaySurface'
19
20
  export { RotatedFrame, type RotatedFrameProps } from '../RotatedFrame'
20
21
  export { getStaggeredWordDuration, StaggeredWord, type StaggeredWordProps } from '../StaggeredWord'
21
22
  export { StatRow } from '../StatRow'
22
23
  export { StatSection } from '../StatSection'
23
24
  export { UpdateDialog, type UpdateDialogProps } from '../UpdateDialog'
25
+ export { useCompactDialog } from '../useCompactDialog'
@@ -5,6 +5,7 @@ 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 { DialogHeader, isCompactDialogHeight } from '../DialogHeader'
8
9
  import { overlayActionStyles } from '../overlayCard'
9
10
  import { OverlayBackdrop, OverlayCard, useOverlayCardColors } from '../OverlaySurface'
10
11
  import type { GuideRotation, GuideStep } from './types'
@@ -19,9 +20,11 @@ const MAX_CARD_WIDTH = 360
19
20
  // lines mean fewer of them, which is what actually has to fit (see sizing below).
20
21
  const MAX_CARD_WIDTH_TURNED = 520
21
22
  const ART_HEIGHT = 132
22
- // Below this much usable height the illustration box shrinks. Sized so the full card (padding, art,
23
- // title, two lines of body, dots, buttons) fits a 360pt-wide phone held sideways.
24
- 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).
25
28
  const ART_HEIGHT_COMPACT = 88
26
29
 
27
30
  // Where the card has to fit. The fleet's rotation is FAKE: the OS window stays portrait and the card
@@ -34,11 +37,13 @@ export function getGuideCardSize(windowWidth: number, windowHeight: number, rota
34
37
  const availableWidth = (turned ? windowHeight : windowWidth) - OVERLAY_GUTTER * 2
35
38
  const availableHeight = (turned ? windowWidth : windowHeight) - OVERLAY_GUTTER * 2
36
39
  const width = Math.min(turned ? MAX_CARD_WIDTH_TURNED : MAX_CARD_WIDTH, availableWidth)
40
+ const compact = isCompactDialogHeight(turned ? windowWidth : windowHeight)
37
41
  return {
38
42
  width,
39
43
  pageWidth: width - CARD_PADDING * 2,
40
44
  maxHeight: availableHeight,
41
- artHeight: availableHeight < COMPACT_BELOW ? ART_HEIGHT_COMPACT : ART_HEIGHT
45
+ artHeight: compact ? ART_HEIGHT_COMPACT : ART_HEIGHT,
46
+ compact
42
47
  }
43
48
  }
44
49
 
@@ -53,11 +58,15 @@ export interface HowToPlayDialogProps {
53
58
  // The app's live fake-landscape rotation (see GuideRotation). Portal content escapes any rotated
54
59
  // ancestor, so the card rotates itself, and sizes itself for the turn (see getGuideCardSize).
55
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
56
65
  }
57
66
 
58
67
  type CardProps = Omit<HowToPlayDialogProps, 'visible'>
59
68
 
60
- 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) {
61
70
  const { dark, colors } = useAutoPaperTheme()
62
71
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
63
72
  const reducedMotion = useReducedMotion()
@@ -75,7 +84,7 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
75
84
  }, [onSkip])
76
85
 
77
86
  const { fg, fgMuted } = useOverlayCardColors()
78
- const { width: cardWidth, pageWidth, maxHeight, artHeight } = getGuideCardSize(windowWidth, windowHeight, rotation)
87
+ const { width: cardWidth, pageWidth, maxHeight, artHeight, compact } = getGuideCardSize(windowWidth, windowHeight, rotation)
79
88
  const rotationStyle = rotation === 0 ? undefined : { transform: [{ rotate: `${rotation}deg` }] }
80
89
  const isFirst = index === 0
81
90
  const isLast = index === steps.length - 1
@@ -122,12 +131,13 @@ function HowToPlayCard({ steps, onFinish, onSkip, rotation = 0 }: CardProps) {
122
131
  <Portal>
123
132
  <OverlayBackdrop style={styles.overlay}>
124
133
  <OverlayCard accessibilityViewIsModal style={[styles.card, { width: cardWidth, maxHeight }, rotationStyle]}>
134
+ <DialogHeader icon={icon} title={title} compact={compact} />
125
135
  {/* The pager shrinks (flexShrink) when the card hits maxHeight, and each page is its own
126
136
  vertical ScrollView, so a page that still doesn't fit (a long translation, large text)
127
137
  scrolls instead of being clipped. In the normal case nothing scrolls. */}
128
138
  <ScrollView ref={scrollRef} horizontal pagingEnabled showsHorizontalScrollIndicator={false} onScroll={handleScroll} scrollEventThrottle={16} onMomentumScrollEnd={handleMomentumEnd} style={[styles.pager, { width: pageWidth }]}>
129
139
  {steps.map((step, i) => (
130
- <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]}>
131
141
  {step.art !== undefined && <View style={[styles.art, { height: artHeight, backgroundColor: artBackground }]}>{step.art}</View>}
132
142
  <Text variant='headlineSmall' style={[styles.title, { color: fg }]}>
133
143
  {step.title}
@@ -196,8 +206,7 @@ const styles = StyleSheet.create({
196
206
  body: {
197
207
  textAlign: 'center'
198
208
  },
199
- // Additive on top of overlayCard's own shared `card` shape - its default 32px padding is tighter
200
- // 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.
201
210
  card: {
202
211
  padding: CARD_PADDING
203
212
  },
@@ -220,6 +229,11 @@ const styles = StyleSheet.create({
220
229
  alignItems: 'center',
221
230
  gap: 12
222
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
+ },
223
237
  pager: {
224
238
  flexShrink: 1
225
239
  },
package/src/index.ts CHANGED
@@ -9,11 +9,12 @@ 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'
17
18
  export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from './OverlaySurface'
18
19
  export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
19
20
  export { PopoverBody } from './PopoverBody'
@@ -37,6 +38,7 @@ export type { TriggerGaugeProps } from './TriggerGauge'
37
38
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
38
39
  export { UpdateDialog, type UpdateDialogProps } from './UpdateDialog'
39
40
  export { type AlignResult, type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
41
+ export { useCompactDialog } from './useCompactDialog'
40
42
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
41
43
  export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
42
44
  export { useZoneClampedAlign } from './useZoneClampedAlign'
@@ -1,44 +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 old literal black/white "retro" card, the formula the fleet apps' own Game-Over/Round-Over/
11
- // Match-Over overlays each re-derived and that this export was made to replace. hud's own cards no
12
- // longer use it.
13
- /**
14
- * @deprecated The literal black/white card colors. hud's own cards moved to the theme-driven, blur-aware
15
- * OverlayCard / OverlayBackdrop / useOverlayCardColors (OverlaySurface.tsx) on 2026-10-15; this stays
16
- * only until every app's own result cards have moved over too.
17
- */
18
- export function getOverlayCardColors(dark: boolean): OverlayCardColors {
19
- return {
20
- fg: dark ? '#FFFFFF' : '#000000',
21
- fgMuted: dark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
22
- cardBg: dark ? '#111111' : '#F2F2F2',
23
- cardBorder: dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
24
- }
25
- }
26
-
27
- // Only the shell shape every overlay card shares (backdrop + card geometry) - deliberately not the
28
- // title/body styling, which already legitimately varies by consumer (each app's own score/badge/zone
29
- // content). New cards should use OverlayBackdrop and OverlayCard (OverlaySurface.tsx), which apply
30
- // `overlay` and `card` underneath and set the theme's backdrop and border colors themselves (the
31
- // backdrop replaces `overlay`'s rgba(0,0,0,0.72)). Only an app still on the deprecated
32
- // getOverlayCardColors puts its cardBg and cardBorder on `card` by hand. The one button row every
33
- // 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.
34
12
  export const overlayStyles = StyleSheet.create({
35
13
  card: {
36
14
  alignItems: 'center',
37
- borderRadius: 20,
38
- borderWidth: 1,
15
+ borderRadius: 28,
39
16
  gap: 16,
40
17
  maxWidth: 360,
41
- padding: 32
18
+ padding: 24
42
19
  },
43
20
  overlay: {
44
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
+ }