@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.
@@ -1,31 +1,35 @@
1
1
  import { SeedColor } from '@rific/auto-paper'
2
- import { Profile, ProfilePicker } from '@tastic/profile'
2
+ import { getOptionPickerContentSize, getProfilePickerContentSize, OptionPicker, Profile, ProfilePicker, type ProfilePickerColorSection } from '@tastic/profile'
3
3
  import { ReactNode } from 'react'
4
4
  import { StyleSheet, View } from 'react-native'
5
5
  import { Text } from 'react-native-paper'
6
6
 
7
7
  import { InlineColorPicker } from './InlineColorPicker'
8
- import { LabeledDropdown, LabeledDropdownOption } from './LabeledDropdown'
8
+ import { LabeledDropdownOption } from './LabeledDropdown'
9
9
  import { ReadyButton } from './ReadyButton'
10
10
  import { AlignResult, PopoverRotation } from './useAutoAlign'
11
11
  import { PopoverHost, usePopoverHost } from './usePopoverHost'
12
+ import { useZoneClampedAlign } from './useZoneClampedAlign'
12
13
 
13
- // This panel's own internal color/CPU-difficulty popover ids, derived from idPrefix - exported so a
14
- // caller that needs to know whether one of them is currently open (e.g. to compute the `open`
15
- // argument useZoneClampedAlign's own first parameter needs, ahead of handing its result to
16
- // colorAlignOverride/cpuDifficultyAlignOverride below) doesn't have to hardcode or guess this
17
- // panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
14
+ // This panel's own internal popover ids, derived from idPrefix - exported so a caller that needs to
15
+ // know whether one of them is currently open (e.g. to elevate the zone or row holding the panel)
16
+ // doesn't have to hardcode or guess this panel's own internal naming convention. Matches secondPickerId's own job for a caller's own
18
17
  // externally-constructed picker - this is the equivalent for the two pickers this panel owns
19
18
  // itself, which have no prop of their own to name their id with.
20
19
  export function getColorPopoverId(idPrefix: string): string {
21
20
  return `${idPrefix}-color`
22
21
  }
22
+ // The seat button's dropdown on a human seat (@tastic/profile's ProfilePicker opens under
23
+ // `${idPrefix}-profile`). A CPU seat's opens under getCpuDifficultyPopoverId.
24
+ export function getProfilePopoverId(idPrefix: string): string {
25
+ return `${idPrefix}-profile`
26
+ }
23
27
  export function getCpuDifficultyPopoverId(idPrefix: string): string {
24
28
  return `${idPrefix}-difficulty`
25
29
  }
26
30
 
