@tastic/hud 0.7.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/dist/index.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ SkiaGate
3
+ } from "./chunk-XEOFR236.mjs";
4
+
1
5
  // src/AchievementRow.tsx
2
6
  import { useAutoPaperTheme } from "@rific/auto-paper";
3
7
  import { StyleSheet, View } from "react-native";
@@ -70,7 +74,7 @@ var styles = StyleSheet.create({
70
74
  import { AutoAppearancePicker, Dialog, useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
71
75
  import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from "@rific/feedback-press";
72
76
  import { useUpdater } from "@rific/updater";
73
- import { useIsTouchPrimaryDevice } from "@tastic/core";
77
+ import { useIsTouchPrimaryDevice, useRotation } from "@tastic/core";
74
78
  import { useContext, useState } from "react";
75
79
  import { Platform, ScrollView, StyleSheet as StyleSheet2, View as View2 } from "react-native";
76
80
  import { Icon as Icon2, Portal, Text as Text2 } from "react-native-paper";
@@ -78,8 +82,10 @@ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
78
82
  function SettingIcon({ source, color, containerColor }) {
79
83
  return /* @__PURE__ */ jsx2(View2, { style: [styles2.iconBadge, { backgroundColor: containerColor }], children: /* @__PURE__ */ jsx2(Icon2, { source, size: 18, color }) });
80
84
  }
81
- function BaseSettingsDialog({ visible, onDismiss, rotation = 0, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }) {
85
+ function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }) {
82
86
  const { dark, colors } = useAutoPaperTheme2();
87
+ const ambientRotation = useRotation();
88
+ const rotation = rotationOverride ?? ambientRotation;
83
89
  const isTouchPrimary = useIsTouchPrimaryDevice();
84
90
  const { settings: hapticSettings, set: setHapticSettings } = useHapticSettings();
85
91
  const { settings: soundSettings, set: setSoundSettings } = useSoundSettings();
@@ -271,12 +277,15 @@ var styles2 = StyleSheet2.create({
271
277
  // src/BaseStatsScreen.tsx
272
278
  import { useAutoPaperTheme as useAutoPaperTheme3 } from "@rific/auto-paper";
273
279
  import { Button as Button2, IconButton } from "@rific/feedback-press";
280
+ import { useRotation as useRotation2 } from "@tastic/core";
274
281
  import { useState as useState2 } from "react";
275
282
  import { ScrollView as ScrollView2, StyleSheet as StyleSheet3, View as View3 } from "react-native";
276
283
  import { Icon as Icon3, Portal as Portal2, Text as Text3 } from "react-native-paper";
277
284
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
278
- 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 }) {
285
+ 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 }) {
279
286
  const { dark, colors } = useAutoPaperTheme3();
287
+ const ambientRotation = useRotation2();
288
+ const rotation = rotationOverride ?? ambientRotation;
280
289
  const [confirmResetVisible, setConfirmResetVisible] = useState2(false);
281
290
  const showReset = !!onReset;
282
291
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
@@ -459,6 +468,7 @@ import { Platform as Platform3 } from "react-native";
459
468
  // src/SectionedDropdown.tsx
460
469
  import { getBlendedColor, getColorRoles } from "@rific/auto-paper";
461
470
  import { IconButton as IconButton2, TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
471
+ import { useRotation as useRotation3 } from "@tastic/core";
462
472
  import { ScrollView as ScrollView3, StyleSheet as StyleSheet7, View as View8 } from "react-native";
463
473
  import { Icon as Icon5, Text as Text5 } from "react-native-paper";
464
474
 
@@ -532,26 +542,27 @@ var styles6 = StyleSheet6.create({
532
542
  }
533
543
  });
534
544
 
535
- // src/TriggerGaugeHost.tsx
536
- import { lazy, Suspense } from "react";
537
-
538
- // src/loadSkiaWeb.ts
539
- function loadSkiaWeb() {
540
- return Promise.resolve();
541
- }
542
-
543
545
  // src/TriggerGaugeHost.tsx
544
546
  import { jsx as jsx7 } from "react/jsx-runtime";
545
- var LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import("./TriggerGauge-2OJOOF6I.mjs").then((m) => ({ default: m.TriggerGauge }))));
547
+ var getTriggerGauge = () => import("./TriggerGauge-2OJOOF6I.mjs").then((mod) => ({ default: mod.TriggerGauge }));
546
548
  function TriggerGaugeHost(props) {
547
- return /* @__PURE__ */ jsx7(Suspense, { fallback: null, children: /* @__PURE__ */ jsx7(LazyTriggerGauge, { ...props }) });
549
+ return /* @__PURE__ */ jsx7(SkiaGate, { getComponent: getTriggerGauge, componentProps: props });
548
550
  }
549
551
 
550
552
  // src/useAutoAlign.ts
551
553
  import { useCallback, useEffect, useRef, useState as useState3 } from "react";
552
554
  import { useWindowDimensions as useWindowDimensions2 } from "react-native";
553
555
  var EDGE_MARGIN = 12;
554
- function useAutoAlign(open, contentWidth, contentHeight) {
556
+ function rotateRect(x, y, width, height, rotation, windowWidth, windowHeight) {
557
+ if (rotation === 0) return { x, y, width, height };
558
+ const cx = windowWidth / 2;
559
+ const cy = windowHeight / 2;
560
+ const dx = x + width / 2 - cx;
561
+ const dy = y + height / 2 - cy;
562
+ const [newDx, newDy, newWidth, newHeight] = rotation === 180 ? [-dx, -dy, width, height] : rotation === 90 ? [-dy, dx, height, width] : [dy, -dx, height, width];
563
+ return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight };
564
+ }
565
+ function useAutoAlign(open, contentWidth, contentHeight, rotation = 0) {
555
566
  const triggerRef = useRef(null);
556
567
  const { width: windowWidth, height: windowHeight } = useWindowDimensions2();
557
568
  const [align, setAlign] = useState3("center");
@@ -564,7 +575,8 @@ function useAutoAlign(open, contentWidth, contentHeight) {
564
575
  if (open) setMeasured(false);
565
576
  }
566
577
  const measure = useCallback(() => {
567
- triggerRef.current?.measureInWindow((x, y, triggerWidth, triggerHeight) => {
578
+ triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
579
+ const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight);
568
580
  const centerX = x + triggerWidth / 2;
569
581
  const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN;
570
582
  const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN;
@@ -578,7 +590,7 @@ function useAutoAlign(open, contentWidth, contentHeight) {
578
590
  setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0));
579
591
  setMeasured(true);
580
592
  });
581
- }, [contentWidth, contentHeight, windowWidth, windowHeight]);
593
+ }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation]);
582
594
  useEffect(() => {
583
595
  if (open) measure();
584
596
  }, [open, measure]);
