@tastic/hud 0.7.0 → 0.8.2

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.
@@ -1,8 +1,11 @@
1
1
  import { useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, IconButton } from '@rific/feedback-press'
3
+ import { useRotation } from '@tastic/core'
3
4
  import { ReactNode, useState } from 'react'
4
5
  import { ScrollView, StyleSheet, View } from 'react-native'
5
- import { Icon, Portal, Text } from 'react-native-paper'
6
+ import { Text } from 'react-native-paper'
7
+
8
+ import { ConfirmDialog } from './ConfirmDialog'
6
9
 
7
10
  // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
8
11
  // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point) just
@@ -10,12 +13,13 @@ import { Icon, Portal, Text } from 'react-native-paper'
10
13
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
11
14
 
12
15
  interface Props {
13
- // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this dialog renders
14
- // as a centered Portal modal, unaffected by whichever screen's own FakeLandscapeView wraps its
15
- // trigger, so it has to rotate its own card content to read right-side-up for whichever way the
16
- // phone is actually being held. Defaults to 0 for a caller that doesn't track one — matches
17
- // BaseSettingsDialog's own identical prop, though the first apps consuming this screen shell
18
- // don't wrap it in a FakeLandscapeView at all and so never have a nonzero one to give.
16
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
17
+ // affects this component's own Portal-rendered reset-confirm dialog (via ConfirmDialog's own
18
+ // rotation prop), which (like any Portal content) is unaffected by whichever screen's own
19
+ // FakeLandscapeView wraps the trigger and so has to rotate itself. Defaults to a live ambient
20
+ // read via useRotation() when omitted — note this does NOT make the header/scrollable content
21
+ // below rotate; the caller's own screen still needs its own FakeLandscapeView wrap for that
22
+ // (this component has no transform on its own main body).
19
23
  rotation?: number
20
24
  title?: string
21
25
  // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
@@ -35,54 +39,50 @@ interface Props {
35
39
  resetLabel?: string
36
40
  resetConfirmTitle?: string
37
41
  resetConfirmBody?: string
38
- // Independently optional overrides for this screen's own background/text colors — each defaults
39
- // to the literal-black/white-by-appearance formula described below when omitted, so every
40
- // existing caller (none of which pass these) renders identically to before. A caller with its own
41
- // app-wide chrome palette (e.g. a tinted background rather than neutral black/white) passes
42
- // whichever of the three it needs; cardBorder on the reset-confirm overlay stays fixed regardless,
43
- // since its low alpha already reads fine against any cardBg.
42
+ // Independently optional overrides for this screen's own header/background colors — each
43
+ // defaults to the literal-black/white-by-appearance formula described below when omitted, so
44
+ // every existing caller (none of which pass these) renders identically to before. A caller with
45
+ // its own app-wide chrome palette (e.g. a tinted background rather than neutral black/white)
46
+ // passes whichever of the two it needs.
47
+ //
48
+ // There used to be a third override here, cardBg, plus a separate accentColor override for the
49
+ // reset-confirm dialog's own icon/title/card. Both are gone: the reset-confirm step used to be a
50
+ // hand-rolled lookalike of ConfirmDialog with its own copy of the overlay/card/button styling,
51
+ // which is exactly how it drifted from ConfirmDialog's real look in the first place (different
52
+ // button layout, different Cancel styling, and — via cardBg — a different, caller-tintable card
53
+ // background no other ConfirmDialog in the fleet has). It now renders a real ConfirmDialog
54
+ // instance below instead of its own copy, which by construction can never drift from every other
55
+ // confirm prompt in the app again — at the cost of losing that per-caller tinting, which is the
56
+ // right trade: a "you're about to erase everything" prompt should look and read identically
57
+ // everywhere, not take on whichever screen happened to trigger it.
44
58
  fg?: string
45
59
  bg?: string
46
- cardBg?: string
47
- // Overrides the confirm-overlay's alert icon/title, which otherwise default to auto-paper's
48
- // colors.secondary. That default is tuned to read against auto-paper's OWN background role, not
49
- // necessarily against a cardBg override above — a caller passing cardBg should generally pass a
50
- // matching accentColor too, rather than risk colors.secondary landing close in hue/lightness to
51
- // its own custom card background (this is what actually happened with a tinted felt cardBg in the
52
- // app this was first overridden for). Every button on this screen (the reset trigger included)
53
- // fills with colors.secondary/onSecondary as a self-contained, always-legible pair instead of
54
- // relying on a text-only color read against the surrounding chrome, so none of them need this —
55
- // only the bare icon/title do.
56
- accentColor?: string
57
60
  children: ReactNode
58
61
  }
59
62
 
60
63
  // The stats/achievements screen shell every @tastic game shares — back button + title header,
61
64
  // scrollable content area, and an optional reset-everything action with its own confirm-before-
62
- // destroying overlay — extracted after the same header/scroll/reset-confirm shape was independently
65
+ // destroying dialog — extracted after the same header/scroll/reset-confirm shape was independently
63
66
  // built into LightCycles' own achievements.tsx. Colors default to the same literal-black/white-by-
64
67
  // appearance formula BaseSettingsDialog hardcodes internally (same as this package's own
65
- // BaseSettingsDialog) — zero-config for every existing caller — but fg/bg/cardBg are each
66
- // independently overridable for a caller whose own app-wide chrome palette isn't that literal
67
- // black/white convention. `children` is the seam for whatever isn't shared: this component has no
68
- // opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
69
- // AchievementRow for the smaller presentational pieces built to go inside it.
70
- export function BaseStatsScreen({ rotation = 0, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, cardBg: cardBgOverride, accentColor: accentColorOverride, children }: Props) {
68
+ // BaseSettingsDialog) — zero-config for every existing caller — but fg/bg are each independently
69
+ // overridable for a caller whose own app-wide chrome palette isn't that literal black/white
70
+ // convention. `children` is the seam for whatever isn't shared: this component has no opinion on
71
+ // what a "stat" is or how achievements are catalogued — see StatRow/StatSection/AchievementRow for
72
+ // the smaller presentational pieces built to go inside it.
73
+ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, children }: Props) {
71
74
  const { dark, colors } = useAutoPaperTheme()
75
+ const ambientRotation = useRotation()
76
+ const rotation = rotationOverride ?? ambientRotation
72
77
  const [confirmResetVisible, setConfirmResetVisible] = useState(false)
73
78
  const showReset = !!onReset
74
79
 
75
80
  // High-contrast retro look by default: literal black/white by appearance, matching every other
76
81
  // screen in this ecosystem (title/loadout/settings all use this identical formula) rather than
77
- // auto-paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
78
- // fg/cardBg/cardBorder use internally. Overridable per-instance via the props above for a caller
79
- // whose own chrome isn't that literal black/white convention; cardBorder has no override (see the
80
- // Props doc above for why).
82
+ // auto-paper's own (slightly tinted) background role. Overridable per-instance via the props
83
+ // above for a caller whose own chrome isn't that literal black/white convention.
81
84
  const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
82
85
  const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
83
- const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
84
- const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
85
- const accentColor = accentColorOverride ?? colors.secondary
86
86
 
87
87
  return (
88
88
  <View style={[styles.container, { backgroundColor: bg }]}>
@@ -97,42 +97,27 @@ export function BaseStatsScreen({ rotation = 0, title = 'Achievements', titleVar
97
97
  {children}
98
98
 
99
99
  {showReset && (
100
- <Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.secondary} textColor={colors.onSecondary} style={styles.resetButton}>
100
+ <Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.danger} textColor={colors.onDanger} style={styles.resetButton}>
101
101
  {resetLabel}
102
102
  </Button>
103
103
  )}
104
104
  </ScrollView>
105
105
 
106
- {confirmResetVisible && (
107
- <Portal>
108
- <View style={[styles.overlay, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
109
- <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
110
- <Icon source='alert-outline' size={64} color={accentColor} />
111
- <Text variant='headlineLarge' style={[styles.overlayTitle, { color: accentColor }]}>
112
- {resetConfirmTitle}
113
- </Text>
114
- <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
115
- {resetConfirmBody}
116
- </Text>
117
- <Button mode='contained' onPress={() => setConfirmResetVisible(false)} style={styles.overlayButton}>
118
- Cancel
119
- </Button>
120
- <Button
121
- mode='contained'
122
- onPress={() => {
123
- onReset?.()
124
- setConfirmResetVisible(false)
125
- }}
126
- style={styles.overlayButton}
127
- buttonColor={colors.secondary}
128
- textColor={colors.onSecondary}
129
- >
130
- Reset
131
- </Button>
132
- </View>
133
- </View>
134
- </Portal>
135
- )}
106
+ <ConfirmDialog
107
+ visible={confirmResetVisible}
108
+ title={resetConfirmTitle}
109
+ message={resetConfirmBody}
110
+ confirmLabel='Reset'
111
+ cancelLabel='Cancel'
112
+ icon='alert-outline'
113
+ destructive
114
+ rotation={rotation}
115
+ onCancel={() => setConfirmResetVisible(false)}
116
+ onConfirm={() => {
117
+ onReset?.()
118
+ setConfirmResetVisible(false)
119
+ }}
120
+ />
136
121
  </View>
137
122
  )
138
123
  }
@@ -151,30 +136,6 @@ const styles = StyleSheet.create({
151
136
  gap: 4,
152
137
  paddingBottom: 8
153
138
  },
154
- // Same overlay shape BaseSettingsDialog's own info overlay uses (backdrop + centered card),
155
- // rotated in place the same way rather than via Portal-root transform for the same reason — see
156
- // that component's own identical styles for the full rationale.
157
- overlay: {
158
- alignItems: 'center',
159
- backgroundColor: 'rgba(0,0,0,0.72)',
160
- bottom: 0,
161
- justifyContent: 'center',
162
- left: 0,
163
- position: 'absolute',
164
- right: 0,
165
- top: 0
166
- },
167
- overlayBody: { textAlign: 'center' },
168
- overlayButton: { width: 160 },
169
- overlayCard: {
170
- alignItems: 'center',
171
- borderRadius: 20,
172
- borderWidth: 1,
173
- gap: 16,
174
- maxWidth: 360,
175
- padding: 32
176
- },
177
- overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
178
139
  resetButton: {
179
140
  marginTop: 16
180
141
  },
@@ -69,11 +69,24 @@ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLab
69
69
  }
70
70
 
71
71
  const styles = StyleSheet.create({
72
+ // flexBasis (not flex: 1's own 0%) is each button's real floor: whenever both fit side by side,
73
+ // flexGrow still splits any leftover width evenly beyond that floor, same visual result as the
74
+ // old flex: 1 on a roomy screen - but a button can never be squeezed narrower than 128, only
75
+ // wrap to its own row instead (see actions' flexWrap below). react-native-paper's Button
76
+ // hardcodes numberOfLines={1} on its label with no override, so a button squeezed under its
77
+ // label's natural width silently ellipsizes ("Cancel" -> "Can...") instead of wrapping - assumed
78
+ // fixable by just giving the row more width, until confirmed live that these two-word labels
79
+ // (Cancel/Reset/New Game) can still lose the truncation fight even with the extra room card's own
80
+ // width fix below provides, on a narrow enough screen. flexBasis is the real fix: past a certain
81
+ // width there's no side-by-side arrangement that fits both buttons at their natural label size,
82
+ // so the layout has to give (stack) rather than the label (truncate).
72
83
  actionButton: {
73
- flex: 1
84
+ flexBasis: 128,
85
+ flexGrow: 1
74
86
  },
75
87
  actions: {
76
88
  flexDirection: 'row',
89
+ flexWrap: 'wrap',
77
90
  gap: 12,
78
91
  width: '100%'
79
92
  },
@@ -86,7 +99,12 @@ const styles = StyleSheet.create({
86
99
  borderWidth: 1,
87
100
  gap: 16,
88
101
  maxWidth: 360,
89
- padding: 32
102
+ padding: 32,
103
+ // Claims its full allowance instead of just maxWidth alone, which only caps how wide the card
104
+ // is allowed to get - without an explicit width, a flex-centered child sizes to its own content
105
+ // instead of stretching, so the card (and therefore actions below) can end up narrower than the
106
+ // screen actually has room for, needlessly forcing the action row to wrap sooner than it has to.
107
+ width: '100%'
90
108
  },
91
109
  overlay: {
92
110
  alignItems: 'center',
@@ -94,11 +112,19 @@ const styles = StyleSheet.create({
94
112
  bottom: 0,
95
113
  justifyContent: 'center',
96
114
  left: 0,
115
+ // Gives the card room to breathe from the screen edges now that it reliably stretches to fill
116
+ // its full width allowance (see card's own width comment) - without this, width: '100%' would
117
+ // run the card edge-to-edge on a screen narrower than maxWidth's own 360.
118
+ paddingHorizontal: 24,
97
119
  position: 'absolute',
98
120
  right: 0,
99
121
  top: 0
100
122
  },
123
+ // textAlign is load-bearing, not decorative, once `title` wraps to more than one line - centered
124
+ // within card's own alignItems: 'center' only centers the Text's block as a whole; each wrapped
125
+ // line inside that block still defaults to left-aligned without this.
101
126
  title: {
102
- fontWeight: 'bold'
127
+ fontWeight: 'bold',
128
+ textAlign: 'center'
103
129
  }
104
130
  })
@@ -1,12 +1,12 @@
1
1
  import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { clamp } from '@tastic/core'
3
+ import { clamp, useRotation } from '@tastic/core'
4
4
  import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
5
5
  import { Icon, Text } from 'react-native-paper'
6
6
 
7
7
  import { MONO_FONT } from './fonts'
8
8
  import { PopoverBody } from './PopoverBody'
9
- import { useAutoAlign } from './useAutoAlign'
9
+ import { 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
@@ -62,6 +62,10 @@ interface Props {
62
62
  // Manual override — omit to let the popover measure its own trigger and pick whichever alignment
63
63
  // keeps it from overflowing the screen edge (see useAutoAlign).
64
64
  align?: 'left' | 'right' | 'center'
65
+ // Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
66
+ // equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
67
+ // full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
68
+ rotation?: PopoverRotation
65
69
  // Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
66
70
  // human, a robot for CPU) to convey identity through the icon itself. Overridden by `tag` below
67
71
  // when present.
@@ -89,7 +93,9 @@ interface Props {
89
93
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
90
94
  // picker would block the whole screen for one player while another can't touch their own panel at
91
95
  // the same time, which defeats the point of a split-screen lobby.
92
- export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
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) {
97
+ const ambientRotation = useRotation()
98
+ const rotation = rotationOverride ?? ambientRotation
93
99
  const menuBg = dark ? '#000000' : '#FFFFFF'
94
100
  const { width: windowWidth } = useWindowDimensions()
95
101
  const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
@@ -99,7 +105,7 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
99
105
  const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
100
106
 
101
107
  const open = host.openId === id
102
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
108
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
103
109
  const align = alignOverride ?? autoAlign
104
110
  const displayFor = previewValue ?? ((hex: string) => hex)
105
111
  const displayValue = displayFor(value)
@@ -1,12 +1,13 @@
1
1
  import { getBlendedColor, getColorRoles } from '@rific/auto-paper'
2
2
  import { IconButton, TouchableRipple } from '@rific/feedback-press'
3
+ import { useRotation } from '@tastic/core'
3
4
  import { ScrollView, StyleSheet, TextStyle, View } from 'react-native'
4
5
  import { Icon, Text } from 'react-native-paper'
5
6
 
6
7
  import { MONO_FONT } from './fonts'
7
8
  import { PopoverBody } from './PopoverBody'
8
9
  import TriggerGaugeHost from './TriggerGaugeHost'
9
- import { useAutoAlign } from './useAutoAlign'
10
+ import { PopoverRotation, useAutoAlign } from './useAutoAlign'
10
11
  import { PopoverHost } from './usePopoverHost'
11
12
 
12
13
  const MENU_BORDER_WIDTH = 1
@@ -99,6 +100,17 @@ interface Props {
99
100
  // keeps it from overflowing the screen edge (see useAutoAlign). Only pass this to force a specific
100
101
  // side regardless of where the trigger actually sits.
101
102
  align?: 'left' | 'right' | 'center'
103
+ // Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
104
+ // ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
105
+ // orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
106
+ // useRotation() when omitted, so most callers never need to pass this at all. Pass it explicitly
107
+ // only when a caller's own reading needs to differ from the ambient one (e.g. a fading dual-zone
108
+ // layout whose panel content deliberately lags the live reading behind a transition). Correctness
109
+ // matters here regardless of source: measureInWindow reports the trigger's PRE-rotation layout
110
+ // position (see useAutoAlign's own doc comment for why, and why it can't recover this any other
111
+ // way), so a wrong rotation value picks an alignment for where the trigger *used to be* before
112
+ // rotating, clipping off the edge it's actually now near instead of the one it was near.
113
+ rotation?: PopoverRotation
102
114
  // Whether picking a value in a *single-select* section closes the popover. Defaults to true
103
115
  // (the old, pre-press-away default: pick one thing, you're done). Multi-select rows never
104
116
  // auto-close regardless of this — see MultiSelectSection's own comment. Worth setting false for a
@@ -116,7 +128,9 @@ interface Props {
116
128
  // powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
117
129
  // combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
118
130
  // array.
119
- export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
131
+ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
132
+ const ambientRotation = useRotation()
133
+ const rotation = rotationOverride ?? ambientRotation
120
134
  const menuBg = dark ? '#000000' : '#FFFFFF'
121
135
  // mutedColor is translucent (fine for the icon/text tints below, each a single paint), but the
122
136
  // caret is deliberately drawn overlapping the box's own top border (PopoverBody's CARET_DIP,
@@ -142,7 +156,7 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
142
156
  const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
143
157
  return sum + rows + footer + divider
144
158
  }, 0)
145
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight)
159
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
146
160
  const align = alignOverride ?? autoAlign
147
161
 
148
162
  // Trigger reflects the selected option's own icon only when there's exactly one section and it's
@@ -0,0 +1,45 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import { useEffect, useState } from 'react'
3
+
4
+ export interface SkiaGateProps<P extends object> {
5
+ // A dynamic import, e.g. `() => import('./Board')` — hoist to module scope at the call site
6
+ // (never an inline closure written directly in JSX) so its identity stays stable across
7
+ // renders; it's this component's own useEffect dependency below.
8
+ getComponent: () => Promise<{ default: ComponentType<P> }>
9
+ componentProps: P
10
+ // Rendered while getComponent()'s promise is still pending. Defaults to null.
11
+ fallback?: ReactNode
12
+ }
13
+
14
+ // Native Skia renders through JSI — no WASM/CanvasKit fetch to wait on — so this file just
15
+ // lazily mounts whatever getComponent() resolves to, once it resolves. The .web.tsx sibling
16
+ // (picked by Metro/webpack's own platform-extension resolution for web bundles) is a different
17
+ // implementation entirely: a direct re-export of @shopify/react-native-skia's own WithSkiaWeb,
18
+ // which gates the same mount behind LoadSkiaWeb() first — CanvasKit's WASM build has to actually
19
+ // finish loading before anything that imports '@shopify/react-native-skia' can evaluate its own
20
+ // top-level `Skia` binding without crashing (see loadSkiaWeb.web.ts's own comment).
21
+ //
22
+ // No React.lazy()/Suspense here, deliberately: lazy() can't be called inline during render
23
+ // (react-hooks/static-components — a getComponent prop that isn't referentially stable would
24
+ // give a new component identity, and therefore an unwanted remount, on every render that passes
25
+ // a fresh one) and can't be cached in a ref either (react-hooks/refs forbids reading or writing
26
+ // ref.current during render, only inside an effect). Awaiting the import directly and setting
27
+ // state from the resolved module sidesteps both — setState only ever runs from the promise
28
+ // callback, never synchronously in render or in the effect body itself.
29
+ export function SkiaGate<P extends object>({ getComponent, componentProps, fallback = null }: SkiaGateProps<P>) {
30
+ const [Component, setComponent] = useState<ComponentType<P> | null>(null)
31
+
32
+ useEffect(() => {
33
+ let cancelled = false
34
+ getComponent().then((mod) => {
35
+ if (!cancelled) setComponent(() => mod.default)
36
+ })
37
+ return () => {
38
+ cancelled = true
39
+ }
40
+ }, [getComponent])
41
+
42
+ if (!Component) return fallback
43
+
44
+ return <Component {...componentProps} />
45
+ }
@@ -0,0 +1,9 @@
1
+ // Web needs CanvasKit's WASM loaded before any Skia <Canvas> evaluates — see loadSkiaWeb.web.ts's
2
+ // own comment for why a plain useEffect-based loader can't fix this (it's a module-evaluation-
3
+ // order problem, not a component-lifecycle one). Only this file (picked by Metro/webpack's
4
+ // platform-extension resolution for web bundles) may import from
5
+ // '@shopify/react-native-skia/src/web' — a static import of that subpath from a native-reachable
6
+ // file pulls canvaskit-wasm's `require('fs')` into the native bundle and breaks iOS/Android
7
+ // builds; this file vs. SkiaGate.tsx is what actually keeps it out, the same split
8
+ // loadSkiaWeb.web.ts/loadSkiaWeb.ts already use.
9
+ export { WithSkiaWeb as SkiaGate } from '@shopify/react-native-skia/src/web'
@@ -1,26 +1,18 @@
1
- import { lazy, Suspense } from 'react'
2
-
3
- import { loadSkiaWeb } from './loadSkiaWeb'
1
+ import { SkiaGate } from './SkiaGate'
4
2
  import { TriggerGaugeProps } from './TriggerGauge'
5
3
 
6
4
  // TriggerGauge.tsx's `Skia` import binds to global.CanvasKit at module-evaluation time, which on web
7
- // only exists once loadSkiaWeb()'s WASM fetch resolves (see that file's own comment) — a plain
8
- // `import { TriggerGauge } from './TriggerGauge'` at the top of this file would already have
9
- // evaluated that binding, against a not-yet-loaded, undefined CanvasKit, before anything in a
10
- // consuming app renders at all, since bundlers evaluate the whole static import graph up front. This
11
- // file therefore never imports TriggerGauge itself except as a type (erased at compile time,
12
- // carrying no runtime module reference) — `lazy()` is what actually defers the real import to
13
- // runtime, after loadSkiaWeb() resolves. Always lazy, on every platform, rather than a native/web
14
- // branch here, purely to keep this file simple — loadSkiaWeb() itself already resolves immediately
15
- // on native (see its own file split), so the Suspense boundary there just clears on the next tick,
16
- // not a perceptible delay. The Suspense fallback renders nothing, so a
17
- // consumer's trigger icon still shows immediately and the gauge ring just pops in a beat later.
18
- const LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import('./TriggerGauge').then((m) => ({ default: m.TriggerGauge }))))
5
+ // only exists once CanvasKit's WASM fetch resolves — a plain `import { TriggerGauge } from
6
+ // './TriggerGauge'` at the top of this file would already have evaluated that binding, against a
7
+ // not-yet-loaded, undefined CanvasKit, before anything in a consuming app renders at all, since
8
+ // bundlers evaluate the whole static import graph up front. This file therefore never imports
9
+ // TriggerGauge itself except as a type (erased at compile time, carrying no runtime module
10
+ // reference) — getTriggerGauge below, hoisted to module scope so its identity stays stable across
11
+ // renders (see SkiaGate's own comment on why that matters), is what actually defers the real import
12
+ // to runtime. No explicit loadSkiaWeb() call here on web: SkiaGate.web.tsx (WithSkiaWeb) already
13
+ // gates its own mount behind CanvasKit being ready, and on native there's nothing to wait on at all.
14
+ const getTriggerGauge = () => import('./TriggerGauge').then((mod) => ({ default: mod.TriggerGauge }))
19
15
 
20
16
  export default function TriggerGaugeHost(props: TriggerGaugeProps) {
21
- return (
22
- <Suspense fallback={null}>
23
- <LazyTriggerGauge {...props} />
24
- </Suspense>
25
- )
17
+ return <SkiaGate getComponent={getTriggerGauge} componentProps={props} />
26
18
  }
package/src/index.ts CHANGED
@@ -15,16 +15,17 @@ export { PressAwayOverlay } from './PressAwayOverlay'
15
15
  export { ReadyButton } from './ReadyButton'
16
16
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
17
17
  export { SharedActionBand } from './SharedActionBand'
18
+ export { SkiaGate, type SkiaGateProps } from './SkiaGate'
18
19
  export { StatRow } from './StatRow'
19
20
  export { StatSection } from './StatSection'
20
21
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
21
- // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
22
- // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import
23
- // into a chunk that this entry point imports eagerly at the top level — since that chunk is then
24
- // shared with TriggerGaugeHost's lazy dynamic import too, it defeats TriggerGaugeHost's own
25
- // deferred loading for every consumer, on every platform, not just the ones reaching for the raw
26
- // component. TriggerGaugeHost is the only supported way to render this.
22
+ // though a native-only consumer could safely skip TriggerGaugeHost's SkiaGate indirection. A
23
+ // value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import into a chunk
24
+ // that this entry point imports eagerly at the top level — since that chunk is then shared with
25
+ // TriggerGaugeHost's own dynamic import too, it defeats TriggerGaugeHost's deferred loading for
26
+ // every consumer, on every platform, not just the ones reaching for the raw component.
27
+ // TriggerGaugeHost is the only supported way to render this.
27
28
  export type { TriggerGaugeProps } from './TriggerGauge'
28
29
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
29
- export { type PopoverAlign, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
30
+ export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
30
31
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
@@ -1,3 +1,4 @@
1
+ import { type ViewRotation } from '@tastic/core'
1
2
  import { useCallback, useEffect, useRef, useState } from 'react'
2
3
  import { useWindowDimensions, View } from 'react-native'
3
4
 
@@ -7,15 +8,55 @@ const EDGE_MARGIN = 12
7
8
 
8
9
  export type PopoverAlign = 'left' | 'right' | 'center'
9
10
  export type PopoverVerticalAlign = 'below' | 'above'
11
+ // Same set of values as @tastic/core's own ViewRotation — re-exported under this package's existing
12
+ // name rather than switching every call site to import ViewRotation directly, since "popover
13
+ // rotation" reads clearer at these call sites than the more general core name.
14
+ export type PopoverRotation = ViewRotation
10
15
 
11
- // Measures a trigger's actual on-screen position (via measureInWindow, which resolves post-
12
- // transform — this is what makes it correct even for a trigger sitting inside a 180°-rotated zone,
13
- // unlike a plain onLayout which only ever reports pre-transform local coordinates) and picks
14
- // whichever alignment keeps a popover of `contentWidth`×`contentHeight` from overflowing a screen
15
- // edge — horizontally (left/right/center, preferring center) and vertically (below/above,
16
- // preferring below — the usual case — but flipping to above when the trigger doesn't have enough
17
- // room underneath it, which is what a short landscape screen with the trigger row near the top
18
- // actually runs into).
16
+ // Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
17
+ // useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
18
+ // position) into its true, post-rotation position — needed for a trigger sitting inside a
19
+ // @tastic/split-screen-style FakeLandscapeView, or any other ancestor rotated the same way (a bare
20
+ // CSS `transform: rotate()`, not an actual OS-level orientation change).
21
+ //
22
+ // The pivot is always the window's own center, for every rotation amount: FakeLandscapeView (and
23
+ // this package's own doc-recommended equivalent for a caller that rolls its own) always centers its
24
+ // rotated container — width/height swapped first for ±90°, unchanged for 180° — within the original,
25
+ // un-rotated screen bounds before applying the CSS rotation, so the transform's own effective pivot
26
+ // lands at that shared center regardless of which of the three non-zero rotations it is.
27
+ export function rotateRect(x: number, y: number, width: number, height: number, rotation: PopoverRotation, windowWidth: number, windowHeight: number): { x: number; y: number; width: number; height: number } {
28
+ if (rotation === 0) return { x, y, width, height }
29
+ const cx = windowWidth / 2
30
+ const cy = windowHeight / 2
31
+ const dx = x + width / 2 - cx
32
+ const dy = y + height / 2 - cy
33
+ // Clockwise-positive about (cx, cy) — matches CSS's own rotate(deg) sign convention, which is
34
+ // where every rotation value this ever receives ultimately comes from (getViewRotation and
35
+ // FakeLandscapeView both already commit to that same convention).
36
+ const [newDx, newDy, newWidth, newHeight] = rotation === 180 ? [-dx, -dy, width, height] : rotation === 90 ? [-dy, dx, height, width] : [dy, -dx, height, width]
37
+ return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight }
38
+ }
39
+
40
+ // Measures a trigger's on-screen position (via measureInWindow) and picks whichever alignment keeps
41
+ // a popover of `contentWidth`×`contentHeight` from overflowing a screen edge — horizontally
42
+ // (left/right/center, preferring center) and vertically (below/above, preferring below — the usual
43
+ // case — but flipping to above when the trigger doesn't have enough room underneath it, which is
44
+ // what a short landscape screen with the trigger row near the top actually runs into).
45
+ //
46
+ // measureInWindow resolves the trigger's PRE-transform layout frame — confirmed against a real
47
+ // rotated screen, not just reasoned about: an earlier version of this comment claimed the opposite
48
+ // (that measureInWindow "resolves post-transform"), which is what let a real bug ship unnoticed,
49
+ // since nothing here ever actually exercised a rotated case to catch that claim being wrong (see
50
+ // this file's own test suite, which now does). A plain `onLayout` reports the same pre-transform
51
+ // frame — there is no plain RN layout API that reports a transformed descendant's true visual
52
+ // position, which is exactly why `rotation` below exists: it's the one piece of information this
53
+ // hook cannot recover from measurement alone, since Yoga's layout pass and a CSS `transform` are
54
+ // deliberately independent stages, so the caller has to supply it.
55
+ //
56
+ // `rotation` defaults to 0 (measureInWindow's raw result used as-is) — every existing caller that
57
+ // never sits inside a rotated ancestor keeps behaving identically; only a caller that both (a) can
58
+ // end up inside a @tastic/split-screen-style rotated zone and (b) knows which rotation is currently
59
+ // live needs to pass it (see SectionedDropdown's own `rotation` prop for the wiring this expects).
19
60
  //
20
61
  // Also returns `maxHeight`: the actual room available in whichever vertical direction got picked.
21
62
  // Flipping above-vs-below only ever picks the *better* side — on a screen short enough that neither
@@ -27,7 +68,7 @@ export type PopoverVerticalAlign = 'below' | 'above'
27
68
  //
28
69
  // Re-measures on every open (not continuously) — the trigger's screen position can't change while
29
70
  // its own popover is showing anyway, so there's nothing to gain from tracking it beyond that.
30
- export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number) {
71
+ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0) {
31
72
  const triggerRef = useRef<View>(null)
32
73
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
33
74
  const [align, setAlign] = useState<PopoverAlign>('center')
@@ -52,7 +93,8 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
52
93
  }
53
94
 
54
95
  const measure = useCallback(() => {
55
- triggerRef.current?.measureInWindow((x, y, triggerWidth, triggerHeight) => {
96
+ triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
97
+ const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight)
56
98
  const centerX = x + triggerWidth / 2
57
99
  const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN
58
100
  const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN
@@ -72,14 +114,14 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
72
114
  setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0))
73
115
  setMeasured(true)
74
116
  })
75
- }, [contentWidth, contentHeight, windowWidth, windowHeight])
117
+ }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation])
76
118
 
77
119
  // Re-measures whenever `open` flips true, and also if `measure` itself changes identity (i.e.
78
- // contentWidth/contentHeight/windowWidth/windowHeight changed) while already open — e.g. a live
79
- // window resize — so it never goes stale while the popover is actually showing. `measured` only
80
- // resets on a genuine (re)open, via the render-phase check above — not here on every re-measure,
81
- // so an already-visible popover never flickers hidden again just because the window resized
82
- // under it.
120
+ // contentWidth/contentHeight/windowWidth/windowHeight/rotation changed) while already open — e.g.
121
+ // a live window resize, or the device being physically turned while the popover is showing — so it
122
+ // never goes stale while the popover is actually showing. `measured` only resets on a genuine
123
+ // (re)open, via the render-phase check above — not here on every re-measure, so an already-visible
124
+ // popover never flickers hidden again just because the window resized (or rotated) under it.
83
125
  useEffect(() => {
84
126
  if (open) measure()
85
127
  }, [open, measure])