@tastic/hud 0.4.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/hud",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -63,14 +63,14 @@
63
63
  },
64
64
  "prettier": "@infinitetoken/eslint-config/prettier",
65
65
  "devDependencies": {
66
- "@infinitetoken/eslint-config": "^0.1.8",
66
+ "@infinitetoken/eslint-config": "0.2.0",
67
67
  "@infinitetoken/jest-config": "^0.2.0",
68
- "@infinitetoken/tsconfig": "^0.3.0",
68
+ "@infinitetoken/tsconfig": "^0.4.1",
69
69
  "@rific/auto-paper": "^0.10.3",
70
70
  "@rific/feedback-press": "^0.10.8",
71
71
  "@rific/updater": "^0.4.2",
72
72
  "@shopify/react-native-skia": "^2.6.2",
73
- "@tastic/core": "^0.1.5",
73
+ "@tastic/core": "^0.2.0",
74
74
  "@testing-library/dom": "^10.4.1",
75
75
  "@testing-library/react": "^16.3.2",
76
76
  "@types/jest": "^30.0.0",
@@ -32,20 +32,28 @@ interface Props {
32
32
  // from a specific profile's own tab — optional since only an app with per-profile stat views has
33
33
  // this concept at all.
34
34
  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.
39
+ fg?: string
40
+ fgMuted?: string
41
+ sectionBg?: string
35
42
  }
36
43
 
37
44
  // One row per achievement — a tier-colored (or locked-gray) badge, title/description, an optional
38
45
  // progress bar while locked, and either an unlocked check-mark+label or a lock icon. Extracted
39
46
  // verbatim from LightCycles' own achievements.tsx, where this exact row shape backed its entire
40
- // "ALL ACHIEVEMENTS" list. Derives fg/fgMuted/its own section background from useAutoPaperTheme(),
41
- // same as StatRow/StatSection/BaseStatsScreen see BaseStatsScreen's own doc for why. Has no
42
- // opinion on what an achievement actually IS (id, unlock predicate, catalog) that stays entirely
43
- // app-local; this is purely the presentational row.
44
- export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker }: Props) {
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)
51
+ // that stays entirely app-local; this is purely the presentational row.
52
+ export function AchievementRow({ icon, title, description, badgeColor, unlockedLabel, checkColor, progress, deviceMarker, fg: fgOverride, fgMuted: fgMutedOverride, sectionBg: sectionBgOverride }: Props) {
45
53
  const { dark } = useAutoPaperTheme()
46
- const fg = dark ? '#FFFFFF' : '#000000'
47
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
48
- const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
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)')
49
57
  const unlocked = unlockedLabel !== undefined
50
58
 
