@tastic/hud 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -17,9 +17,11 @@ import {
17
17
  StatRow,
18
18
  StatSection,
19
19
  UpdateDialog,
20
+ WELL_SHADOW,
20
21
  getStaggeredWordDuration,
21
- useCompactDialog
22
- } from "./chunk-DK3HOZC5.mjs";
22
+ useCompactDialog,
23
+ useWellColors
24
+ } from "./chunk-N7A7W72S.mjs";
23
25
  import {
24
26
  COMPACT_DIALOG_HEIGHT,
25
27
  DialogHeader,
@@ -683,7 +685,7 @@ function loadSkiaWeb() {
683
685
  }
684
686
 
685
687
  // src/PlayerSetupPanel.tsx
686
- import { ProfilePicker } from "@tastic/profile";
688
+ import { getOptionPickerContentSize, getProfilePickerContentSize, OptionPicker, ProfilePicker } from "@tastic/profile";
687
689
  import { StyleSheet as StyleSheet8, View as View8 } from "react-native";
688
690
  import { Text as Text6 } from "react-native-paper";
689
691
 
@@ -721,26 +723,73 @@ function usePopoverHost() {
721
723
  return { openId, toggle, close };
722
724
  }
723
725
 
726
+ // src/useZoneClampedAlign.ts
727
+ import { useZoneBounds } from "@tastic/split-screen";
728
+ import { useEffect as useEffect2, useState as useState3 } from "react";
729
+ import { useWindowDimensions as useWindowDimensions3 } from "react-native";
730
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
731
+ var EDGE_MARGIN2 = 12;
732
+ function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
733
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
734
+ const zone = useZoneBounds();
735
+ const { height: windowHeight } = useWindowDimensions3();
736
+ const insets = useSafeAreaInsets();
737
+ const shouldMeasure = open && auto.measured && zone !== null;
738
+ const [ownBounds, setOwnBounds] = useState3(null);
739
+ const [prevShouldMeasure, setPrevShouldMeasure] = useState3(shouldMeasure);
740
+ if (shouldMeasure !== prevShouldMeasure) {
741
+ setPrevShouldMeasure(shouldMeasure);
742
+ setOwnBounds(null);
743
+ }
744
+ useEffect2(() => {
745
+ if (!shouldMeasure) return;
746
+ auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
747
+ const belowShared = zone.zoneSide === "belowShared";
748
+ const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
749
+ const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
750
+ const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
751
+ let awaySide = belowShared ? "below" : "above";
752
+ let towardSide = belowShared ? "above" : "below";
753
+ if (zone.rotated) {
754
+ awaySide = awaySide === "above" ? "below" : "above";
755
+ towardSide = towardSide === "above" ? "below" : "above";
756
+ }
757
+ setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
758
+ });
759
+ }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
760
+ if (!zone) return auto;
761
+ if (!ownBounds) return { ...auto, measured: false };
762
+ return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
763
+ }
764
+
724
765
  // src/PlayerSetupPanel.tsx
725
766
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
726
767
  function getColorPopoverId(idPrefix) {
727
768
  return `${idPrefix}-color`;
728
769
  }
770
+ function getProfilePopoverId(idPrefix) {
771
+ return `${idPrefix}-profile`;
772
+ }
729
773
  function getCpuDifficultyPopoverId(idPrefix) {
730
774
  return `${idPrefix}-difficulty`;
731
775
  }