27
31
  export interface PlayerSetupPanelProps<C extends string = string> {
28
- // Namespaces this panel's popover ids ('color', whatever secondPickerId names, 'difficulty') so
32
+ // Namespaces this panel's popover ids ('profile', 'difficulty', 'color', whatever secondPickerId names) so
29
33
  // two panels can safely share one host (see `host` below) without their ids colliding.
30
34
  idPrefix: string
31
35
  // Shared popover host, when this panel's own popovers should be mutually exclusive with another
@@ -35,7 +39,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
35
39
  // real people editing at once is the point.
36
40
  host?: PopoverHost
37
41
  label?: string
38
- // Applied only to the plain `label` fallback text below (ProfilePicker/LabeledDropdown render
42
+ // Applied only to the plain `label` fallback text below (ProfilePicker/OptionPicker render
39
43
  // their own fixed chrome regardless of this prop) - omit to inherit whichever font the calling
40
44
  // screen's own theme resolves for a labelSmall Text, rather than this package forcing its own
41
45
  // MONO_FONT onto what's really the host app's own copy.
@@ -44,21 +48,24 @@ export interface PlayerSetupPanelProps<C extends string = string> {
44
48
  onColorChange: (hex: string) => void
45
49
  swatches: SeedColor[]
46
50
  // The other player's current color - stays visible in the swatch grid but disabled, rather than
47
- // removed from it entirely. Unless allowSwapTaken is set - see InlineColorPicker's own doc.
51
+ // removed from it entirely. Unless allowSwapTaken is set (the other seat is the CPU), which lets
52
+ // it be picked so the caller can swap the two - see InlineColorPicker's own doc.
48
53
  takenColor?: string
49
54
  allowSwapTaken?: boolean
50
- // Passed straight through to the internal InlineColorPicker's own `alignOverride` - this panel
51
- // has no way for a caller to reach that picker directly (it's not a ReactNode slot the way
52
- // secondPicker is, see that prop's own doc), so a caller inside a @tastic/split-screen zone
53
- // needs this to substitute useZoneClampedAlign's result for the color picker's own popover.
55
+ // A short line under the seat button's Color row while a profile is selected, saying where the
56
+ // color applies (e.g. 'Snake only': the pick is kept in this game, not on the profile). Left out
57
+ // for a guest or the CPU, who have no color anywhere else.
58
+ colorCaption?: string
59
+ // False leaves the Color row out of the seat button's dropdown (e.g. a CPU seat set to "None",
60
+ // with no second player to color). Defaults to true.
61
+ showColor?: boolean
62
+ // The fallback color swatch's placement and rotation, passed straight through to its
63
+ // InlineColorPicker. That swatch is only drawn on a seat with neither a profile picker nor a CPU
64
+ // difficulty picker (see the header below); the seat button places its own dropdown.
54
65
  colorAlignOverride?: AlignResult
55
- // Passed straight through to the internal InlineColorPicker's own `rotation` - same reasoning as
56
- // colorAlignOverride above (no other way for a caller to reach that picker directly), for a
57
- // caller whose own rotation reading needs to differ from InlineColorPicker's default ambient
58
- // useRotation() read (see that component's own `rotation` doc for when that's worth doing).
59
66
  colorRotation?: PopoverRotation
60
67
  isHuman: boolean
61
- // Whatever sits beside the color picker in pickerRow, if anything - a ControlSchemePicker, a raw
68
+ // Whatever sits under the seat button in pickerRow, if anything - a ControlSchemePicker, a raw
62
69
  // SectionedDropdown, or nothing at all. The five apps this component was extracted from each
63
70
  // duplicated this entire panel and differed only in what (if anything) occupied this one slot,
64
71
  // each wanting a genuinely different concept there (which physical input drives a seat, vs. a
@@ -69,7 +76,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
69
76
  secondPicker?: ReactNode
70
77
  // The popover id secondPicker opens under, if it opens one at all - lets pickerRow's own
71
78
  // "elevate above later siblings while one of my own popovers is open" logic (see pickerRowOpen)
72
- // react to secondPicker's popover the same way it already does for the color picker's, without
79
+ // react to secondPicker's popover the same way it already does for the fallback swatch's, without
73
80
  // this component needing any opinion on what secondPicker actually renders. Omit when secondPicker
74
81
  // has no popover of its own, or isn't provided at all.
75
82
  secondPickerId?: string
@@ -82,7 +89,7 @@ export interface PlayerSetupPanelProps<C extends string = string> {
82
89
  profiles?: Profile[]
83
90
  selectedProfileId?: string | null
84
91
  takenProfileId?: string | null
85
- // What the name trigger shows before any profile's selected - this seat's own 'P1'/'P2' (see
92
+ // What the seat button shows before any profile's selected - this seat's own 'P1'/'P2' (see
86
93
  // ProfilePicker's own doc for why it's never "Player"). Required whenever profiles is provided,
87
94
  // since the trigger always needs some idle text.
88
95
  guestLabel?: string
@@ -95,29 +102,30 @@ export interface PlayerSetupPanelProps<C extends string = string> {
95
102
  cpuDifficulty?: C
96
103
  cpuDifficultyOptions?: LabeledDropdownOption<C>[]
97
104
  onCpuDifficultyChange?: (value: C) => void
98
- // Passed straight through to the internal LabeledDropdown's own `alignOverride` - same reasoning
99
- // as colorAlignOverride above, for the CPU-difficulty picker instead of the color picker.
100
- cpuDifficultyAlignOverride?: AlignResult
101
- // InlineColorPicker's own `icon` for a human vs. a non-human seat (overridden by a selected
102
- // profile's own `tag`, same precedence InlineColorPicker itself applies) - defaults match every
103
- // consumer's own convention. A caller with its own extra CPU states (e.g. a "no CPU" slot) computes
104
- // its own icon string and passes it through cpuIcon rather than this component needing any opinion
105
- // on what those states are.
105
+ // The icon on a non-human seat's button chip, and on the fallback color swatch for a human vs. a
106
+ // non-human seat (overridden there by a selected profile's own `tag`). A human seat's button chip
107
+ // shows the selected profile's tag, or ProfilePicker's guest icon. A caller with its own extra CPU
108
+ // states (e.g. a "no CPU" slot) computes its own icon string and passes it through cpuIcon rather
109
+ // than this component needing any opinion on what those states are.
106
110
  humanIcon?: string
107
111
  cpuIcon?: string
108
112
  }
109
113
 
110
- // One panel per player slot in a local-multiplayer loadout/lobby screen: a name header (a saved
111
- // profile switcher for a human seat, a CPU-difficulty picker for a non-human one, or a plain label
112
- // when neither applies), a color picker optionally paired with a second, per-app picker (see
113
- // `secondPicker`'s own doc), and a Ready toggle for human seats. Ported from five apps' own
114
- // near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx (AirHockey, BoxHockey, Pong, LightCycles,
115
- // Snake), which had all independently converged on this exact skeleton.
114
+ // One panel per player slot in a local-multiplayer loadout/lobby screen: one seat button, an
115
+ // optional second, per-app picker under it (see `secondPicker`'s own doc), and a Ready toggle for
116
+ // human seats. A human seat's button is @tastic/profile's ProfilePicker and a CPU seat's is its
117
+ // OptionPicker (the difficulty levels), each with the seat's color behind a Color row at the top of
118
+ // the same dropdown: Solitaire's player button, made the fleet's seat control (Jay, 2026-10-16). It
119
+ // used to be a profile (or difficulty) dropdown with a separate color swatch beside it, which read
120
+ // as two unrelated things. A seat with neither picker wired keeps the plain label and the separate
121
+ // swatch. Ported from five apps' own near-identical PlayerSetupPanel.tsx/LobbyPlayerPanel.tsx
122
+ // (AirHockey, BoxHockey, Pong, LightCycles, Snake), which had all independently converged on this
123
+ // exact skeleton.
116
124
  //
117
- // Owns its own popover host shared by its own color/profile/difficulty pickers unless a `host` is
118
- // passed in to share with another panel (or a shared-controls row) instead - same convention as
119
- // every popover-hosting component in this package.
120
- export function PlayerSetupPanel<C extends string = string>({ 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' }: PlayerSetupPanelProps<C>) {
125
+ // Owns its own popover host shared by its own pickers unless a `host` is passed in to share with
126
+ // another panel (or a shared-controls row) instead - same convention as every popover-hosting
127
+ // component in this package.
128
+ export function PlayerSetupPanel<C extends string = string>({ 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' }: PlayerSetupPanelProps<C>) {
121
129
  const ownHost = usePopoverHost()
122
130
  const popover = host ?? ownHost
123
131
  // Checking *this panel's own* ids, not just "is anything open on the host" - when `host` is
@@ -126,22 +134,33 @@ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, la
126
134
  // letting DOM order (wrongly) decide which one paints on top.
127
135
  const ownPopoverOpen = popover.openId?.startsWith(`${idPrefix}-`) ?? false
128
136
  // Narrower than ownPopoverOpen on purpose - only true for a popover actually inside pickerRow
129
- // itself (color, or secondPicker's own popover when it has one and secondPickerId names it). See
130
- // pickerRowOpen's own style comment for why pickerRow needs this in addition to panelOpen above.
137
+ // itself (the fallback color swatch, or secondPicker's own popover when it has one and
138
+ // secondPickerId names it). See pickerRowOpen's own style comment for why pickerRow needs this in
139
+ // addition to panelOpen above.
131
140
  const pickerRowPopoverOpen = popover.openId === getColorPopoverId(idPrefix) || (secondPickerId !== undefined && popover.openId === secondPickerId)
132
141
  const showProfilePicker = isHuman && profiles !== undefined && !!onProfileSelect
133
142
  const showCpuDifficulty = !isHuman && cpuDifficulty !== undefined && cpuDifficultyOptions !== undefined && !!onCpuDifficultyChange
134
- // The color button's own tag display (see InlineColorPicker) - read live from the selected
135
- // profile rather than snapshotted at selection time, since a caller typically has no other
136
- // editing surface for it on this same screen.
143
+ const seatButton = showProfilePicker || showCpuDifficulty
144
+ // Read live from the selected profile rather than snapshotted at selection time, since a caller
145
+ // typically has no other editing surface for it on this same screen.
137
146
  const selectedProfile = profiles?.find((p) => p.id === selectedProfileId) ?? null
138
147
 
148
+ const colorSection: ProfilePickerColorSection | undefined = showColor ? { value: color, onChange: onColorChange, swatches, takenValue: takenColor, allowSwapTaken, caption: showProfilePicker && selectedProfile ? colorCaption : undefined } : undefined
149
+ // The seat button's dropdown is placed here rather than by the caller: inside a
150
+ // @tastic/split-screen zone (2 Player) it has to stay in that player's half, which
151
+ // useZoneClampedAlign does, and outside one it's useAutoAlign's own placement. Sized from the same
152
+ // section and rows the picker draws. Rotation 0, the same as the profile, color and difficulty
153
+ // dropdowns this button replaced were all placed with.
154
+ const seatPickerId = showProfilePicker ? getProfilePopoverId(idPrefix) : getCpuDifficultyPopoverId(idPrefix)
155
+ const seatPickerSize = showProfilePicker ? getProfilePickerContentSize(profiles!.length, !!onManageProfiles, colorSection) : getOptionPickerContentSize(cpuDifficultyOptions?.length ?? 0, colorSection)
156
+ const seatPickerAlign = useZoneClampedAlign(seatButton && popover.openId === seatPickerId, seatPickerSize.width, seatPickerSize.height)
157
+
139
158
  return (
140
159
  <View style={[styles.panel, ownPopoverOpen && styles.panelOpen]}>
141
160
  {showProfilePicker ? (
142
- <ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} />
161
+ <ProfilePicker idPrefix={idPrefix} host={popover} profiles={profiles!} selectedId={selectedProfileId ?? null} takenId={takenProfileId} color={color} dark={dark} guestLabel={guestLabel ?? 'GUEST'} onSelect={(profile: Profile | null) => onProfileSelect?.(profile)} onManage={onManageProfiles} colorSection={colorSection} alignOverride={seatPickerAlign} />
143
162
  ) : showCpuDifficulty ? (
144
- <LabeledDropdown id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} alignOverride={cpuDifficultyAlignOverride} />
163
+ <OptionPicker id={getCpuDifficultyPopoverId(idPrefix)} host={popover} options={cpuDifficultyOptions!} value={cpuDifficulty!} onChange={onCpuDifficultyChange!} color={color} dark={dark} icon={cpuIcon} colorSection={colorSection} alignOverride={seatPickerAlign} />
145
164
  ) : (
146
165
  label && (
147
166
  <Text variant='labelSmall' style={{ color, fontFamily: labelFontFamily }}>
@@ -150,14 +169,16 @@ export function PlayerSetupPanel<C extends string = string>({ idPrefix, host, la
150
169
  )
151
170
  )}
152
171
 
153
- <View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
154
- {/* Human slots show the selected profile's own tag when it has one, falling back to
155
- humanIcon; a non-human slot always falls back to cpuIcon - this is what actually
156
- distinguishes "you" from "the CPU" now, not a fixed icon. */}
157
- <InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />
172
+ {(!seatButton || secondPicker) && (
173
+ <View style={[styles.pickerRow, pickerRowPopoverOpen && styles.pickerRowOpen]}>
174
+ {/* The fallback swatch, for a seat with no button: a human slot shows the selected
175
+ profile's own tag when it has one, falling back to humanIcon; a non-human slot always
176
+ falls back to cpuIcon. */}
177
+ {!seatButton && <InlineColorPicker id={getColorPopoverId(idPrefix)} host={popover} value={color} onChange={onColorChange} swatches={swatches} takenValue={takenColor} allowSwapTaken={allowSwapTaken} alignOverride={colorAlignOverride} rotation={colorRotation} dark={dark} tag={isHuman ? selectedProfile?.tag : undefined} icon={isHuman ? humanIcon : cpuIcon} />}
158
178
 
159
- {secondPicker}
160
- </View>
179
+ {secondPicker}
180
+ </View>
181
+ )}
161
182
 
162
183
  {isHuman && showReadyButton && onToggleReady && <ReadyButton color={color} ready={ready ?? false} onToggleReady={onToggleReady} />}
163
184
  </View>
@@ -175,9 +196,9 @@ const styles = StyleSheet.create({
175
196
  panelOpen: {
176
197
  zIndex: 100
177
198
  },
178
- // Same gap as `panel`'s own vertical rhythm, reused horizontally - the color picker and (when
179
- // present) secondPicker sit side by side within this row instead of stacked in the panel's own
180
- // column.
199
+ // Same gap as `panel`'s own vertical rhythm, reused horizontally - the fallback color swatch and
200
+ // (when present) secondPicker sit side by side within this row instead of stacked in the panel's
201
+ // own column.
181
202
  pickerRow: {
182
203
  alignItems: 'center',
183
204
  flexDirection: 'row',
package/src/StatRow.tsx CHANGED
@@ -1,29 +1,28 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
2
1
  import { StyleSheet, View } from 'react-native'
3
2
  import { Text } from 'react-native-paper'
4
3
 
4
+ import { useWellColors } from './wellSurface'
5
+
5
6
  interface Props {
6
7
  label: string
7
8
  value: string
8
- // Independently optional overrides for this row's own text colors - default to the same
9
- // useAutoPaperTheme()-derived formula as BaseStatsScreen (see that component's own doc for why)
10
- // when omitted, so every existing caller renders identically to before.
9
+ // Independently optional overrides for this row's own text colors - default to the text colors of the well it
10
+ // sits in (wellSurface.ts: onSurface, onSurfaceVariant).
11
11
  fg?: string
12
12
  fgMuted?: string
13
13
  }
14
14
 
15
15
  // A single labeled stat - label on the left (muted), value on the right (bold, full-contrast).
16
16
  // Extracted from LightCycles' own achievements.tsx, where this exact row shape backed every
17
- // section (Overall/Vs CPU/Two Player/Activity). fg/fgMuted default to the same
18
- // useAutoPaperTheme()-derived formula as BaseStatsScreen (see that component's own doc for why),
19
- // each independently overridable via the matching prop for a caller with its own chrome palette.
17
+ // section (Overall/Vs CPU/Two Player/Activity). fg/fgMuted default to the text colors of the StatSection well it
18
+ // sits in, each independently overridable via the matching prop for a caller with its own chrome palette.
20
19
  // Deliberately just label+value text, not a color swatch or profile chip - an app needing a row
21
- // with a leading visual builds its own on top of this same dark-mode formula (see LightCycles' own
20
+ // with a leading visual builds its own on top of the same useWellColors() (see LightCycles' own
22
21
  // ColorStatRow/ProfileRankingRow for an example), since what that visual even is varies per game.
23
22
  export function StatRow({ label, value, fg: fgOverride, fgMuted: fgMutedOverride }: Props) {
24
- const { dark } = useAutoPaperTheme()
25
- const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
- const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
23
+ const well = useWellColors()
24
+ const fg = fgOverride ?? well.fg
25
+ const fgMuted = fgMutedOverride ?? well.fgMuted
27
26
 
28
27
  return (
29
28
  <View style={styles.row}>
@@ -1,29 +1,28 @@
1
- import { useAutoPaperTheme } from '@rific/auto-paper'
2
1
  import { ReactNode } from 'react'
3
2
  import { StyleSheet, View } from 'react-native'
4
3
  import { Text } from 'react-native-paper'
5
4
 
5
+ import { useWellColors, WELL_SHADOW } from './wellSurface'
6
+
6
7
  interface Props {
7
8
  label: string
8
- // Independently optional overrides for this section's own label color/background - default to
9
- // the same useAutoPaperTheme()-derived formula as StatRow/BaseStatsScreen (see BaseStatsScreen's
10
- // own doc for why) when omitted, so every existing caller renders identically to before.
9
+ // Independently optional overrides for this section's own label color/background - default to the well's
10
+ // (wellSurface.ts: the theme's surface and its onSurface text).
11
11
  fg?: string
12
12
  sectionBg?: string
13
13
  children: ReactNode
14
14
  }
15
15
 
16
- // A labeled, tinted-background grouping box - the "OVERALL"/"VS CPU"/"ACTIVITY"-style section
17
- // shape from LightCycles' own achievements.tsx, generalized to any content (typically a stack of
18
- // StatRow). fg/sectionBg default to the same useAutoPaperTheme()-derived formula as
19
- // StatRow/BaseStatsScreen (see BaseStatsScreen's own doc for why), each independently overridable
20
- // via the matching prop for a caller with its own chrome palette. The label itself carries the
21
- // section's own meaning (what it's a section OF), so this component has no opinion on that beyond
22
- // rendering the string it's given.
16
+ // A labeled grouping box, a well (wellSurface.ts: the theme's surface on MD3's level-1 shadow) - the
17
+ // "OVERALL"/"VS CPU"/"ACTIVITY"-style section shape from LightCycles' own achievements.tsx, generalized to any
18
+ // content (typically a stack of StatRow). fg/sectionBg default to the well's colors, each independently
19
+ // overridable via the matching prop for a caller with its own chrome palette. It was a faint tint until
20
+ // 2026-10-17 (6% white in dark mode, 4% black in light). The label itself carries the section's own meaning
21
+ // (what it's a section OF), so this component has no opinion on that beyond rendering the string it's given.
23
22
  export function StatSection({ label, fg: fgOverride, sectionBg: sectionBgOverride, children }: Props) {
24
- const { dark } = useAutoPaperTheme()
25
- const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
- const sectionBg = sectionBgOverride ?? (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
23
+ const well = useWellColors()
24
+ const fg = fgOverride ?? well.fg
25
+ const sectionBg = sectionBgOverride ?? well.bg
27
26
 
28
27
  return (
29
28
  <View style={[styles.section, { backgroundColor: sectionBg }]}>
@@ -46,6 +45,7 @@ const styles = StyleSheet.create({
46
45
  },
47
46
  section: {
48
47
  borderRadius: 12,
48
+ boxShadow: WELL_SHADOW,
49
49
  gap: 8,
50
50
  padding: 16
51
51
  }
@@ -23,3 +23,4 @@ export { StatRow } from '../StatRow'
23
23
  export { StatSection } from '../StatSection'
24
24
  export { UpdateDialog, type UpdateDialogProps } from '../UpdateDialog'
25
25
  export { useCompactDialog } from '../useCompactDialog'
26
+ export { useWellColors, WELL_SHADOW, type WellColors } from '../wellSurface'
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, Labele
16
16
  export { loadSkiaWeb } from './loadSkiaWeb'
17
17
  export { overlayActionStyles, overlayStyles } from './overlayCard'
18
18
  export { OverlayBackdrop, type OverlayBackdropProps, OverlayCard, type OverlayCardProps, type OverlayCardThemeColors, useOverlayCardColors } from './OverlaySurface'
19
- export { getColorPopoverId, getCpuDifficultyPopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
19
+ export { getColorPopoverId, getCpuDifficultyPopoverId, getProfilePopoverId, PlayerSetupPanel, type PlayerSetupPanelProps } from './PlayerSetupPanel'
20
20
  export { PopoverBody } from './PopoverBody'
21
21
  export { PressAwayOverlay } from './PressAwayOverlay'
22
22
  export { ReadyButton } from './ReadyButton'
@@ -42,3 +42,4 @@ export { useCompactDialog } from './useCompactDialog'
42
42
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
43
43
  export { type QuitConfirmation, useQuitConfirmation } from './useQuitConfirmation'
44
44
  export { useZoneClampedAlign } from './useZoneClampedAlign'
45
+ export { useWellColors, WELL_SHADOW, type WellColors } from './wellSurface'
@@ -0,0 +1,30 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+
3
+ // A well is a resting card on a screen: a stats section, an achievement row, a game's menu tile. Every well in the
4
+ // fleet is the theme's plain surface (the same surface as the dialogs, OverlayCard and auto-paper's Dialog) lifted off
5
+ // the screen by MD3's level-1 shadow, with no elevation tint (Jay, 2026-10-17: Solitaire's Achievements wells read as
6
+ // a different shade from its Home tiles, and he picked "shadow, no tint" over MD3's tonal elevation, which washes the
7
+ // surface with the primary like the dialogs no longer are).
8
+ //
9
+ // The shadow is what makes a well show on a screen the color of the surface: MD3 (as react-native-paper implements it)
10
+ // gives `background` and `surface` the same tone, and BaseStatsScreen's light-mode #FFFFFF is the same as the light
11
+ // surface to the eye. In dark mode the surface is already lighter than a black screen, and the shadow barely shows.
12
+
13
+ // MD3's level-1 elevation shadow (its web spec's two layers: a tight key shadow and a soft ambient one). boxShadow is
14
+ // drawn outside the view on iOS, Android and web alike, so a well's own background is never darkened by it.
15
+ export const WELL_SHADOW = '0px 1px 2px rgba(0, 0, 0, 0.3), 0px 1px 3px 1px rgba(0, 0, 0, 0.15)'
16
+
17
+ export interface WellColors {
18
+ // The well's own background.
19
+ bg: string
20
+ // Text on the well: titles and values, then labels and descriptions.
21
+ fg: string
22
+ fgMuted: string
23
+ }
24
+
25
+ // The colors of a well and the text on it, from the app's own theme: the same roles the dialogs' text uses
26
+ // (useOverlayCardColors).
27
+ export function useWellColors(): WellColors {
28
+ const { colors } = useAutoPaperTheme()
29
+ return { bg: colors.surface, fg: colors.onSurface, fgMuted: colors.onSurfaceVariant }
30
+ }