@tastic/hud 0.6.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.6.0",
3
+ "version": "0.8.1",
4
4
  "description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
5
5
  "keywords": [
6
6
  "react-native",
@@ -33,6 +33,13 @@
33
33
  "types": "./dist/index.d.ts",
34
34
  "import": "./dist/index.mjs",
35
35
  "require": "./dist/index.js"
36
+ },
37
+ "./skia-gate": {
38
+ "react-native": "./src/SkiaGate.tsx",
39
+ "browser": "./src/SkiaGate.web.tsx",
40
+ "types": "./dist/SkiaGate.d.ts",
41
+ "import": "./dist/SkiaGate.mjs",
42
+ "require": "./dist/SkiaGate.js"
36
43
  }
37
44
  },
38
45
  "main": "dist/index.js",
@@ -63,14 +70,14 @@
63
70
  },
64
71
  "prettier": "@infinitetoken/eslint-config/prettier",
65
72
  "devDependencies": {
66
- "@infinitetoken/eslint-config": "0.2.0",
67
- "@infinitetoken/jest-config": "^0.2.0",
73
+ "@infinitetoken/eslint-config": "^0.2.0",
74
+ "@infinitetoken/jest-config": "^0.3.0",
68
75
  "@infinitetoken/tsconfig": "^0.4.1",
69
76
  "@rific/auto-paper": "^0.10.3",
70
77
  "@rific/feedback-press": "^0.10.8",
71
- "@rific/updater": "^0.4.2",
78
+ "@rific/updater": "^0.6.0",
72
79
  "@shopify/react-native-skia": "^2.6.2",
73
- "@tastic/core": "^0.2.0",
80
+ "@tastic/core": "^0.3.0",
74
81
  "@testing-library/dom": "^10.4.1",
75
82
  "@testing-library/react": "^16.3.2",
76
83
  "@types/jest": "^30.0.0",
@@ -94,7 +101,7 @@
94
101
  "@rific/feedback-press": ">=0.10.0",
95
102
  "@rific/updater": ">=0.4.0",
96
103
  "@shopify/react-native-skia": ">=1.5.0",
97
- "@tastic/core": ">=0.1.0",
104
+ "@tastic/core": ">=0.3.0",
98
105
  "react": ">=19.0.0",
99
106
  "react-native": ">=0.76.0",
100
107
  "react-native-paper": ">=5.0.0",
@@ -1,7 +1,7 @@
1
1
  import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
3
3
  import { useUpdater } from '@rific/updater'
4
- import { useIsTouchPrimaryDevice } from '@tastic/core'
4
+ import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
5
5
  import { ReactNode, useContext, useState } from 'react'
6
6
  import { Platform, ScrollView, StyleSheet, View } from 'react-native'
7
7
  import { Icon, Portal, Text } from 'react-native-paper'
@@ -26,10 +26,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
26
26
  export interface BaseSettingsDialogProps {
27
27
  visible: boolean
28
28
  onDismiss: () => void
29
- // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this is a centered,
30
- // app-wide modal with no per-player zone to match (unlike an in-game round-over dialog), so it
31
- // just rotates its own content in place; defaults to 0 for a caller that doesn't track one (a
32
- // game with no face-to-face two-player mode has nothing to stay consistent with anyway).
29
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — this is
30
+ // a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
31
+ // dialog), so it just rotates its own content in place. Defaults to a live ambient read via
32
+ // useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
33
+ // ambient one (rare — most callers should simply omit it).
33
34
  rotation?: number
34
35
  // Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
35
36
  // itself, since every consuming app tracks its own release.ts independently. Accepts a number too
@@ -76,8 +77,10 @@ export interface BaseSettingsDialogProps {
76
77
  // is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
77
78
  // backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
78
79
  // forking this component.
79
- export function BaseSettingsDialog({ visible, onDismiss, rotation = 0, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
80
+ export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
80
81
  const { dark, colors } = useAutoPaperTheme()
82
+ const ambientRotation = useRotation()
83
+ const rotation = rotationOverride ?? ambientRotation
81
84
  // Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
82
85
  // unconditionally on native (see the hook's own doc), so this only actually excludes a
83
86
  // desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
@@ -1,5 +1,6 @@
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
6
  import { Icon, Portal, Text } from 'react-native-paper'
@@ -10,12 +11,12 @@ import { Icon, Portal, Text } from 'react-native-paper'
10
11
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
11
12
 
12
13
  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.
14
+ // Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
15
+ // affects this component's own Portal-rendered reset-confirm overlay, which (like any Portal
16
+ // content) is unaffected by whichever screen's own FakeLandscapeView wraps the trigger and so has
17
+ // to rotate itself. Defaults to a live ambient read via useRotation() when omitted — note this
18
+ // does NOT make the header/scrollable content below rotate; the caller's own screen still needs
19
+ // its own FakeLandscapeView wrap for that (this component has no transform on its own main body).
19
20
  rotation?: number
20
21
  title?: string
21
22
  // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
@@ -67,8 +68,10 @@ interface Props {
67
68
  // black/white convention. `children` is the seam for whatever isn't shared: this component has no
68
69
  // opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
69
70
  // 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) {
71
+ 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, cardBg: cardBgOverride, accentColor: accentColorOverride, children }: Props) {
71
72
  const { dark, colors } = useAutoPaperTheme()
73
+ const ambientRotation = useRotation()
74
+ const rotation = rotationOverride ?? ambientRotation
72
75
  const [confirmResetVisible, setConfirmResetVisible] = useState(false)
73
76
  const showReset = !!onReset
74
77
 
@@ -0,0 +1,104 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { Button } from '@rific/feedback-press'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Icon, Portal, Text } from 'react-native-paper'
5
+
6
+ export interface ConfirmDialogProps {
7
+ visible: boolean
8
+ title: string
9
+ message: string
10
+ confirmLabel: string
11
+ cancelLabel: string
12
+ onConfirm: () => void
13
+ onCancel: () => void
14
+ // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) - same convention as
15
+ // BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
16
+ // match (a portrait-only or single-player game has nothing to stay consistent with).
17
+ rotation?: number
18
+ // Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
19
+ // question mark, since this component has no way to know why a caller is asking.
20
+ icon?: string
21
+ // Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
22
+ // instead of the default primary-colored affirmative action.
23
+ destructive?: boolean
24
+ }
25
+
26
+ // A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern - the
27
+ // same "retro card overlay" look BaseSettingsDialog's own update-check info messages already use
28
+ // (see its onInfo/infoMessage), generalized here into its own reusable, exported component for any
29
+ // caller that needs a real yes/no decision instead of a single OK dismissal. A solid backdrop +
30
+ // centered card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
31
+ // this renders identically on every platform, including web, where react-native-web's Alert.alert
32
+ // is a hard no-op (confirmed: its entire implementation is `static alert() {}`) and Dialog's own
33
+ // Portal-based positioning has its own web quirks BaseSettingsDialog already works around.
34
+ export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
35
+ const { dark, colors } = useAutoPaperTheme()
36
+ if (!visible) return null
37
+
38
+ // High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
39
+ // tinted) background role - same convention BaseSettingsDialog's own overlay card uses.
40
+ const fg = dark ? '#FFFFFF' : '#000000'
41
+ const cardBg = dark ? '#111111' : '#F2F2F2'
42
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
43
+ const accent = destructive ? colors.danger : colors.primary
44
+ const onAccent = destructive ? colors.onDanger : colors.onPrimary
45
+
46
+ return (
47
+ <Portal>
48
+ <View style={styles.overlay}>
49
+ <View style={[styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
50
+ <Icon source={icon} size={64} color={accent} />
51
+ <Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
52
+ {title}
53
+ </Text>
54
+ <Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
55
+ {message}
56
+ </Text>
57
+ <View style={styles.actions}>
58
+ <Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
59
+ {cancelLabel}
60
+ </Button>
61
+ <Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
62
+ {confirmLabel}
63
+ </Button>
64
+ </View>
65
+ </View>
66
+ </View>
67
+ </Portal>
68
+ )
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ actionButton: {
73
+ flex: 1
74
+ },
75
+ actions: {
76
+ flexDirection: 'row',
77
+ gap: 12,
78
+ width: '100%'
79
+ },
80
+ body: {
81
+ textAlign: 'center'
82
+ },
83
+ card: {
84
+ alignItems: 'center',
85
+ borderRadius: 20,
86
+ borderWidth: 1,
87
+ gap: 16,
88
+ maxWidth: 360,
89
+ padding: 32
90
+ },
91
+ overlay: {
92
+ alignItems: 'center',
93
+ backgroundColor: 'rgba(0,0,0,0.72)',
94
+ bottom: 0,
95
+ justifyContent: 'center',
96
+ left: 0,
97
+ position: 'absolute',
98
+ right: 0,
99
+ top: 0
100
+ },
101
+ title: {
102
+ fontWeight: 'bold'
103
+ }
104
+ })
@@ -0,0 +1,53 @@
1
+ import { useIsTouchPrimaryDevice } from '@tastic/core'
2
+ import { Platform } from 'react-native'
3
+
4
+ import { MenuOption, SectionedDropdown } from './SectionedDropdown'
5
+ import { PopoverHost } from './usePopoverHost'
6
+
7
+ export interface ControlSchemePickerProps<T extends string | number> {
8
+ id: string
9
+ host: PopoverHost
10
+ value: T
11
+ onChange: (value: T) => void
12
+ options: MenuOption<T>[]
13
+ // Another seat's currently-selected scheme, if any — passed straight through as the underlying
14
+ // section's own takenValue (see SectionedDropdown's own doc: stays in the list, shown disabled,
15
+ // rather than filtered out). Two local seats sharing one physical keyboard (and, depending on your
16
+ // own option set, one mouse) can't both be assigned the same scheme at once; this is what a caller
17
+ // wires up to prevent that.
18
+ takenValue?: T
19
+ accentColor: string
20
+ mutedColor: string
21
+ onAccentColor?: string
22
+ dark: boolean
23
+ align?: 'left' | 'right' | 'center'
24
+ labelFontFamily?: string
25
+ }
26
+
27
+ // A single-select SectionedDropdown, opinionated for exactly one job: letting a local seat pick
28
+ // which physical input drives it in a couch-multiplayer game. Fixes the trigger icon and
29
+ // accessibility label so every game's control-scheme picker looks and reads the same, and — the
30
+ // actual reason this exists as its own component rather than a usage snippet in each app's own
31
+ // docs — bakes in the "does this even mean anything right now" gate every consumer was independently
32
+ // re-deriving: a physical keyboard scheme has no meaning on a touch-primary device (there's nothing
33
+ // to pick a *keyboard* layout for), and this repo's own components target React Native Web, where
34
+ // `Platform.OS !== 'web'` means native/touch already, so keying off `useIsTouchPrimaryDevice` alone
35
+ // isn't enough on its own — a native touch device still needs the `Platform.OS` check to be excluded
36
+ // the same way a desktop browser needs the touch check. Renders nothing at all when either
37
+ // condition fails, rather than a disabled/empty picker, so a caller can mount this unconditionally
38
+ // inside its own per-seat layout (a `View` row alongside a color picker, say) without its own
39
+ // `Platform`/`useIsTouchPrimaryDevice` branch — an always-rendered-but-sometimes-null child is a
40
+ // no-op in a flex layout, so the surrounding row collapses to just its other children automatically.
41
+ //
42
+ // Deliberately generic over `T` (matching SectionedDropdown's own `MenuOption<T>`) rather than
43
+ // hardcoding a fixed scheme union: this repo has no opinion on which schemes exist — the raw
44
+ // physical-key layouts (wasd/arrows/ijkl/numpad) live in `@tastic/input`, and any extra values with
45
+ // no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
46
+ // caller always supplies its own `options` (and typically its own `T`) rather than this component
47
+ // importing `@tastic/input` and choosing a set for every consumer.
48
+ export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
49
+ const isTouchPrimary = useIsTouchPrimaryDevice()
50
+ if (Platform.OS !== 'web' || isTouchPrimary) return null
51
+
52
+ return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
53
+ }
@@ -12,6 +12,14 @@ interface Props {
12
12
  // @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
13
13
  // it doesn't know anything about physical device orientation itself.
14
14
  insets: { top: number; left: number; right: number }
15
+ // Overrides the top-left button's icon (a react-native-paper/MaterialCommunityIcons name) and its
16
+ // accessibility label — default 'arrow-left'/'Back'. Not every screen's top-left control is
17
+ // actually "undo the last navigation": a flat menu/detail screen with nothing deeper to pop back
18
+ // through often wants a literal home/menu affordance driving an absolute navigation instead (see
19
+ // e.g. Solitaire's GameChrome, whose onBack navigates straight to its root route). Pass both
20
+ // together — an icon without a matching label (or vice versa) misleads screen readers/tooltips.
21
+ backIcon?: string
22
+ backLabel?: string
15
23
  }
16
24
 
17
25
  // Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
@@ -20,10 +28,10 @@ interface Props {
20
28
  // right answer for a screen where the top corners themselves fall *inside* one player's own rotated
21
29
  // zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
22
30
  // between based on their own game-mode/orientation state (this component has no opinion on which).
23
- export function CornerActionButtons({ onBack, onSettings, fg, insets }: Props) {
31
+ export function CornerActionButtons({ onBack, onSettings, fg, insets, backIcon = 'arrow-left', backLabel = 'Back' }: Props) {
24
32
  return (
25
33
  <>
26
- <IconButton icon='arrow-left' iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel='Back' />
34
+ <IconButton icon={backIcon} iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel={backLabel} />
27
35
  <IconButton icon='cog' iconColor={fg} size={24} style={[styles.topRight, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={onSettings} accessibilityLabel='Settings' />
28
36
  </>
29
37
  )
@@ -0,0 +1,87 @@
1
+ import { StyleSheet, View } from 'react-native'
2
+ import { Icon } from 'react-native-paper'
3
+
4
+ // Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported —
5
+ // this package has never taken a dependency on @tastic/split-screen; every orientation/rotation-
6
+ // derived value it touches (ConfirmDialog's and BaseSettingsDialog's own `rotation` props, this
7
+ // component's orientationMode/p1OnRight below) is always a plain value the caller already resolved
8
+ // via that package's own hook, never something this package reads itself.
9
+ export type OrientationMode = 'faceToFace' | 'sideBySide'
10
+
11
+ export interface CornerStatusBadgeSeat {
12
+ // Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty — a
13
+ // seat with nothing to show right now is different from no seat at all (see `p1`/`p2` on
14
+ // CornerStatusBadgesProps below), and the empty badge is itself the answer ("nothing"), not a
15
+ // reason to hide the corner.
16
+ icon?: string
17
+ }
18
+
19
+ export interface CornerStatusBadgesProps {
20
+ orientationMode: OrientationMode
21
+ // Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight — only
22
+ // meaningful when orientationMode is 'sideBySide'; ignored in 'faceToFace', where seat 1 always
23
+ // owns the bottom half of a shared screen and seat 2 the top half, regardless of physical rotation.
24
+ p1OnRight: boolean
25
+ // Independently optional — omit a seat entirely to skip rendering its badge at all (e.g. a solo
26
+ // mode with no second seat to show anything for). Pass an object (even `{}`) to render that
27
+ // seat's badge with nothing in it.
28
+ p1?: CornerStatusBadgeSeat
29
+ p2?: CornerStatusBadgeSeat
30
+ }
31
+
32
+ const BADGE_SIZE = 30
33
+
34
+ // Deliberately understated (small, low-opacity, no per-seat color) — a quiet corner landmark, not
35
+ // something that should compete for attention with whatever's on screen underneath it.
36
+ function StatusBadge({ icon }: { icon?: string }) {
37
+ return <View style={styles.badge}>{icon && <Icon source={icon} size={16} color='rgba(255,255,255,0.85)' />}</View>
38
+ }
39
+
40
+ // Generic per-seat corner status badge: at most one caller-supplied icon per seat (or none), each
41
+ // pinned to that seat's own screen corner. Has zero opinion on what the icon means — originally
42
+ // extracted from LightCycles' PowerupHud.tsx and Snake's SnakePowerupHud.tsx, which both used this
43
+ // exact positioning and styling to reveal a held power-up, but this component only ever sees a
44
+ // resolved icon name (or undefined) per seat, never the domain value behind it, so it's equally
45
+ // suited to any other per-seat status a caller wants glanceable in a screen corner (connection
46
+ // state, a turn-order marker, anything that's a single icon or nothing). Corner picked per seat
47
+ // from orientationMode/p1OnRight — the same "is this seat on the right" convention
48
+ // @tastic/split-screen's own consumers already use for zone/rotation decisions — so each badge
49
+ // always sits in that seat's own zone rather than a side fixed regardless of which way the device
50
+ // is being held. In 'faceToFace', column doesn't matter (each seat's zone spans the full width):
51
+ // seat 1 stays bottom, seat 2 stays top, regardless of p1OnRight.
52
+ export function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: CornerStatusBadgesProps) {
53
+ const p1OnRightSide = orientationMode === 'sideBySide' && p1OnRight
54
+ const p2OnLeftSide = orientationMode === 'sideBySide' && !p1OnRight
55
+ return (
56
+ <>
57
+ {p1 && (
58
+ <View pointerEvents='none' style={p1OnRightSide ? styles.bottomRight : styles.bottomLeft}>
59
+ <StatusBadge icon={p1.icon} />
60
+ </View>
61
+ )}
62
+ {p2 && (
63
+ <View pointerEvents='none' style={p2OnLeftSide ? styles.topLeft : styles.topRight}>
64
+ <StatusBadge icon={p2.icon} />
65
+ </View>
66
+ )}
67
+ </>
68
+ )
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ badge: {
73
+ alignItems: 'center',
74
+ backgroundColor: 'rgba(0,0,0,0.28)',
75
+ borderColor: 'rgba(255,255,255,0.18)',
76
+ borderRadius: BADGE_SIZE / 2,
77
+ borderWidth: 1,
78
+ height: BADGE_SIZE,
79
+ justifyContent: 'center',
80
+ opacity: 0.7,
81
+ width: BADGE_SIZE
82
+ },
83
+ bottomLeft: { bottom: 12, left: 12, position: 'absolute' },
84
+ bottomRight: { bottom: 12, position: 'absolute', right: 12 },
85
+ topLeft: { left: 12, position: 'absolute', top: 12 },
86
+ topRight: { position: 'absolute', right: 12, top: 12 }
87
+ })
@@ -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
  }