732
- function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, cpuDifficultyAlignOverride, humanIcon = "face-man", cpuIcon = "robot" }) {
776
+ function PlayerSetupPanel({ idPrefix, host, label, labelFontFamily, color, onColorChange, swatches, takenColor, allowSwapTaken, colorCaption, showColor = true, colorAlignOverride, colorRotation, isHuman, secondPicker, secondPickerId, ready, onToggleReady, dark, showReadyButton = true, profiles, selectedProfileId, takenProfileId, guestLabel, onProfileSelect, onManageProfiles, cpuDifficulty, cpuDifficultyOptions, onCpuDifficultyChange, humanIcon = "face-man", cpuIcon = "robot" }) {
733
777
  const ownHost = usePopoverHost();
734
778
  const popover = host ?? ownHost;
735
779
  const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false;
736
780
  const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || secondPickerId !== void 0 && popover.openId === secondPickerId;
737
781
  const showProfilePicker = isHuman && profiles !== void 0 && !!onProfileSelect;
738
782
  const showCpuDifficulty = !isHuman && cpuDifficulty !== void 0 && cpuDifficultyOptions !== void 0 && !!onCpuDifficultyChange;
783
+ const seatButton = showProfilePicker || showCpuDifficulty;
739
784
  const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null;
785
+ const colorSection = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : void 0 } : void 0;
786
+ const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix);
787
+ const seatPickerSize = showProfilePicker ? getProfilePickerContentSize(profiles.length, !!onManageProfiles, colorSection) : getOptionPickerContentSize(cpuDifficultyOptions?.length ?? 0, colorSection);
788
+ const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height);
740
789
  return /* @__PURE__ */ jsxs7(View8, { style: [styles8.panel, ownPopoverOpen && styles8.panelOpen], children: [
741
- showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(LabeledDropdown, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, alignOverride: cpuDifficultyAlignOverride }) : label && /* @__PURE__ */ jsx10(Text6, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
742
- /* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
743
- /* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
790
+ showProfilePicker ? /* @__PURE__ */ jsx10(ProfilePicker, { idPrefix, host: popover, profiles, selectedId: selectedProfileId ?? null, takenId: takenProfileId, color, dark, guestLabel: guestLabel ?? "GUEST", onSelect: (profile) => onProfileSelect?.(profile), onManage: onManageProfiles, colorSection, alignOverride: seatPickerAlign }) : showCpuDifficulty ? /* @__PURE__ */ jsx10(OptionPicker, { id: getCpuDifficultyPopoverId(idPrefix), host: popover, options: cpuDifficultyOptions, value: cpuDifficulty, onChange: onCpuDifficultyChange, color, dark, icon: cpuIcon, colorSection, alignOverride: seatPickerAlign }) : label && /* @__PURE__ */ jsx10(Text6, { variant: "labelSmall", style: { color, fontFamily: labelFontFamily }, children: label }),
791
+ (!seatButton || secondPicker) && /* @__PURE__ */ jsxs7(View8, { style: [styles8.pickerRow, pickerRowPopoverOpen && styles8.pickerRowOpen], children: [
792
+ !seatButton && /* @__PURE__ */ jsx10(InlineColorPicker, { id: getColorPopoverId(idPrefix), host: popover, value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, alignOverride: colorAlignOverride, rotation: colorRotation, dark, tag: isHuman ? selectedProfile?.tag : void 0, icon: isHuman ? humanIcon : cpuIcon }),
744
793
  secondPicker
745
794
  ] }),
746
795
  isHuman && showReadyButton && onToggleReady && /* @__PURE__ */ jsx10(ReadyButton, { color, ready: ready ?? false, onToggleReady })
@@ -757,9 +806,9 @@ var styles8 = StyleSheet8.create({
757
806
  panelOpen: {
758
807
  zIndex: 100
759
808
  },
760
- // Same gap as `panel`'s own vertical rhythm, reused horizontally - the color picker and (when
761
- // present) secondPicker sit side by side within this row instead of stacked in the panel's own
762
- // column.
809
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
810
+ // (when present) secondPicker sit side by side within this row instead of stacked in the panel's
811
+ // own column.
763
812
  pickerRow: {
764
813
  alignItems: "center",
765
814
  flexDirection: "row",
@@ -816,15 +865,15 @@ var styles9 = StyleSheet10.create({
816
865
  });
817
866
 
818
867
  // src/useQuitConfirmation.ts
819
- import { useCallback as useCallback3, useEffect as useEffect2, useRef as useRef2, useState as useState3 } from "react";
868
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef2, useState as useState4 } from "react";
820
869
  function useQuitConfirmation(hasProgress, onConfirmedBack) {
821
- const [confirmVisible, setConfirmVisible] = useState3(false);
870
+ const [confirmVisible, setConfirmVisible] = useState4(false);
822
871
  const hasProgressRef = useRef2(hasProgress);
823
- useEffect2(() => {
872
+ useEffect3(() => {
824
873
  hasProgressRef.current = hasProgress;
825
874
  });
826
875
  const onConfirmedBackRef = useRef2(onConfirmedBack);
827
- useEffect2(() => {
876
+ useEffect3(() => {
828
877
  onConfirmedBackRef.current = onConfirmedBack;
829
878
  });
830
879
  const requestBack = useCallback3(() => {
@@ -834,45 +883,6 @@ function useQuitConfirmation(hasProgress, onConfirmedBack) {
834
883
  const cancelBack = useCallback3(() => setConfirmVisible(false), []);
835
884
  return { confirmVisible, requestBack, cancelBack };
836
885
  }
837
-
838
- // src/useZoneClampedAlign.ts
839
- import { useZoneBounds } from "@tastic/split-screen";
840
- import { useEffect as useEffect3, useState as useState4 } from "react";
841
- import { useWindowDimensions as useWindowDimensions3 } from "react-native";
842
- import { useSafeAreaInsets } from "react-native-safe-area-context";
843
- var EDGE_MARGIN2 = 12;
844
- function useZoneClampedAlign(open, contentWidth, contentHeight, rotation = 0) {
845
- const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
846
- const zone = useZoneBounds();
847
- const { height: windowHeight } = useWindowDimensions3();
848
- const insets = useSafeAreaInsets();
849
- const shouldMeasure = open && auto.measured && zone !== null;
850
- const [ownBounds, setOwnBounds] = useState4(null);
851
- const [prevShouldMeasure, setPrevShouldMeasure] = useState4(shouldMeasure);
852
- if (shouldMeasure !== prevShouldMeasure) {
853
- setPrevShouldMeasure(shouldMeasure);
854
- setOwnBounds(null);
855
- }
856
- useEffect3(() => {
857
- if (!shouldMeasure) return;
858
- auto.triggerRef.current?.measureInWindow((_x, y, _width, height) => {
859
- const belowShared = zone.zoneSide === "belowShared";
860
- const awayRoom = belowShared ? windowHeight - insets.bottom - (y + height) - EDGE_MARGIN2 : y - insets.top - EDGE_MARGIN2;
861
- const towardRoom = belowShared ? y - zone.sharedEdgeY - EDGE_MARGIN2 : zone.sharedEdgeY - (y + height) - EDGE_MARGIN2;
862
- const useToward = contentHeight > awayRoom && towardRoom > awayRoom;
863
- let awaySide = belowShared ? "below" : "above";
864
- let towardSide = belowShared ? "above" : "below";
865
- if (zone.rotated) {
866
- awaySide = awaySide === "above" ? "below" : "above";
867
- towardSide = towardSide === "above" ? "below" : "above";
868
- }
869
- setOwnBounds({ maxHeight: Math.max(useToward ? towardRoom : awayRoom, 0), verticalAlign: useToward ? towardSide : awaySide });
870
- });
871
- }, [shouldMeasure, zone, auto.triggerRef, contentHeight, windowHeight, insets.top, insets.bottom]);
872
- if (!zone) return auto;
873
- if (!ownBounds) return { ...auto, measured: false };
874
- return { ...auto, maxHeight: ownBounds.maxHeight, measured: auto.measured, verticalAlign: ownBounds.verticalAlign };
875
- }
876
886
  export {
877
887
  AchievementCatalogSection,
878
888
  AchievementRow,
@@ -907,10 +917,12 @@ export {
907
917
  StatSection,
908
918
  TriggerGaugeHost,
909
919
  UpdateDialog,
920
+ WELL_SHADOW,
910
921
  getColorPopoverId,
911
922
  getCpuDifficultyPopoverId,
912
923
  getInlineColorPickerContentSize,
913
924
  getLabeledDropdownContentHeight,
925
+ getProfilePopoverId,
914
926
  getStaggeredWordDuration,
915
927
  isCompactDialogHeight,
916
928
  loadSkiaWeb,
@@ -921,5 +933,6 @@ export {
921
933
  useOverlayCardColors,
922
934
  usePopoverHost,
923
935
  useQuitConfirmation,
936
+ useWellColors,
924
937
  useZoneClampedAlign
925
938
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
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",
@@ -85,16 +85,16 @@
85
85
  "prettier": "@infinitetoken/eslint-config/prettier",
86
86
  "devDependencies": {
87
87
  "@infinitetoken/eslint-config": "^0.2.0",
88
- "@infinitetoken/jest-config": "^0.4.0",
89
- "@infinitetoken/tsconfig": "^0.5.0",
90
- "@rific/auto-paper": "^0.13.2",
88
+ "@infinitetoken/jest-config": "^0.5.2",
89
+ "@infinitetoken/tsconfig": "^0.6.0",
90
+ "@rific/auto-paper": "^0.13.3",
91
91
  "@rific/core": "^0.3.1",
92
92
  "@rific/feedback-press": "^0.13.0",
93
93
  "@rific/updater": "^0.7.1",
94
- "@shopify/react-native-skia": "^2.6.2",
94
+ "@shopify/react-native-skia": "^2.14.0",
95
95
  "@tastic/achievements": "^0.6.0",
96
96
  "@tastic/core": "^0.14.0",
97
- "@tastic/profile": "^0.13.0",
97
+ "@tastic/profile": "^0.15.0",
98
98
  "@tastic/split-screen": "^0.5.2",
99
99
  "@testing-library/dom": "^10.4.1",
100
100
  "@testing-library/react": "^16.3.2",
@@ -108,10 +108,10 @@
108
108
  "prettier": "^3.8.3",
109
109
  "react": "^19.0.0",
110
110
  "react-dom": "^19.0.0",
111
- "react-native": "^0.86.3",
111
+ "react-native": "^0.88.0",
112
112
  "react-native-paper": "^5.15.2",
113
- "react-native-reanimated": "^4.5.1",
114
- "react-native-safe-area-context": "^5.10.0",
113
+ "react-native-reanimated": "^4.7.1",
114
+ "react-native-safe-area-context": "^5.10.1",
115
115
  "tsup": "^8.0.0",
116
116
  "typescript": "^6.0.3"
117
117
  },
@@ -122,7 +122,7 @@
122
122
  "@shopify/react-native-skia": ">=2.6.0",
123
123
  "@tastic/achievements": ">=0.2.0",
124
124
  "@tastic/core": ">=0.12.0",
125
- "@tastic/profile": ">=0.4.0",
125
+ "@tastic/profile": ">=0.15.0",
126
126
  "@tastic/split-screen": ">=0.5.0",
127
127
  "react": ">=19.0.0",
128
128
  "react-native": ">=0.76.0",
@@ -15,12 +15,13 @@ import { MONO_FONT } from './fonts'
15
15
  interface Props {
16
16
  rows: AchievementCatalogRow[]
17
17
  label?: string
18
- // Independently optional overrides for this section's own heading/row colors - default to the
19
- // same useAutoPaperTheme()-derived formula as AchievementRow/StatSection/StatRow/BaseStatsScreen
20
- // (see BaseStatsScreen's own doc for why) when omitted, so every existing caller renders
21
- // identically to before. Passed straight through to each <AchievementRow>.
18
+ // Independently optional overrides for the heading's and rows' colors. fg and sectionBg go to each
19
+ // <AchievementRow> (whose own defaults are the well's, see wellSurface.ts). fgMuted goes to the heading and
20
+ // the rows; omitted, the heading (drawn on the screen, not on a well) keeps its own screen-derived default
21
+ // and the rows theirs.
22
22
  fg?: string
23
23
  fgMuted?: string
24
+ sectionBg?: string
24
25
  }
25
26
 
26
27
  // The "ALL ACHIEVEMENTS" heading + one <AchievementRow> per row - extracted verbatim from every
@@ -42,7 +43,7 @@ interface Props {
42
43
  // about 1.4:1 and 1.8:1 against the light screen's white, so a bar or check in either barely shows;
43
44
  // mixed, they're about 5:1 and 6:1 (bronze goes from about 3:1 to 9:1). The badge keeps the true tier
44
45
  // color: its glyph is black, which reads on all three.
45
- export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride }: Props) {
46
+ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg, fgMuted: fgMutedOverride, sectionBg }: Props) {
46
47
  const { dark } = useAutoPaperTheme()
47
48
  const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
48
49
  const tierAccent = (tierColor: string) => (dark ? tierColor : getBlendedColor('#000000', tierColor, 0.5))
@@ -53,7 +54,7 @@ export function AchievementCatalogSection({ rows, label = 'ALL ACHIEVEMENTS', fg
53
54
  {label}
54
55
  </Text>
55
56
  {rows.map((row) => (
56
- <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={tierAccent(row.tierColor)} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} />
57
+ <AchievementRow key={row.id} icon={row.icon} title={row.title} description={row.description} badgeColor={row.unlockedAt !== undefined ? row.tierColor : LOCKED_BADGE_COLOR} checkColor={tierAccent(row.tierColor)} unlockedLabel={row.unlockedLabel} progress={row.progress} deviceMarker={row.deviceMarker} fg={fg} fgMuted={fgMutedOverride} sectionBg={sectionBg} />
57
58
  ))}
58
59
  </Fragment>
59
60
  )
@@ -1,7 +1,8 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
2
1
  import { StyleSheet, View } from 'react-native'
3
2
  import { Icon, ProgressBar, Text } from 'react-native-paper'
4
3
 
4
+ import { useWellColors, WELL_SHADOW } from './wellSurface'
5
+
5
6
  // A neutral gray for a locked badge's own background - exported so a caller computing its own
6
7
  // badge color (unlocked ? tierColor : LOCKED_BADGE_COLOR, matching LightCycles' own achievements.tsx)
7
8
  // doesn't need to invent or duplicate this exact tone.
@@ -32,10 +33,9 @@ interface Props {
32
33
  // from a specific profile's own tab - optional since only an app with per-profile stat views has
33
34
  // this concept at all.
34
35
  deviceMarker?: boolean
35
- // Independently optional overrides for this row's own text/background colors - each defaults to
36
- // the same dark-mode-derived formula as StatRow/StatSection/BaseStatsScreen (see that component's
37
- // own doc for why) when omitted, so every existing caller renders identically to before. A caller
38
- // with its own app-wide chrome palette passes whichever of the three it needs.
36
+ // Independently optional overrides for this row's own text/background colors - each defaults to the well's
37
+ // (wellSurface.ts: the theme's surface and its onSurface/onSurfaceVariant text), like StatSection/StatRow. A
38
+ // caller with its own app-wide chrome palette passes whichever of the three it needs.
39
39
  fg?: string
40
40
  fgMuted?: string
41
41
  sectionBg?: string
@@ -44,16 +44,15 @@ interface Props {
44
44
  // One row per achievement - a tier-colored (or locked-gray) badge, title/description, an optional
45
45
  // progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
46
46
  // verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
47
- // "ALL ACHIEVEMENTS" list. fg/fgMuted/its own section background default to the same
48
- // useAutoPaperTheme()-derived formula as StatRow/StatSection/BaseStatsScreen (see BaseStatsScreen's
49
- // own doc for why), each independently overridable via the matching prop for a caller with its own
50
- // chrome palette. Has no opinion on what an achievement actually IS (id, unlock predicate, catalog)
47
+ // "ALL ACHIEVEMENTS" list. Each row is its own well (wellSurface.ts: the theme's surface on MD3's level-1 shadow);
48
+ // fg/fgMuted/its own background default to the well's colors, each independently overridable via the matching
49
+ // prop for a caller with its own chrome palette. Has no opinion on what an achievement actually IS (id, unlock predicate, catalog)
51
50
  // - that stays entirely app-local; this is purely the presentational row.
52
51
  export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props) {
53
- const { dark } = useAutoPaperTheme()
54
- const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
55
- const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
56
- const sectionBg = sectionBgOverride ?? (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
52
+ const well = useWellColors()
53
+ const fg = fgOverride ?? well.fg
54
+ const fgMuted = fgMutedOverride ?? well.fgMuted
55
+ const sectionBg = sectionBgOverride ?? well.bg
57
56
  const unlocked = unlockedLabel !== undefined
58
57
 
59
58
  return (
@@ -116,6 +115,7 @@ const styles = StyleSheet.create({
116
115
  row: {
117
116
  alignItems: 'center',
118
117
  borderRadius: 12,
118
+ boxShadow: WELL_SHADOW,
119
119
  flexDirection: 'row',
120
120
  gap: 12,
121
121
  padding: 12
@@ -9,6 +9,11 @@ interface ActivityStats {
9
9
 
10
10
  interface Props {
11
11
  stats: ActivityStats
12
+ // Optional overrides for the section's and its rows' colors, passed straight through to StatSection and each
13
+ // StatRow (whose own defaults are the well's, see wellSurface.ts) - for a caller with its own chrome palette.
14
+ fg?: string
15
+ fgMuted?: string
16
+ sectionBg?: string
12
17
  }
13
18
 
14
19
  // The "ACTIVITY" StatSection (Days Played / Day Streak / Best Day Streak) - extracted verbatim
@@ -18,12 +23,12 @@ interface Props {
18
23
  // currentDayStreak/bestDayStreak) rather than importing DayStreakState itself, so this component
19
24
  // has no dependency on @tastic/achievements at all: any caller's own stats/statsView object that
20
25
  // happens to carry these three fields already satisfies it structurally.
21
- export function ActivityStatSection({ stats }: Props) {
26
+ export function ActivityStatSection({ stats, fg, fgMuted, sectionBg }: Props) {
22
27
  return (
23
- <StatSection label='ACTIVITY'>
24
- <StatRow label='Days Played' value={String(stats.distinctDaysPlayed)} />
25
- <StatRow label='Day Streak' value={String(stats.currentDayStreak)} />
26
- <StatRow label='Best Day Streak' value={String(stats.bestDayStreak)} />
28
+ <StatSection label='ACTIVITY' fg={fg} sectionBg={sectionBg}>
29
+ <StatRow label='Days Played' value={String(stats.distinctDaysPlayed)} fg={fg} fgMuted={fgMuted} />
30
+ <StatRow label='Day Streak' value={String(stats.currentDayStreak)} fg={fg} fgMuted={fgMuted} />
31
+ <StatRow label='Best Day Streak' value={String(stats.bestDayStreak)} fg={fg} fgMuted={fgMuted} />
27
32
  </StatSection>
28
33
  )
29
34
  }
@@ -1,8 +1,8 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
1
+ import { FadingScrollView, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { Button, IconButton } from '@rific/feedback-press'
3
3
  import { useRotation, type ViewRotation } from '@tastic/core'
4
4
  import { ReactNode, useState } from 'react'
5
- import { type Insets, ScrollView, StyleSheet, View } from 'react-native'
5
+ import { type Insets, StyleSheet, View } from 'react-native'
6
6
  import { Text } from 'react-native-paper'
7
7
 
8
8
  import { ConfirmDialog } from './ConfirmDialog'
@@ -101,7 +101,9 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
101
101
  </Text>
102
102
  </View>
103
103
 
104
- <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
104
+ {/* Fades out at an edge while there is more past it, the same cue as the Settings sheet's rows and the
105
+ Profiles screen's (Jay, 2026-10-16: every scrolling screen alike). */}
106
+ <FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false}>
105
107
  {children}
106
108
 
107
109
  {showReset && (
@@ -109,7 +111,7 @@ export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achieveme
109
111
  {resetLabel}
110
112
  </Button>
111
113
  )}
112
- </ScrollView>
114
+ </FadingScrollView>
113
115
 
114
116
  <ConfirmDialog
115
117
  visible={confirmResetVisible}
@@ -147,7 +149,7 @@ const styles = StyleSheet.create({
147
149
  resetButton: {
148
150
  marginTop: 16
149
151
  },
150
- // Bounds the ScrollView to the space `container`'s flex:1 actually gives it - without this, a
152
+ // Bounds the scroller (FadingScrollView puts it on its outer box) to the space `container`'s flex:1 actually gives it - without this, a
151
153
  // ScrollView with only a contentContainerStyle isn't reliably height-constrained on native (it
152
154
  // can render at its full unclipped content height instead of the screen's), which leaves the
153
155
  // header's back button touch target unreliable underneath it.