@@ -597,7 +609,9 @@ var MENU_ITEM_HEIGHT = 40;
597
609
  var MENU_ITEM_HEIGHT_WITH_DESCRIPTION = 58;
598
610
  var MENU_DIVIDER_HEIGHT = 9;
599
611
  var MENU_VERTICAL_PADDING = 12;
600
- function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: "All", clear: "Clear" } }) {
612
+ 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" } }) {
613
+ const ambientRotation = useRotation3();
614
+ const rotation = rotationOverride ?? ambientRotation;
601
615
  const menuBg = dark ? "#000000" : "#FFFFFF";
602
616
  const menuBorderColor = getBlendedColor(mutedColor, menuBg, 0.5);
603
617
  const resolvedOnAccentColor = onAccentColor ?? getColorRoles(accentColor, menuBg).onColor;
@@ -609,7 +623,7 @@ function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accen
609
623
  const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0;
610
624
  return sum + rows + footer + divider;
611
625
  }, 0);
612
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight);
626
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation);
613
627
  const align = alignOverride ?? autoAlign;
614
628
  const onlySection = sections.length === 1 ? sections[0] : null;
615
629
  const selectedOption = onlySection?.kind === "single" ? onlySection.options.find((o) => o.value === onlySection.value) : void 0;
@@ -841,7 +855,7 @@ var styles9 = StyleSheet9.create({
841
855
  // src/InlineColorPicker.tsx
842
856
  import { defaultColors, getContrastColor } from "@rific/auto-paper";
843
857
  import { TouchableRipple as TouchableRipple3 } from "@rific/feedback-press";
844
- import { clamp } from "@tastic/core";
858
+ import { clamp, useRotation as useRotation4 } from "@tastic/core";
845
859
  import { ScrollView as ScrollView4, StyleSheet as StyleSheet10, useWindowDimensions as useWindowDimensions3, View as View10 } from "react-native";
846
860
  import { Icon as Icon7, Text as Text6 } from "react-native-paper";
847
861
  import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
@@ -857,7 +871,9 @@ var SCREEN_MARGIN = 40;
857
871
  function widthForColumns(columns) {
858
872
  return SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * columns + SWATCHES_GAP * (columns - 1);
859
873
  }
860
- 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 }) {
874
+ 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 }) {
875
+ const ambientRotation = useRotation4();
876
+ const rotation = rotationOverride ?? ambientRotation;
861
877
  const menuBg = dark ? "#000000" : "#FFFFFF";
862
878
  const { width: windowWidth } = useWindowDimensions3();
863
879
  const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS);
@@ -866,7 +882,7 @@ function InlineColorPicker({ id, host, value, onChange, previewValue, swatches =
866
882
  const swatchRows = Math.ceil(swatches.length / resolvedColumns);
867
883
  const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1);
868
884
  const open = host.openId === id;
869
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight);
885
+ const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation);
870
886
  const align = alignOverride ?? autoAlign;
871
887
  const displayFor = previewValue ?? ((hex) => hex);
872
888
  const displayValue = displayFor(value);
@@ -1062,6 +1078,11 @@ var styles11 = StyleSheet11.create({
1062
1078
  }
1063
1079
  });
1064
1080
 
1081
+ // src/loadSkiaWeb.ts
1082
+ function loadSkiaWeb() {
1083
+ return Promise.resolve();
1084
+ }
1085
+
1065
1086
  // src/PressAwayOverlay.tsx
1066
1087
  import { Pressable, StyleSheet as StyleSheet12 } from "react-native";
1067
1088
  import { jsx as jsx14 } from "react/jsx-runtime";
@@ -1207,6 +1228,7 @@ export {
1207
1228
  ReadyButton,
1208
1229
  SectionedDropdown,
1209
1230
  SharedActionBand,
1231
+ SkiaGate,
1210
1232
  StatRow,
1211
1233
  StatSection,
1212
1234
  TriggerGaugeHost,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.7.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
 
@@ -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'