51
59
  return (
@@ -334,25 +334,25 @@ const styles = StyleSheet.create({
334
334
  sectionLabel: {
335
335
  letterSpacing: 2
336
336
  },
337
- // Negative margin cancels the padding so the icon still lines up with APPEARANCE/SOUND &
338
- // HAPTICS below, while the ripple/hover highlight itself gets room to breathe on both sides
339
- // instead of a flush edge-to-edge slab. overflow: 'hidden' makes sure that highlight actually
340
- // clips to borderRadius instead of drawing as a plain rectangle.
337
+ // Used to carry marginHorizontal: -12 + paddingHorizontal: 12 (netting to the same inset as
338
+ // APPEARANCE/SOUND & HAPTICS below, while giving the ripple/hover highlight room to breathe
339
+ // into the dialog's own gutter beyond that). Removed: this dialog scrolls (Dialog.ScrollArea +
340
+ // ScrollView above), and on web a ScrollView's own content wrapper clips to its own bounds
341
+ // regardless of a child's negative margin - confirmed live, the icon's own rounded-square badge
342
+ // was getting its left edge sliced off by exactly that overhang. No horizontal inset of its own
343
+ // now - it relies purely on the same dialog padding APPEARANCE/every other row already does,
344
+ // which can never overflow because it's never negative.
341
345
  toggleButton: {
342
346
  borderRadius: 12,
343
- marginHorizontal: -12,
344
347
  overflow: 'hidden',
345
- paddingHorizontal: 12,
346
348
  paddingVertical: 10
347
349
  },
348
- // Same shape as toggleButton but `flex: 1` instead of its own `marginHorizontal: -12` two of
349
- // these side by side in toggleRow would otherwise both pull inward and collide in the middle.
350
- // toggleRow carries that edge-alignment margin once, for the row as a whole, instead.
350
+ // Same shape as toggleButton, `flex: 1` instead of its own width - two of these side by side in
351
+ // toggleRow share the row's own edge alignment instead of each carrying their own.
351
352
  toggleButtonInRow: {
352
353
  borderRadius: 12,
353
354
  flex: 1,
354
355
  overflow: 'hidden',
355
- paddingHorizontal: 12,
356
356
  paddingVertical: 10
357
357
  },
358
358
  toggleContent: {
@@ -363,9 +363,9 @@ const styles = StyleSheet.create({
363
363
  toggleGroup: {
364
364
  gap: 4
365
365
  },
366
+ // No horizontal margin of its own now either - see toggleButton's identical note.
366
367
  toggleRow: {
367
368
  flexDirection: 'row',
368
- gap: 4,
369
- marginHorizontal: -12
369
+ gap: 4
370
370
  }
371
371
  })
@@ -4,6 +4,11 @@ import { ReactNode, useState } from 'react'
4
4
  import { ScrollView, StyleSheet, View } from 'react-native'
5
5
  import { Icon, Portal, Text } from 'react-native-paper'
6
6
 
7
+ // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
8
+ // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point) just
9
+ // to type one optional prop.
10
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
11
+
7
12
  interface Props {
8
13
  // Live physical-hold rotation (see @tastic/split-screen's getViewRotation) — this dialog renders
9
14
  // as a centered Portal modal, unaffected by whichever screen's own FakeLandscapeView wraps its
@@ -13,6 +18,13 @@ interface Props {
13
18
  // don't wrap it in a FakeLandscapeView at all and so never have a nonzero one to give.
14
19
  rotation?: number
15
20
  title?: string
21
+ // 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
22
+ // display tier's own smallest step), then 'headlineLarge', both still read as oversized for a
23
+ // header that's paired with a small 24px back-button glyph rather than standing alone the way a
24
+ // title screen's own wordmark does; headlineSmall sits close to that icon's own size instead of
25
+ // towering over it. Any react-native-paper MD3 Text variant, not just the headline tier, for a
26
+ // caller that wants something else entirely.
27
+ titleVariant?: MD3TextVariant
16
28
  onBack: () => void
17
29
  insets: { top: number; left: number; bottom: number; right: number }
18
30
  // Optional, and always passed as a pair — a caller with nothing resettable (or that deliberately
@@ -23,38 +35,60 @@ interface Props {
23
35
  resetLabel?: string
24
36
  resetConfirmTitle?: string
25
37
  resetConfirmBody?: string
38
+ // Independently optional overrides for this screen's own background/text colors — each defaults
39
+ // to the literal-black/white-by-appearance formula described below when omitted, so every
40
+ // existing caller (none of which pass these) renders identically to before. A caller with its own
41
+ // app-wide chrome palette (e.g. a tinted background rather than neutral black/white) passes
42
+ // whichever of the three it needs; cardBorder on the reset-confirm overlay stays fixed regardless,
43
+ // since its low alpha already reads fine against any cardBg.
44
+ fg?: string
45
+ bg?: string
46
+ cardBg?: string
47
+ // Overrides the confirm-overlay's alert icon/title, which otherwise default to auto-paper's
48
+ // colors.secondary. That default is tuned to read against auto-paper's OWN background role, not
49
+ // necessarily against a cardBg override above — a caller passing cardBg should generally pass a
50
+ // matching accentColor too, rather than risk colors.secondary landing close in hue/lightness to
51
+ // its own custom card background (this is what actually happened with a tinted felt cardBg in the
52
+ // app this was first overridden for). Every button on this screen (the reset trigger included)
53
+ // fills with colors.secondary/onSecondary as a self-contained, always-legible pair instead of
54
+ // relying on a text-only color read against the surrounding chrome, so none of them need this —
55
+ // only the bare icon/title do.
56
+ accentColor?: string
26
57
  children: ReactNode
27
58
  }
28
59
 
29
60
  // The stats/achievements screen shell every @tastic game shares — back button + title header,
30
61
  // scrollable content area, and an optional reset-everything action with its own confirm-before-
31
62
  // destroying overlay — extracted after the same header/scroll/reset-confirm shape was independently
32
- // built into LightCycles' own achievements.tsx. Derives its own colors from useAutoPaperTheme(),
33
- // same as this package's own BaseSettingsDialog, rather than taking fg/bg/cardBg/etc as props — a
34
- // stats screen is app-wide chrome, not a per-player surface, so there's no reason to make every
35
- // caller re-derive and thread through the identical dark-mode formula BaseSettingsDialog already
36
- // hardcodes internally. `children` is the seam for whatever isn't shared: this component has no
63
+ // built into LightCycles' own achievements.tsx. Colors default to the same literal-black/white-by-
64
+ // appearance formula BaseSettingsDialog hardcodes internally (same as this package's own
65
+ // BaseSettingsDialog) zero-config for every existing caller but fg/bg/cardBg are each
66
+ // independently overridable for a caller whose own app-wide chrome palette isn't that literal
67
+ // black/white convention. `children` is the seam for whatever isn't shared: this component has no
37
68
  // opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
38
69
  // AchievementRow for the smaller presentational pieces built to go inside it.
39
- export function BaseStatsScreen({ rotation = 0, title = 'Achievements', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', children }: Props) {
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) {
40
71
  const { dark, colors } = useAutoPaperTheme()
41
72
  const [confirmResetVisible, setConfirmResetVisible] = useState(false)
42
73
  const showReset = !!onReset
43
74
 
44
- // High-contrast retro look: literal black/white by appearance, matching every other screen in
45
- // this ecosystem (title/loadout/settings all use this identical formula) rather than auto-
46
- // paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
47
- // fg/cardBg/cardBorder use internally.
48
- const fg = dark ? '#FFFFFF' : '#000000'
49
- const bg = dark ? '#000000' : '#FFFFFF'
50
- const cardBg = dark ? '#111111' : '#F2F2F2'
75
+ // High-contrast retro look by default: literal black/white by appearance, matching every other
76
+ // screen in this ecosystem (title/loadout/settings all use this identical formula) rather than
77
+ // auto-paper's own (slightly tinted) background role — same convention BaseSettingsDialog's own
78
+ // fg/cardBg/cardBorder use internally. Overridable per-instance via the props above for a caller
79
+ // whose own chrome isn't that literal black/white convention; cardBorder has no override (see the
80
+ // Props doc above for why).
81
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
82
+ const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
83
+ const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
51
84
  const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
85
+ const accentColor = accentColorOverride ?? colors.secondary
52
86
 
53
87
  return (
54
88
  <View style={[styles.container, { backgroundColor: bg }]}>
55
89
  <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
56
90
  <IconButton icon='arrow-left' iconColor={fg} size={24} onPress={onBack} />
57
- <Text variant='displaySmall' style={[styles.title, { color: fg }]}>
91
+ <Text variant={titleVariant} style={[styles.title, { color: fg }]}>
58
92
  {title}
59
93
  </Text>
60
94
  </View>
@@ -63,7 +97,7 @@ export function BaseStatsScreen({ rotation = 0, title = 'Achievements', onBack,
63
97
  {children}
64
98
 
65
99
  {showReset && (
66
- <Button mode='outlined' onPress={() => setConfirmResetVisible(true)} textColor={colors.secondary} style={styles.resetButton}>
100
+ <Button mode='contained' onPress={() => setConfirmResetVisible(true)} buttonColor={colors.secondary} textColor={colors.onSecondary} style={styles.resetButton}>
67
101
  {resetLabel}
68
102
  </Button>
69
103
  )}
@@ -73,8 +107,8 @@ export function BaseStatsScreen({ rotation = 0, title = 'Achievements', onBack,
73
107
  <Portal>
74
108
  <View style={[styles.overlay, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
75
109
  <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
76
- <Icon source='alert-outline' size={64} color={colors.secondary} />
77
- <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.secondary }]}>
110
+ <Icon source='alert-outline' size={64} color={accentColor} />
111
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: accentColor }]}>
78
112
  {resetConfirmTitle}
79
113
  </Text>
80
114
  <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
@@ -0,0 +1,55 @@
1
+ import { computeContentBounds } from '@tastic/core'
2
+ import { ReactNode } from 'react'
3
+ import { StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native'
4
+
5
+ interface Props {
6
+ // Upper bound on the centered content region's width, in px. Omitted (the default) means no
7
+ // clamp at all - content fills the full window width and both gutters stay at 0 - the right
8
+ // default for a caller that wants this component's centered-content structure without ever
9
+ // actually capping width (e.g. a future full-bleed/"extend into safe area"-style mode: just
10
+ // don't pass this prop for that mode, rather than needing a separate disable flag).
11
+ maxContentWidth?: number
12
+ // Decoration for the left/right leftover space once maxContentWidth actually clamps something -
13
+ // e.g. a boundary wall. Each renders inside a View already sized to that side's own
14
+ // gutterWidth (0, and so with no room to show in, whenever nothing's clamped) - omit either/both
15
+ // for plain empty space, the default.
16
+ leftGutter?: ReactNode
17
+ rightGutter?: ReactNode
18
+ style?: StyleProp<ViewStyle>
19
+ children: ReactNode
20
+ }
21
+
22
+ // Caps a game's play area at maxContentWidth on a wide desktop/web window, splitting whatever's
23
+ // left over into two equal, empty-by-default gutters instead of letting content keep stretching
24
+ // (or drift off-center, left-anchored inside a now-much-wider parent) past the point where growing
25
+ // any further stops being useful. Below maxContentWidth - or with it omitted entirely - this is a
26
+ // no-op passthrough: children render at the full window width, no gutters.
27
+ //
28
+ // See @tastic/core's computeContentBounds for the actual clamp math this just wires up to a live
29
+ // useWindowDimensions() read - deliberately the only geometry source this component owns itself; a
30
+ // caller that also needs to fold in device safe-area insets or a split-screen zone boundary
31
+ // composes those together on its own side, the same way LightCycles' useZoneClampedAlign composes
32
+ // useAutoAlign with its own extra geometry (see that hook for the pattern this follows).
33
+ export function ContentGutter({ maxContentWidth, leftGutter, rightGutter, style, children }: Props) {
34
+ const { width: windowWidth } = useWindowDimensions()
35
+ const { contentWidth, gutterWidth } = computeContentBounds(windowWidth, maxContentWidth ?? Infinity)
36
+
37
+ return (
38
+ <View style={[styles.row, style]}>
39
+ <View style={[styles.gutter, { width: gutterWidth }]}>{leftGutter}</View>
40
+ <View style={{ width: contentWidth }}>{children}</View>
41
+ <View style={[styles.gutter, { width: gutterWidth }]}>{rightGutter}</View>
42
+ </View>
43
+ )
44
+ }
45
+
46
+ const styles = StyleSheet.create({
47
+ // overflow:'hidden' keeps an oversized leftGutter/rightGutter from bleeding into the content
48
+ // column instead of clipping cleanly at its own real width.
49
+ gutter: {
50
+ overflow: 'hidden'
51
+ },
52
+ row: {
53
+ flexDirection: 'row'
54
+ }
55
+ })
@@ -40,6 +40,13 @@ interface Props {
40
40
  host: PopoverHost
41
41
  value: string
42
42
  onChange: (hex: string) => void
43
+ // Transforms only what's *rendered* — trigger fill, swatch fill, and the contrast/caret/border
44
+ // colors derived from them — leaving `value`/`swatch.value` themselves untouched for `selected`/
45
+ // `taken` matching and for what onChange actually commits. For a host whose own theme mutes or
46
+ // tints a raw seed color before it's actually used elsewhere (e.g. blending it toward a base
47
+ // palette for a card-back tint), so the picker can preview that real result per-swatch instead of
48
+ // the too-vibrant seed hue, without changing what gets stored.
49
+ previewValue?: (hex: string) => string
43
50
  swatches?: SeedColor[]
44
51
  // The other player's current color, if any — stays visible in the grid (so the full palette
45
52
  // reads consistently for both players) but renders disabled with an X, rather than being
@@ -82,7 +89,7 @@ interface Props {
82
89
  // Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
83
90
  // picker would block the whole screen for one player while another can't touch their own panel at
84
91
  // the same time, which defeats the point of a split-screen lobby.
85
- export function InlineColorPicker({ id, host, value, onChange, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
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) {
86
93
  const menuBg = dark ? '#000000' : '#FFFFFF'
87
94
  const { width: windowWidth } = useWindowDimensions()
88
95
  const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
@@ -94,7 +101,9 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
94
101
  const open = host.openId === id
95
102
  const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
96
103
  const align = alignOverride ?? autoAlign
97
- const contrastColor = getContrastColor(value)
104
+ const displayFor = previewValue ?? ((hex: string) => hex)
105
+ const displayValue = displayFor(value)
106
+ const contrastColor = getContrastColor(displayValue)
98
107
  // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
99
108
  // font leaves more of its own em-box empty), so a plain-text ratio that looks right for an initial
100
109
  // still looks small for an emoji tag at the identical size — this bumps emoji up to the icon's own
@@ -103,7 +112,7 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
103
112
 
104
113
  return (
105
114
  <View style={[styles.anchor, open && styles.anchorOpen]}>
106
- <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: value, borderRadius: size / 2, height: size, width: size }]}>
115
+ <TouchableRipple onPress={() => host.toggle(id)} borderless style={[styles.trigger, { backgroundColor: displayValue, borderRadius: size / 2, height: size, width: size }]}>
107
116
  <View ref={triggerRef} collapsable={false} style={styles.triggerMeasure}>
108
117
  {/* Sized as a fraction of the trigger's own diameter rather than a fixed pixel size, so
109
118
  the glyph still reads as a deliberate part of the circle instead of shrinking toward its
@@ -121,18 +130,19 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
121
130
  {/* Gated on `measured`, not just `open` — see useAutoAlign's own comment and
122
131
  SectionedDropdown's identical fix: without it, the popover mounts for one frame at a stale or
123
132
  guessed alignment and visibly jumps once this open's own measurement lands. */}
124
- <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={value} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={size}>
133
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={displayValue} caretBorderWidth={SWATCHES_BORDER_WIDTH} triggerSize={size}>
125
134
  {/* Border matches this trigger's own current color (not a neutral gray) — two triggers can
126
135
  sit close together, so the popover needs a clear visual tie back to which one opened it,
127
136
  not just its screen position. maxHeight (see useAutoAlign) only actually clamps on a screen
128
137
  short enough that the full grid can't fit above or below the trigger either way — a short
129
138
  landscape screen with the trigger row near the top, same case SectionedDropdown's own
130
139
  ScrollView exists for. */}
131
- <ScrollView style={[styles.swatches, { backgroundColor: menuBg, borderColor: value, width: swatchesWidth, maxHeight }]} contentContainerStyle={styles.swatchesContent} showsVerticalScrollIndicator={false}>
140
+ <ScrollView style={[styles.swatches, { backgroundColor: menuBg, borderColor: displayValue, width: swatchesWidth, maxHeight }]} contentContainerStyle={styles.swatchesContent} showsVerticalScrollIndicator={false}>
132
141
  {swatches.map((swatch) => {
133
142
  const selected = swatch.value.toLowerCase() === value.toLowerCase()
134
143
  const taken = !selected && !!takenValue && swatch.value.toLowerCase() === takenValue.toLowerCase()
135
144
  const swappable = taken && allowSwapTaken
145
+ const swatchDisplay = displayFor(swatch.value)
136
146
  return (
137
147
  <TouchableRipple
138
148
  key={swatch.value}
@@ -142,9 +152,9 @@ export function InlineColorPicker({ id, host, value, onChange, swatches = defaul
142
152
  if (autoDismiss) host.close()
143
153
  }}
144
154
  borderless
145
- style={[styles.swatch, { backgroundColor: swatch.value }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
155
+ style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]}
146
156
  >
147
- {selected ? <Icon source='check' size={16} color={getContrastColor(swatch.value)} /> : swappable ? <Icon source='swap-horizontal' size={16} color={getContrastColor(swatch.value)} /> : taken ? <Icon source='close' size={16} color={getContrastColor(swatch.value)} /> : <View />}
157
+ {selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : swappable ? <Icon source='swap-horizontal' size={16} color={getContrastColor(swatchDisplay)} /> : taken ? <Icon source='close' size={16} color={getContrastColor(swatchDisplay)} /> : <View />}
148
158
  </TouchableRipple>
149
159
  )
150
160
  })}
@@ -200,6 +210,10 @@ const styles = StyleSheet.create({
200
210
  gap: SWATCHES_GAP,
201
211
  padding: SWATCHES_PADDING
202
212
  },
213
+ tagLabel: {
214
+ fontWeight: '700',
215
+ letterSpacing: 0.5
216
+ },
203
217
  // borderRadius/height/width come from the `size` prop instead (see render-site override) — no
204
218
  // fixed default here, since this style object is shared by every caller regardless of size.
205
219
  trigger: {
@@ -218,9 +232,5 @@ const styles = StyleSheet.create({
218
232
  justifyContent: 'center',
219
233
  paddingHorizontal: 4,
220
234
  width: '100%'
221
- },
222
- tagLabel: {
223
- fontWeight: '700',
224
- letterSpacing: 0.5
225
235
  }
226
236
  })
@@ -53,6 +53,12 @@ export interface SingleSelectSection<T extends string | number> {
53
53
  options: MenuOption<T>[]
54
54
  value: T
55
55
  onChange: (value: T) => void
56
+ // Another seat's currently-selected value in this section, if any — stays in `options` (so the
57
+ // trigger gauge below keeps indexing into the one list every seat shares, instead of each seat's
58
+ // gauge landing wherever its own filtered-down list happens to put the remaining values) but
59
+ // renders disabled and dimmed, unselectable. Same "shown, not removed" treatment as
60
+ // InlineColorPicker's own takenValue, for the same reason.
61
+ takenValue?: T
56
62
  }
57
63
 
58
64
  // Any number of these can be true at once — checkbox-style. Tapping a row toggles just that one and
@@ -250,12 +256,13 @@ function SingleSection({
250
256
  <>
251
257
  {section.options.map((option) => {
252
258
  const selected = option.value === section.value
259
+ const taken = !selected && option.value === section.takenValue
253
260
  const rowColor = selected ? onAccentColor : mutedColor
254
- const itemStyle = [styles.item, selected && { backgroundColor: accentColor }]
261
+ const itemStyle = [styles.item, selected && { backgroundColor: accentColor }, taken && styles.itemTaken]
255
262
  const labelStyle: TextStyle = { fontFamily: labelFontFamily, color: rowColor, fontWeight: selected ? 'bold' : 'normal' }
256
263
  const descriptionStyle: TextStyle = { fontFamily: labelFontFamily, color: rowColor }
257
264
  return (
258
- <TouchableRipple key={option.value} onPress={() => onSelect(option.value)} style={itemStyle}>
265
+ <TouchableRipple key={option.value} disabled={taken} onPress={() => onSelect(option.value)} style={itemStyle}>
259
266
  <View style={styles.itemRow}>
260
267
  {option.icon && <Icon source={option.icon} size={MENU_ITEM_ICON_SIZE} color={rowColor} />}
261
268
  <View style={styles.itemLabelColumn}>
@@ -367,6 +374,11 @@ const styles = StyleSheet.create({
367
374
  flexDirection: 'row',
368
375
  gap: 8
369
376
  },
377
+ // Same opacity as ProfilePicker's rowDisabled / InlineColorPicker's swatchTaken — the fleet's
378
+ // established "shown, not removed" treatment for another seat's own current pick.
379
+ itemTaken: {
380
+ opacity: 0.35
381
+ },
370
382
  menu: {
371
383
  borderRadius: 12,
372
384
  borderWidth: MENU_BORDER_WIDTH,
package/src/StatRow.tsx CHANGED
@@ -5,20 +5,25 @@ import { Text } from 'react-native-paper'
5
5
  interface Props {
6
6
  label: string
7
7
  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.
11
+ fg?: string
12
+ fgMuted?: string
8
13
  }
9
14
 
10
15
  // A single labeled stat — label on the left (muted), value on the right (bold, full-contrast).
11
16
  // Extracted from LightCycles' own achievements.tsx, where this exact row shape backed every
12
- // section (Overall/Vs CPU/Two Player/Activity). Derives its own colors from useAutoPaperTheme(),
13
- // same as BaseStatsScreen see that component's own doc for why a stats-screen row doesn't take
14
- // fg/fgMuted as props. Deliberately just label+value text, not a color swatch or profile chip
15
- // an app needing a row with a leading visual builds its own on top of this same dark-mode formula
16
- // (see LightCycles' own ColorStatRow/ProfileRankingRow for an example), since what that visual
17
- // even is varies per game.
18
- export function StatRow({ label, value }: Props) {
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.
20
+ // 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
22
+ // ColorStatRow/ProfileRankingRow for an example), since what that visual even is varies per game.
23
+ export function StatRow({ label, value, fg: fgOverride, fgMuted: fgMutedOverride }: Props) {
19
24
  const { dark } = useAutoPaperTheme()
20
- const fg = dark ? '#FFFFFF' : '#000000'
21
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
25
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
22
27
 
23
28
  return (
24
29
  <View style={styles.row}>
@@ -5,22 +5,29 @@ import { Text } from 'react-native-paper'
5
5
 
6
6
  interface Props {
7
7
  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.
11
+ fg?: string
12
+ sectionBg?: string
8
13
  children: ReactNode
9
14
  }
10
15
 
11
16
  // A labeled, tinted-background grouping box — the "OVERALL"/"VS CPU"/"ACTIVITY"-style section
12
17
  // shape from LightCycles' own achievements.tsx, generalized to any content (typically a stack of
13
- // StatRow). Derives its own colors from useAutoPaperTheme(), same as StatRow/BaseStatsScreen — see
14
- // BaseStatsScreen's own doc for why. The label itself carries the section's own meaning (what it's
15
- // a section OF), so this component has no opinion on that beyond rendering the string it's given.
16
- export function StatSection({ label, children }: Props) {
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.
23
+ export function StatSection({ label, fg: fgOverride, sectionBg: sectionBgOverride, children }: Props) {
17
24
  const { dark } = useAutoPaperTheme()
18
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
19
- const sectionBg = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
25
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
26
+ const sectionBg = sectionBgOverride ?? (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)')
20
27
 
21
28
  return (
22
29
  <View style={[styles.section, { backgroundColor: sectionBg }]}>
23
- <Text variant='labelMedium' style={[styles.label, { color: fgMuted }]}>
30
+ <Text variant='labelMedium' style={[styles.label, { color: fg }]}>
24
31
  {label}
25
32
  </Text>
26
33
  {children}
@@ -29,7 +36,12 @@ export function StatSection({ label, children }: Props) {
29
36
  }
30
37
 
31
38
  const styles = StyleSheet.create({
39
+ // Bold and full-contrast (fg), not the original fontless/fgMuted treatment — that read at the
40
+ // same visual weight as StatRow's own row labels below it (both used the identical muted color;
41
+ // letterSpacing/variant alone didn't separate them enough to read as "this is the group header"
42
+ // at a glance). Matches AchievementRow's own title styling (bold + fg) below it in the same list.
32
43
  label: {
44
+ fontWeight: 'bold',
33
45
  letterSpacing: 2
34
46
  },
35
47
  section: {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
2
2
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
3
  export { BaseStatsScreen } from './BaseStatsScreen'
4
+ export { ContentGutter } from './ContentGutter'
4
5
  export { CornerActionButtons } from './CornerActionButtons'
5
6
  export { MONO_FONT } from './fonts'
6
7
  export { InlineColorPicker } from './InlineColorPicker'