@tastic/profile 0.13.0 → 0.14.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.
@@ -1,15 +1,7 @@
1
- import { getContrastColor } from '@rific/auto-paper'
2
- import { StyleSheet, View } from 'react-native'
3
- import { Icon, Text } from 'react-native-paper'
4
-
1
+ import { IdentityChip } from './IdentityChip'
5
2
  import { useProfileDisplayColor } from './ProfileColorContext'
6
3
  import { Profile } from './types'
7
4
 
8
- // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
9
- // only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
10
- // just "does this tag contain a pictograph at all."
11
- const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
12
-
13
5
  interface Props {
14
6
  profile: Profile
15
7
  size?: number
@@ -32,32 +24,5 @@ interface Props {
32
24
  export function ProfileChip({ profile, size = 22, filled = true }: Props) {
33
25
  // The drawn color, not necessarily the saved one - see ProfileColorProvider.
34
26
  const color = useProfileDisplayColor(profile.color)
35
- const contrastColor = getContrastColor(color)
36
- // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
37
- // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
38
- // looks small for "🎮" at the identical size - this bumps emoji up to the icon's own ratio instead,
39
- // since a single emoji is closer in visual weight to an icon glyph than to a line of text.
40
- const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
41
- return (
42
- <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
43
- {profile.tag ? (
44
- <Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
45
- {profile.tag}
46
- </Text>
47
- ) : (
48
- <Icon source='account' size={size * 0.6} color={contrastColor} />
49
- )}
50
- </View>
51
- )
27
+ return <IdentityChip color={color} tag={profile.tag} icon='account' size={size} filled={filled} />
52
28
  }
53
-
54
- const styles = StyleSheet.create({
55
- chip: {
56
- alignItems: 'center',
57
- justifyContent: 'center'
58
- },
59
- tag: {
60
- fontWeight: '700',
61
- paddingHorizontal: 2
62
- }
63
- })
@@ -1,10 +1,11 @@
1
- import { getContrastColor } from '@rific/auto-paper'
1
+ import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
4
- import { RefObject, useMemo } from 'react'
5
- import { ScrollView, StyleSheet, View } from 'react-native'
4
+ import { RefObject, useMemo, useState } from 'react'
5
+ import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
6
6
  import { Icon, Text } from 'react-native-paper'
7
7
 
8
+ import { IdentityChip } from './IdentityChip'
8
9
  import { ProfileChip } from './ProfileChip'
9
10
  import { useProfileColorPreview } from './ProfileColorContext'
10
11
  import { Profile } from './types'
@@ -13,6 +14,58 @@ const TRIGGER_HEIGHT = 28
13
14
  const POPOVER_WIDTH = 220
14
15
  const ROW_HEIGHT = 36
15
16
  const LIST_PADDING = 8
17
+ const MENU_BORDER_WIDTH = 2
18
+ // A row's own inset from the list's padded edge (`rowInner`'s paddingHorizontal); the color page
19
+ // lines its swatches up with it.
20
+ const ROW_INSET = 12
21
+ // The chip trigger (see `colorSection`): a CHIP_SIZE circle inside a pill padded by CHIP_TRIGGER_PADDING.
22
+ const CHIP_SIZE = 32
23
+ const CHIP_TRIGGER_PADDING = 6
24
+
25
+ // The color row and page (see `colorSection`). Every height here is fixed (explicit line heights),
26
+ // so getProfilePickerContentSize can size them exactly. Swatches are the same 28pt circles as
27
+ // @tastic/hud's InlineColorPicker, spread to span exactly the rows' content width (5 across, so
28
+ // 20 colors fill 4 full rows), with the same spacing between rows.
29
+ const SWATCH_SIZE = 28
30
+ const SWATCH_MIN_GAP = 6
31
+ // The Color row: a plain row's height, or taller with a caption line under the label.
32
+ const COLOR_ROW_CAPTIONED_HEIGHT = 48
33
+ const COLOR_LABEL_LINE = 18
34
+ const COLOR_CAPTION_LINE = 16
35
+ // The divider under the Color row: 1pt plus 4pt above and below, the same as the Manage divider.
36
+ const COLOR_ROW_DIVIDER = 9
37
+ // The color page: a back row, then the grid with this much space above and below it.
38
+ const GRID_TOP = 4
39
+ const GRID_BOTTOM = 8
40
+ const GRID_WIDTH = POPOVER_WIDTH - 2 * MENU_BORDER_WIDTH - 2 * LIST_PADDING - 2 * ROW_INSET
41
+ const GRID_COLUMNS = Math.floor((GRID_WIDTH + SWATCH_MIN_GAP) / (SWATCH_SIZE + SWATCH_MIN_GAP))
42
+ const GRID_GAP = (GRID_WIDTH - GRID_COLUMNS * SWATCH_SIZE) / (GRID_COLUMNS - 1)
43
+
44
+ // A color choice behind a row at the top of the popover (see ProfilePicker's `colorSection`).
45
+ export interface ProfilePickerColorSection {
46
+ // The raw seed color in effect - matched against each swatch's own value for the checkmark.
47
+ value: string
48
+ onChange: (hex: string) => void
49
+ // Transforms only what's drawn (each swatch's fill), leaving `value` and what `onChange` commits
50
+ // untouched - the same contract as @tastic/hud's InlineColorPicker `previewValue`, for a host
51
+ // that mutes or tints the seed before using it.
52
+ previewValue?: (hex: string) => string
53
+ swatches?: SeedColor[]
54
+ // The row's label, and the color page's heading. Defaults to 'Color'.
55
+ label?: string
56
+ // One short line under the row's label, e.g. where the color applies.
57
+ caption?: string
58
+ }
59
+
60
+ function colorRowHeight(caption: string | undefined) {
61
+ return caption ? COLOR_ROW_CAPTIONED_HEIGHT : ROW_HEIGHT
62
+ }
63
+
64
+ // The color page's content: the back row and the grid.
65
+ function colorPageHeight(swatches: SeedColor[]) {
66
+ const rows = Math.ceil(swatches.length / GRID_COLUMNS)
67
+ return ROW_HEIGHT + GRID_TOP + rows * SWATCH_SIZE + (rows - 1) * GRID_GAP + GRID_BOTTOM
68
+ }
16
69
 
17
70
  // A complete placement decision for the popover: which way it opens, how tall it may grow, whether
18
71
  // this open's measurement has landed yet, and the ref attached to the measured trigger.
@@ -35,16 +88,21 @@ export interface ProfilePickerContentSize {
35
88
 
36
89
  // The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
37
90
  // for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
38
- // given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
91
+ // given an `onManage`) the trailing Manage row, plus the list's own padding. With a
92
+ // `colorSection` (the same object handed to the picker works), it's the taller of the popover's
93
+ // two pages, exactly: the list with the Color row and its divider on top, or the color page (a
94
+ // back row and the swatches), so one placement fits whichever page is showing. This is the size
39
95
  // ProfilePicker hands to `useAutoAlign` - the ONE formula, used by the component itself (it leaves
40
96
  // out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
41
97
  // estimate, not a pixel-exact measurement) - exported
42
98
  // so a host supplying its own placement through `alignOverride` can size that placement hook (its
43
99
  // own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
44
100
  // instead of copying these constants and drifting whenever they change here.
45
- export function getProfilePickerContentSize(profileCount: number, hasManageRow = false): ProfilePickerContentSize {
101
+ export function getProfilePickerContentSize(profileCount: number, hasManageRow = false, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize {
46
102
  const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
47
- return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT }
103
+ const list = rowCount * ROW_HEIGHT + (colorSection ? colorRowHeight(colorSection.caption) + COLOR_ROW_DIVIDER : 0)
104
+ const content = colorSection ? Math.max(list, colorPageHeight(colorSection.swatches ?? defaultColors)) : list
105
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + content }
48
106
  }
49
107
 
50
108
  interface ProfilePickerProps<P extends Profile> {
@@ -102,6 +160,21 @@ interface ProfilePickerProps<P extends Profile> {
102
160
  // entirely. This component never manages profiles itself (see ProfilesManager for that); this is
103
161
  // selection-only.
104
162
  onManage?: () => void
163
+ // Puts a Color row at the top of the popover, above the profile rows, and turns the trigger into
164
+ // one button that leads with a chip: a circle in `color` (the color in effect) with the selected
165
+ // profile's tag (`nullIcon` for the null selection), then the name and the chevron. For a host
166
+ // where the player's color and their profile are one control (Solitaire's Home), instead of a
167
+ // separate color trigger beside the name that read as a different thing. The Color row swaps the
168
+ // popover's contents for the color page (a back row, then the swatches); picking a swatch calls
169
+ // `onChange` and stays on that page, so the change can be seen behind it and another tried. A
170
+ // press away closes the whole popover, and the next open starts on the list again. The selected
171
+ // profile's own color, when it's one of the swatches, carries a small dot while another color is
172
+ // in effect, as the way back to it. Omit for the plain name trigger and a rows-only popover,
173
+ // exactly as before.
174
+ colorSection?: ProfilePickerColorSection
175
+ // Extra style for the trigger (its pressable box) - e.g. a background behind the chip trigger's
176
+ // pill. The chip trigger already pads itself and rounds into a pill.
177
+ triggerStyle?: StyleProp<ViewStyle>
105
178
  }
106
179
 
107
180
  // Generic over P (an app's own Profile type, richer than this package's base Profile) so that a
@@ -120,7 +193,7 @@ interface ProfilePickerProps<P extends Profile> {
120
193
  // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
121
194
  // no internal profile-storage access of its own, so the host app stays the one place that reads
122
195
  // its own persistence hook.
123
- export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
196
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage, colorSection, triggerStyle }: ProfilePickerProps<P>) {
124
197
  const id = `${idPrefix}-profile`
125
198
  // A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
126
199
  const colorPreview = useProfileColorPreview()
@@ -133,7 +206,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
133
206
  // most from staying scannable once there are more than a handful of saved profiles.
134
207
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
135
208
  // Null row + each saved profile + (if passed) the manage row.
136
- const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
209
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage, colorSection)
137
210
  // Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
138
211
  // be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
139
212
  // override its triggerRef is never attached, so this hook's own measurement is a no-op.
@@ -158,20 +231,44 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
158
231
  onManage?.()
159
232
  }
160
233
 
234
+ // The chip trigger is a whole pill, so the caret points at its middle: PopoverBody places the
235
+ // caret half a `triggerSize` in from the side the popover is aligned to, so the pill's own width
236
+ // centers it whichever way the popover opens. The plain trigger keeps TRIGGER_HEIGHT, as before.
237
+ const chipTrigger = !!colorSection
238
+ const [triggerWidth, setTriggerWidth] = useState(0)
239
+ const handleAnchorLayout = chipTrigger ? (e: LayoutChangeEvent) => setTriggerWidth(e.nativeEvent.layout.width) : undefined
240
+ // Which page the popover shows. Reset on every press of the trigger, so an open always starts on
241
+ // the list (a press away closes the popover without coming back through here).
242
+ const [colorPage, setColorPage] = useState(false)
243
+ const handleTriggerPress = () => {
244
+ setColorPage(false)
245
+ host.toggle(id)
246
+ }
247
+ const triggerLabel = (selectedProfile?.name ?? guestLabel).toUpperCase()
248
+ // Shown on the list page only (see the footer below).
249
+ const manageFooter = !!onManage && !(colorSection && colorPage)
250
+
161
251
  return (
162
- <View style={[styles.anchor, open && styles.anchorOpen]}>
163
- <TouchableRipple onPress={() => host.toggle(id)} style={styles.trigger}>
164
- <View ref={triggerRef} collapsable={false} style={styles.triggerInner}>
165
- <Text style={[styles.triggerLabel, { color }]} numberOfLines={1}>
166
- {(selectedProfile?.name ?? guestLabel).toUpperCase()}
252
+ <View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
253
+ <TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
254
+ <View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
255
+ {chipTrigger && <IdentityChip color={color} tag={selectedProfile?.tag} icon={selectedProfile ? 'account' : nullIcon} size={CHIP_SIZE} />}
256
+ {/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
257
+ color on the host's pill read dim. The plain trigger keeps the color, which is what tells a
258
+ multi-seat game's seats apart. */}
259
+ <Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
260
+ {triggerLabel}
167
261
  </Text>
168
- <Icon source='menu-down' size={16} color={color} />
262
+ <Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
169
263
  </View>
170
264
  </TouchableRipple>
171
265
 
172
266
  {/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
173
- <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={2} triggerSize={TRIGGER_HEIGHT}>
174
- <ScrollView
267
+ <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={MENU_BORDER_WIDTH} triggerSize={chipTrigger ? triggerWidth : TRIGGER_HEIGHT}>
268
+ {/* The box (background, border, max height) is a plain View around the scroller, so
269
+ FadingScrollView's edge fade - the same cue as a dialog's scrolling rows - masks only the
270
+ rows, never the menu's own border or background. */}
271
+ <View
175
272
  style={[
176
273
  styles.menu,
177
274
  {
@@ -181,73 +278,151 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
181
278
  maxHeight
182
279
  }
183
280
  ]}
184
- contentContainerStyle={styles.menuContent}
185
- showsVerticalScrollIndicator={false}
186
281
  >
187
- {/* Selected row's own background fills entirely with its identity color (this seat's own
188
- accent for the null row, the profile's own saved color otherwise) - matching how a
189
- selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
190
- bolted onto an otherwise-plain row. */}
191
- <TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
192
- <View style={styles.rowInner}>
193
- {/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
194
- background circle, but still the same fixed-size slot every row's icon/chip sits in
195
- (see ProfileChip's own doc), so this row's icon stays lined up with every other row
196
- regardless of selection. */}
197
- <View style={styles.guestChip}>
198
- <Icon source={nullIcon} size={13} color={selectedId === null ? getContrastColor(color) : fg} />
199
- </View>
200
- <Text style={[styles.rowLabel, { color: selectedId === null ? getContrastColor(color) : fg }]}>{nullLabel}</Text>
201
- </View>
202
- </TouchableRipple>
203
-
204
- {sortedProfiles.map((profile) => {
205
- const taken = profile.id === takenId
206
- const selected = selectedId === profile.id
207
- const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
208
- return (
209
- <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
210
- <View style={[styles.rowInner, taken && styles.rowDisabled]}>
211
- {/* Unfilled on a selected row - its own background already fills with the
212
- profile's color, so a second circle behind the tag would just be a redundant badge
213
- where every other selected row in the app has plain icon+text instead. */}
214
- <ProfileChip profile={profile} filled={!selected} />
215
- <Text
216
- style={[
217
- styles.rowLabel,
218
- {
219
- color: selected ? getContrastColor(rowColor) : fg
220
- }
221
- ]}
222
- numberOfLines={1}
223
- >
224
- {profile.name}
225
- </Text>
226
- </View>
227
- </TouchableRipple>
228
- )
229
- })}
282
+ {/* Keyed by page, so swapping pages starts the new one scrolled to its top. */}
283
+ <FadingScrollView key={colorSection && colorPage ? 'color' : 'list'} contentContainerStyle={[styles.menuContent, manageFooter && styles.menuContentAboveFooter]} showsVerticalScrollIndicator={false}>
284
+ {colorSection && colorPage ? (
285
+ <ColorPage section={colorSection} profileColor={selectedProfile?.color} fg={fg} onBack={() => setColorPage(false)} />
286
+ ) : (
287
+ <>
288
+ {colorSection && <ColorRow section={colorSection} fg={fg} dividerColor={color} onPress={() => setColorPage(true)} />}
289
+ {/* Selected row's own background fills entirely with its identity color (this seat's own
290
+ accent for the null row, the profile's own saved color otherwise) - matching how a
291
+ selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
292
+ bolted onto an otherwise-plain row. */}
293
+ <TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
294
+ <View style={styles.rowInner}>
295
+ {/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
296
+ background circle, but still the same fixed-size slot every row's icon/chip sits in
297
+ (see ProfileChip's own doc), so this row's icon stays lined up with every other row
298
+ regardless of selection. */}
299
+ <View style={styles.guestChip}>
300
+ <Icon source={nullIcon} size={13} color={selectedId === null ? getContrastColor(color) : fg} />
301
+ </View>
302
+ <Text style={[styles.rowLabel, { color: selectedId === null ? getContrastColor(color) : fg }]}>{nullLabel}</Text>
303
+ </View>
304
+ </TouchableRipple>
230
305
 
231
- {onManage && (
232
- <>
306
+ {sortedProfiles.map((profile) => {
307
+ const taken = profile.id === takenId
308
+ const selected = selectedId === profile.id
309
+ const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
310
+ return (
311
+ <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
312
+ <View style={[styles.rowInner, taken && styles.rowDisabled]}>
313
+ {/* Unfilled on a selected row - its own background already fills with the
314
+ profile's color, so a second circle behind the tag would just be a redundant badge
315
+ where every other selected row in the app has plain icon+text instead. */}
316
+ <ProfileChip profile={profile} filled={!selected} />
317
+ <Text
318
+ style={[
319
+ styles.rowLabel,
320
+ {
321
+ color: selected ? getContrastColor(rowColor) : fg
322
+ }
323
+ ]}
324
+ numberOfLines={1}
325
+ >
326
+ {profile.name}
327
+ </Text>
328
+ </View>
329
+ </TouchableRipple>
330
+ )
331
+ })}
332
+ </>
333
+ )}
334
+ </FadingScrollView>
335
+ {/* Manage is pinned below the scrolling rows, the way a dialog's Done sits below its rows:
336
+ always reachable, with the rows fading out above its divider while there are more below
337
+ (2026-10-16; it used to be the list's last row and scrolled away with it). Tertiary - a
338
+ navigation action, not another selectable identity in the list above it, so it reads more
339
+ like @tastic/hud's SectionedDropdown All/Clear footer button (centered, bold) than a normal
340
+ row. The accent is on the icon only: a muted accent label on the menu's black read dim,
341
+ almost disabled. Not on the color page, which only has the back row and the swatches. */}
342
+ {manageFooter && (
343
+ <View style={styles.menuFooter}>
233
344
  <View style={[styles.divider, { backgroundColor: color }]} />
234
- {/* Tertiary - a navigation action, not another selectable identity in the list above
235
- it, so it reads more like @tastic/hud's SectionedDropdown All/Clear footer button
236
- (centered, bold, accent-colored) than a normal row. */}
237
345
  <TouchableRipple onPress={handleManage} style={styles.row}>
238
346
  <View style={styles.manageRowInner}>
239
347
  <Icon source='account-cog-outline' size={16} color={color} />
240
- <Text style={[styles.manageLabel, { color }]}>Manage</Text>
348
+ <Text style={[styles.manageLabel, { color: fg }]}>Manage</Text>
241
349
  </View>
242
350
  </TouchableRipple>
243
- </>
351
+ </View>
244
352
  )}
245
- </ScrollView>
353
+ </View>
246
354
  </PopoverBody>
247
355
  </View>
248
356
  )
249
357
  }
250
358
 
359
+ // The color section's heading, caption and swatch grid, then a divider before the profile rows.
360
+ // The list page's top row: a swatch of the color in effect, the label (and caption), and a chevron
361
+ // to the color page. A divider sets it apart from the selectable rows under it.
362
+ function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePickerColorSection; fg: string; dividerColor: string; onPress: () => void }) {
363
+ const { value, previewValue, label = 'Color', caption } = section
364
+ const drawn = previewValue ? previewValue(value) : value
365
+ return (
366
+ <>
367
+ <TouchableRipple onPress={onPress} style={[styles.row, { height: colorRowHeight(caption) }]} accessibilityRole='button' accessibilityLabel={caption ? `${label}, ${caption}` : label}>
368
+ <View style={styles.rowInner}>
369
+ <View style={[styles.colorRowSwatch, { backgroundColor: drawn }]} />
370
+ <View style={styles.colorRowText}>
371
+ <Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
372
+ {label}
373
+ </Text>
374
+ {caption ? (
375
+ <Text style={[styles.colorRowCaption, { color: fg }]} numberOfLines={1}>
376
+ {caption}
377
+ </Text>
378
+ ) : null}
379
+ </View>
380
+ <Icon source='chevron-right' size={18} color={fg} />
381
+ </View>
382
+ </TouchableRipple>
383
+ <View style={[styles.divider, { backgroundColor: dividerColor }]} />
384
+ </>
385
+ )
386
+ }
387
+
388
+ // The color page: a back row to the list, then the swatches. `profileColor` is the selected
389
+ // profile's own color: its swatch carries a dot, so a player who has picked something else can find
390
+ // their way back to it (the profile's row shows that color, but matching it to one of two similar
391
+ // swatches by eye is guesswork). The check takes the dot's place on the swatch in effect, so the
392
+ // dot only shows while the color in effect is another one.
393
+ function ColorPage({ section, profileColor, fg, onBack }: { section: ProfilePickerColorSection; profileColor?: string; fg: string; onBack: () => void }) {
394
+ const { value, onChange, previewValue, swatches = defaultColors, label = 'Color' } = section
395
+ const displayFor = previewValue ?? ((hex: string) => hex)
396
+ return (
397
+ <>
398
+ <TouchableRipple onPress={onBack} style={styles.row} accessibilityRole='button' accessibilityLabel='Back'>
399
+ <View style={styles.rowInner}>
400
+ <View style={styles.guestChip}>
401
+ <Icon source='chevron-left' size={20} color={fg} />
402
+ </View>
403
+ <Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
404
+ {label}
405
+ </Text>
406
+ </View>
407
+ </TouchableRipple>
408
+ <View style={styles.swatches}>
409
+ {swatches.map((swatch) => {
410
+ const selected = swatch.value.toLowerCase() === value.toLowerCase()
411
+ const isProfileColor = !!profileColor && swatch.value.toLowerCase() === profileColor.toLowerCase()
412
+ const swatchDisplay = displayFor(swatch.value)
413
+ // The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
414
+ // ringed and checked.
415
+ return (
416
+ <TouchableRipple key={swatch.value} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, { backgroundColor: swatchDisplay }, selected && styles.swatchSelected]} accessibilityRole='button' accessibilityLabel={isProfileColor ? `${swatch.label}, profile color` : swatch.label} accessibilityState={{ selected }}>
417
+ {selected ? <Icon source='check' size={16} color={getContrastColor(swatchDisplay)} /> : isProfileColor ? <View style={[styles.profileDot, { backgroundColor: getContrastColor(swatchDisplay) }]} /> : <View />}
418
+ </TouchableRipple>
419
+ )
420
+ })}
421
+ </View>
422
+ </>
423
+ )
424
+ }
425
+
251
426
  const styles = StyleSheet.create({
252
427
  anchor: {
253
428
  position: 'relative'
@@ -257,6 +432,40 @@ const styles = StyleSheet.create({
257
432
  anchorOpen: {
258
433
  zIndex: 100
259
434
  },
435
+ // The pill, rounded at both ends. The host adds its own background through `triggerStyle`.
436
+ chipTrigger: {
437
+ borderRadius: (CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING) / 2
438
+ },
439
+ // The padding lives on the measured view, not the pressable around it, so the placement measures
440
+ // the pill's real edge: measured from inside the padding, a popover opening toward a screen edge
441
+ // was sized as if the pill were 6pt further from it, and ran 2pt past that edge.
442
+ chipTriggerInner: {
443
+ alignItems: 'center',
444
+ flexDirection: 'row',
445
+ gap: 8,
446
+ height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
447
+ paddingLeft: CHIP_TRIGGER_PADDING,
448
+ paddingRight: CHIP_TRIGGER_PADDING + 8
449
+ },
450
+ colorRowCaption: {
451
+ fontSize: 12,
452
+ lineHeight: COLOR_CAPTION_LINE,
453
+ opacity: 0.6
454
+ },
455
+ // A row's label, with a line height so the captioned Color row's height is exact.
456
+ colorRowLabel: {
457
+ fontSize: 14,
458
+ lineHeight: COLOR_LABEL_LINE
459
+ },
460
+ // The color in effect, in the same 22pt slot as every row's chip.
461
+ colorRowSwatch: {
462
+ borderRadius: 11,
463
+ height: 22,
464
+ width: 22
465
+ },
466
+ colorRowText: {
467
+ flex: 1
468
+ },
260
469
  // Separates Manage (a navigation action) from the selectable rows above it - different kind of
261
470
  // action, not just another row in the same list. Tinted with the seat's own color (passed in as
262
471
  // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
@@ -291,7 +500,7 @@ const styles = StyleSheet.create({
291
500
  },
292
501
  menu: {
293
502
  borderRadius: 12,
294
- borderWidth: 2,
503
+ borderWidth: MENU_BORDER_WIDTH,
295
504
  elevation: 8,
296
505
  shadowColor: '#000000',
297
506
  shadowOffset: { height: 2, width: 0 },
@@ -304,6 +513,20 @@ const styles = StyleSheet.create({
304
513
  menuContent: {
305
514
  padding: LIST_PADDING
306
515
  },
516
+ // With Manage pinned below, its own padding takes the bottom edge (same total height as before).
517
+ menuContentAboveFooter: {
518
+ paddingBottom: 0
519
+ },
520
+ menuFooter: {
521
+ paddingBottom: LIST_PADDING,
522
+ paddingHorizontal: LIST_PADDING
523
+ },
524
+ // The selected profile's own color (see ColorSection).
525
+ profileDot: {
526
+ borderRadius: 4,
527
+ height: 8,
528
+ width: 8
529
+ },
307
530
  row: {
308
531
  borderRadius: 8,
309
532
  height: ROW_HEIGHT
@@ -316,12 +539,33 @@ const styles = StyleSheet.create({
316
539
  flexDirection: 'row',
317
540
  flex: 1,
318
541
  gap: 8,
319
- paddingHorizontal: 12
542
+ paddingHorizontal: ROW_INSET
320
543
  },
321
544
  rowLabel: {
322
545
  flex: 1,
323
546
  fontSize: 14
324
547
  },
548
+ swatch: {
549
+ alignItems: 'center',
550
+ borderRadius: SWATCH_SIZE / 2,
551
+ height: SWATCH_SIZE,
552
+ justifyContent: 'center',
553
+ width: SWATCH_SIZE
554
+ },
555
+ swatchSelected: {
556
+ borderColor: '#ffffff',
557
+ borderWidth: 2
558
+ },
559
+ swatches: {
560
+ columnGap: GRID_GAP,
561
+ flexDirection: 'row',
562
+ flexWrap: 'wrap',
563
+ marginBottom: GRID_BOTTOM,
564
+ marginHorizontal: ROW_INSET,
565
+ marginTop: GRID_TOP,
566
+ rowGap: GRID_GAP,
567
+ width: GRID_WIDTH
568
+ },
325
569
  trigger: {
326
570
  alignSelf: 'flex-start'
327
571
  },
@@ -396,11 +396,17 @@ const styles = StyleSheet.create({
396
396
  gap: 4,
397
397
  paddingHorizontal: 20
398
398
  },
399
+ // zIndex: the color popover lives inside this row, and a zIndex only orders siblings of one parent
400
+ // (React Native Web also gives every positioned view its own stacking context), so the popover's
401
+ // own zIndex never reached the rows after this one: they were drawn over it (a Kid row and the New
402
+ // Profile row across the swatches, 2026-10-16). Raising the row lifts the popover with it. There
403
+ // is only ever one edit row.
399
404
  editRow: {
400
405
  alignItems: 'center',
401
406
  flexDirection: 'row',
402
407
  gap: 8,
403
- paddingVertical: 6
408
+ paddingVertical: 6,
409
+ zIndex: 1
404
410
  },
405
411
  header: {
406
412
  alignItems: 'center',
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { ProfileChip } from './ProfileChip'
2
2
  export { ProfileColorProvider, useProfileColorPreview, useProfileDisplayColor } from './ProfileColorContext'
3
- export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize } from './ProfilePicker'
3
+ export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerColorSection, type ProfilePickerContentSize } from './ProfilePicker'
4
4
  export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
5
5
  export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
6
6
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'