@tastic/profile 0.11.0 → 0.13.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.
@@ -17,7 +17,7 @@ const LIST_PADDING = 8
17
17
  // A complete placement decision for the popover: which way it opens, how tall it may grow, whether
18
18
  // this open's measurement has landed yet, and the ref attached to the measured trigger.
19
19
  // Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
20
- // same shape `useAutoAlign` itself returns) — declared here rather than imported so this package's
20
+ // same shape `useAutoAlign` itself returns) - declared here rather than imported so this package's
21
21
  // public types don't start requiring a newer @tastic/hud than its peer range already admits. A
22
22
  // hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
23
23
  export interface ProfilePickerAlignResult {
@@ -36,9 +36,9 @@ export interface ProfilePickerContentSize {
36
36
  // The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
37
37
  // for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
38
38
  // given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
39
- // ProfilePicker hands to `useAutoAlign` — the ONE formula, used by the component itself (it leaves
39
+ // ProfilePicker hands to `useAutoAlign` - the ONE formula, used by the component itself (it leaves
40
40
  // out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
41
- // estimate, not a pixel-exact measurement) — exported
41
+ // estimate, not a pixel-exact measurement) - exported
42
42
  // so a host supplying its own placement through `alignOverride` can size that placement hook (its
43
43
  // own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
44
44
  // instead of copying these constants and drifting whenever they change here.
@@ -48,32 +48,32 @@ export function getProfilePickerContentSize(profileCount: number, hasManageRow =
48
48
  }
49
49
 
50
50
  interface ProfilePickerProps<P extends Profile> {
51
- // Namespaces this trigger's id — pass something unique per instance whenever more than one of
51
+ // Namespaces this trigger's id - pass something unique per instance whenever more than one of
52
52
  // these shares a PopoverHost (e.g. one per seat).
53
53
  idPrefix: string
54
- // The host this trigger's popover opens against — share one host across sibling pickers (a
54
+ // The host this trigger's popover opens against - share one host across sibling pickers (a
55
55
  // seat's color/tag/profile triggers) so opening one closes any other in the same group.
56
56
  host: PopoverHost
57
57
  profiles: P[]
58
58
  selectedId: string | null
59
- // Another picker's currently-selected profile id, if any — disabled in this list. Useful when a
59
+ // Another picker's currently-selected profile id, if any - disabled in this list. Useful when a
60
60
  // profile shouldn't be selectable in two places at once (e.g. two seats in a local-multiplayer
61
61
  // game, where double-selecting one profile would double-count its stats as both winner and
62
- // loser) — omit if that doesn't apply.
62
+ // loser) - omit if that doesn't apply.
63
63
  takenId?: string | null
64
- // Tint for the trigger's own label/icon — pass the seat/context's current live color so the
64
+ // Tint for the trigger's own label/icon - pass the seat/context's current live color so the
65
65
  // trigger reads as one identity with whatever it's paired with.
66
66
  color: string
67
67
  dark: boolean
68
68
  // Forces a specific horizontal alignment, beating whatever the placement (the automatic
69
69
  // measurement, or `alignOverride` below) would have picked. Omit to let placement decide.
70
70
  align?: 'left' | 'right' | 'center'
71
- // Substitutes the popover's whole placement — horizontal/vertical alignment, max height, the
72
- // `measured` gate and the trigger ref — for the automatic `useAutoAlign` one, for a host whose
71
+ // Substitutes the popover's whole placement - horizontal/vertical alignment, max height, the
72
+ // `measured` gate and the trigger ref - for the automatic `useAutoAlign` one, for a host whose
73
73
  // trigger sits somewhere that hook's plain window-relative measurement gets wrong. The motivating
74
74
  // case is a FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked):
75
75
  // `measureInWindow` reports a rect in true screen space, `useAutoAlign` compares it against the
76
- // raw portrait window, and so measures along the wrong axes — flipping the list upward off-screen
76
+ // raw portrait window, and so measures along the wrong axes - flipping the list upward off-screen
77
77
  // or running it past the frame's bottom. The host runs its own rotation-aware placement hook
78
78
  // (sized with `getProfilePickerContentSize`, passing `open` from its own `host.openId === ...`
79
79
  // check) and hands the result in here; `align` above still wins over this result's own `align`.
@@ -81,7 +81,7 @@ interface ProfilePickerProps<P extends Profile> {
81
81
  // produced it is measuring the right element. Omit for the default automatic placement.
82
82
  alignOverride?: ProfilePickerAlignResult
83
83
  // Rotation of an ancestor frame turned by a fake landscape (or equivalent), forwarded to the
84
- // automatic `useAutoAlign` measurement — the same seam @tastic/hud's InlineColorPicker/
84
+ // automatic `useAutoAlign` measurement - the same seam @tastic/hud's InlineColorPicker/
85
85
  // SectionedDropdown expose (needs @tastic/hud >= 0.8.0; older versions ignore it). Defaults to 0
86
86
  // (measurement used as-is), which is exactly how this picker behaved before this prop existed.
87
87
  // Deliberately NOT read from an ambient provider the way hud's own pickers do: this component
@@ -89,35 +89,35 @@ interface ProfilePickerProps<P extends Profile> {
89
89
  // every existing caller already living under a rotation provider. Ignored when `alignOverride` is
90
90
  // supplied (the override replaces the placement this feeds).
91
91
  rotation?: PopoverRotation
92
- // Trigger's own idle-state text (uppercased here regardless of casing passed in) — shown until a
92
+ // Trigger's own idle-state text (uppercased here regardless of casing passed in) - shown until a
93
93
  // profile is selected.
94
94
  guestLabel: string
95
- // The null-selection row's own label/icon — defaults to a generic anonymous-player row, but
95
+ // The null-selection row's own label/icon - defaults to a generic anonymous-player row, but
96
96
  // overridable for a caller where null means something else (e.g. an "All Profiles" combined view
97
97
  // reusing this same component for its own picker).
98
98
  nullLabel?: string
99
99
  nullIcon?: string
100
100
  onSelect: (profile: P | null) => void
101
- // Renders a row for navigating to a dedicated create/rename/delete screen — omit to hide it
101
+ // Renders a row for navigating to a dedicated create/rename/delete screen - omit to hide it
102
102
  // entirely. This component never manages profiles itself (see ProfilesManager for that); this is
103
103
  // selection-only.
104
104
  onManage?: () => void
105
105
  }
106
106
 
107
107
  // Generic over P (an app's own Profile type, richer than this package's base Profile) so that a
108
- // caller's own onSelect — typed to accept its own richer profile shape — type-checks against the
108
+ // caller's own onSelect - typed to accept its own richer profile shape - type-checks against the
109
109
  // objects this component actually hands back, which are always exactly whatever came in through
110
110
  // `profiles` (see handleSelectExisting below). Without this, TypeScript would only ever let
111
111
  // onSelect declare the base Profile type, since that's all a non-generic prop signature could
112
- // promise it receives — even though at runtime it always receives the caller's own richer objects.
112
+ // promise it receives - even though at runtime it always receives the caller's own richer objects.
113
113
  // ProfileChip doesn't need this same treatment: it only ever takes a profile in, never hands one
114
114
  // back out, so plain structural covariance already lets a richer object through.
115
115
  //
116
116
  // A name trigger + selection popover: pick an existing profile, fall back to a null/"guest"
117
117
  // selection, or (if onManage is passed) navigate to manage the roster. Creating, renaming, and
118
- // deleting profiles all live outside this component (see ProfilesManager) — this is selection-only,
118
+ // deleting profiles all live outside this component (see ProfilesManager) - this is selection-only,
119
119
  // both because that keeps this component simple enough to drop into a HUD trigger row, and because
120
- // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven —
120
+ // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven -
121
121
  // no internal profile-storage access of its own, so the host app stays the one place that reads
122
122
  // its own persistence hook.
123
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>) {
@@ -129,12 +129,12 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
129
129
 
130
130
  const open = host.openId === id
131
131
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null
132
- // Alphabetical — this is the list that sees the most real use, so it's the one that benefits
132
+ // Alphabetical - this is the list that sees the most real use, so it's the one that benefits
133
133
  // most from staying scannable once there are more than a handful of saved profiles.
134
134
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
135
135
  // Null row + each saved profile + (if passed) the manage row.
136
136
  const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
137
- // Always called, even when `alignOverride` is supplied and this result goes unused — hooks can't
137
+ // Always called, even when `alignOverride` is supplied and this result goes unused - hooks can't
138
138
  // be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
139
139
  // override its triggerRef is never attached, so this hook's own measurement is a no-op.
140
140
  const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
@@ -169,7 +169,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
169
169
  </View>
170
170
  </TouchableRipple>
171
171
 
172
- {/* Gated on `measured`, not just `open` — see InlineColorPicker's identical fix. */}
172
+ {/* Gated on `measured`, not just `open` - see InlineColorPicker's identical fix. */}
173
173
  <PopoverBody visible={open && measured} align={align} verticalAlign={verticalAlign} caretColor={menuBg} caretBorderColor={color} caretBorderWidth={2} triggerSize={TRIGGER_HEIGHT}>
174
174
  <ScrollView
175
175
  style={[
@@ -185,12 +185,12 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
185
185
  showsVerticalScrollIndicator={false}
186
186
  >
187
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
188
+ accent for the null row, the profile's own saved color otherwise) - matching how a
189
189
  selected row reads elsewhere (see @tastic/hud's SectionedDropdown) rather than a checkmark
190
190
  bolted onto an otherwise-plain row. */}
191
191
  <TouchableRipple onPress={handleSelectGuest} style={[styles.row, selectedId === null && { backgroundColor: color }]}>
192
192
  <View style={styles.rowInner}>
193
- {/* Unfilled, matching a selected profile row's own ProfileChip treatment — no
193
+ {/* Unfilled, matching a selected profile row's own ProfileChip treatment - no
194
194
  background circle, but still the same fixed-size slot every row's icon/chip sits in
195
195
  (see ProfileChip's own doc), so this row's icon stays lined up with every other row
196
196
  regardless of selection. */}
@@ -208,7 +208,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
208
208
  return (
209
209
  <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
210
210
  <View style={[styles.rowInner, taken && styles.rowDisabled]}>
211
- {/* Unfilled on a selected row — its own background already fills with the
211
+ {/* Unfilled on a selected row - its own background already fills with the
212
212
  profile's color, so a second circle behind the tag would just be a redundant badge
213
213
  where every other selected row in the app has plain icon+text instead. */}
214
214
  <ProfileChip profile={profile} filled={!selected} />
@@ -231,7 +231,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
231
231
  {onManage && (
232
232
  <>
233
233
  <View style={[styles.divider, { backgroundColor: color }]} />
234
- {/* Tertiary — a navigation action, not another selectable identity in the list above
234
+ {/* Tertiary - a navigation action, not another selectable identity in the list above
235
235
  it, so it reads more like @tastic/hud's SectionedDropdown All/Clear footer button
236
236
  (centered, bold, accent-colored) than a normal row. */}
237
237
  <TouchableRipple onPress={handleManage} style={styles.row}>
@@ -252,12 +252,12 @@ const styles = StyleSheet.create({
252
252
  anchor: {
253
253
  position: 'relative'
254
254
  },
255
- // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
255
+ // See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
256
256
  // view its own stacking context, so the elevation has to live on the anchor itself.
257
257
  anchorOpen: {
258
258
  zIndex: 100
259
259
  },
260
- // Separates Manage (a navigation action) from the selectable rows above it — different kind of
260
+ // Separates Manage (a navigation action) from the selectable rows above it - different kind of
261
261
  // action, not just another row in the same list. Tinted with the seat's own color (passed in as
262
262
  // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
263
263
  divider: {
@@ -266,7 +266,7 @@ const styles = StyleSheet.create({
266
266
  opacity: 0.25
267
267
  },
268
268
  // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
269
- // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
269
+ // - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
270
270
  // own to fill it with, and the render site now colors the icon directly instead.
271
271
  guestChip: {
272
272
  alignItems: 'center',
@@ -280,7 +280,7 @@ const styles = StyleSheet.create({
280
280
  fontWeight: '700',
281
281
  letterSpacing: 0.5
282
282
  },
283
- // Centered rather than left-aligned like a normal row — see this row's own render-site comment
283
+ // Centered rather than left-aligned like a normal row - see this row's own render-site comment
284
284
  // for why (a footer action, not another selectable identity).
285
285
  manageRowInner: {
286
286
  alignItems: 'center',
@@ -298,7 +298,7 @@ const styles = StyleSheet.create({
298
298
  shadowOpacity: 0.3,
299
299
  shadowRadius: 8
300
300
  },
301
- // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
301
+ // Padded on every side (not just top/bottom) - a selected row's own full-color background (see
302
302
  // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
303
303
  // pill instead of looking clipped against them.
304
304
  menuContent: {
@@ -2,10 +2,10 @@ import { defaultColors, useAutoPaperTheme } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { useFocusChain } from '@rific/focus-chain'
4
4
  import { useToast } from '@rific/toaster'
5
- import { getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
5
+ import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
6
6
  import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
7
7
  import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
8
- import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
8
+ import { Icon, Text, TextInput } from 'react-native-paper'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
10
 
11
11
  import { ProfileChip } from './ProfileChip'
@@ -14,17 +14,17 @@ import { type ProfilePickerAlignResult } from './ProfilePicker'
14
14
  import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
15
15
  import { Profile } from './types'
16
16
 
17
- // Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip — one
17
+ // Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip - one
18
18
  // size for every circle on this screen, rather than each picking its own.
19
19
  const CHIP_SIZE = 40
20
20
 
21
- // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
21
+ // react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
22
22
  // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
23
23
  // as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
24
24
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
25
25
 
26
- // Exported (not just used internally) so a host composing a screen around this component — e.g.
27
- // @tastic/profile's own ProfilesScreen — can reference this exact shape in its own onCreate/onSave
26
+ // Exported (not just used internally) so a host composing a screen around this component - e.g.
27
+ // @tastic/profile's own ProfilesScreen - can reference this exact shape in its own onCreate/onSave
28
28
  // prop types without redeclaring it.
29
29
  export interface ProfileEditPatch {
30
30
  name: string
@@ -33,51 +33,47 @@ export interface ProfileEditPatch {
33
33
  }
34
34
 
35
35
  export interface ProfilesManagerHandle {
36
- // Flushes whatever row is currently being edited — the same thing this component's own unmount
36
+ // Flushes whatever row is currently being edited - the same thing this component's own unmount
37
37
  // effect does, but callable explicitly. Exists because "unmount" isn't a reliable "the user is
38
38
  // navigating away" signal on every host: React Navigation's web renderer keeps a popped screen
39
39
  // mounted-but-hidden instead of unmounting it, so a host relying only on the unmount fallback
40
40
  // silently loses an in-progress edit on `router.back()` there. A host whose router doesn't
41
41
  // genuinely unmount this component on navigation should hold a ref and call this right before
42
42
  // navigating away (e.g. in its own back-button handler). Safe to call any time, including when
43
- // nothing is being edited — a no-op then, same as the unmount effect already is.
43
+ // nothing is being edited - a no-op then, same as the unmount effect already is.
44
44
  commitPendingEdit: () => void
45
45
  }
46
46
 
47
47
  export interface ProfilesManagerProps {
48
48
  profiles: Profile[]
49
- // The color a brand-new profile's draft starts on — this component doesn't have (and shouldn't
49
+ // The color a brand-new profile's draft starts on - this component doesn't have (and shouldn't
50
50
  // invent) an opinion about the host app's own palette, so this is required rather than defaulted.
51
51
  defaultColor: string
52
52
  onCreate: (input: ProfileEditPatch) => void
53
53
  onSave: (id: string, patch: ProfileEditPatch) => void
54
- // Irreversible — this only fires after this component's own confirmation card, not on the first
54
+ // Irreversible - this only fires after this component's own confirmation card, not on the first
55
55
  // tap. If the host app tracks anything else keyed by profile id (stats, history), clear that on
56
56
  // its own side when this fires.
57
57
  onDelete: (id: string) => void
58
- // Rendered inline, before the "Profiles" title, in this component's own header row — a plain
58
+ // Rendered inline, before the "Profiles" title, in this component's own header row - a plain
59
59
  // rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
60
60
  // back button, only that the host wants something shown there. Omit to hide the slot.
61
61
  headerLeft?: ReactNode
62
- // React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) — see
62
+ // React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) - see
63
63
  // ProfilesManagerHandle's own doc for why a host may need this.
64
64
  ref?: Ref<ProfilesManagerHandle>
65
- // Independently optional overrides for this screen's text/card colors — each defaults to the same
66
- // literal-black/white-by-appearance formula as before when omitted, so every existing caller
67
- // (none of which pass these) renders identically to before. Same convention as @tastic/hud's
68
- // BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
69
- // of that literal black/white convention. cardBorder/cancelBg/cancelFg have no override, matching
70
- // BaseStatsScreen's identical reasoning for cardBorder: their low-alpha/neutral values already
71
- // read fine against any cardBg, and (for Cancel specifically) are deliberately neutral rather than
72
- // theme-derived so a host's own live gameplay/seat color can't land there by coincidence.
65
+ // Independently optional overrides for this screen's text colors - each defaults to the same
66
+ // literal-black/white-by-appearance formula as before when omitted. Same convention as @tastic/hud's
67
+ // BaseStatsScreen fg override, for a caller with its own app-wide chrome palette instead of that
68
+ // literal black/white convention. (cardBg, which only colored the delete confirmation, went with it
69
+ // when that became hud's ConfirmDialog, 0.13.0.)
73
70
  fg?: string
74
71
  fgMuted?: string
75
- cardBg?: string
76
72
  // 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
77
- // default and the reasoning behind it — this title sits next to a small headerLeft back-button
73
+ // default and the reasoning behind it - this title sits next to a small headerLeft back-button
78
74
  // glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
79
75
  titleVariant?: MD3TextVariant
80
- // Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
76
+ // Transforms the color swatch grid's own preview per-swatch - forwarded straight to the internal
81
77
  // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
82
78
  // color value untouched; only what's rendered changes. Defaults to ProfileColorProvider's preview.
83
79
  colorPreview?: (hex: string) => string
@@ -97,7 +93,7 @@ export interface ProfilesManagerProps {
97
93
 
98
94
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
99
95
 
100
- // First letter of the name, uppercased — the tag's own default for as long as it hasn't been typed
96
+ // First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
101
97
  // into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
102
98
  // nothing into the tag field yourself would leave it.
103
99
  function deriveTag(name: string): string {
@@ -105,22 +101,22 @@ function deriveTag(name: string): string {
105
101
  return trimmed ? trimmed[0].toUpperCase() : ''
106
102
  }
107
103
 
108
- // The profile roster itself — create/rename/recolor/retag/delete, editing inline right in the row
109
- // you tapped — with no navigation of its own: just data in (`profiles`) and callbacks out, plus
104
+ // The profile roster itself - create/rename/recolor/retag/delete, editing inline right in the row
105
+ // you tapped - with no navigation of its own: just data in (`profiles`) and callbacks out, plus
110
106
  // this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
111
107
  // would still assume *some* host owns navigation; this component doesn't even go that far), so any
112
108
  // consuming screen just renders it as its body inside its own routed wrapper (back button, native
113
109
  // push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
114
110
  // Staying router-agnostic means this component can't know for itself whether "navigating away"
115
- // really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
111
+ // really unmounts it on a given host's router - see ProfilesManagerHandle's own doc for the
116
112
  // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
117
113
  // instead of relying on the unmount fallback alone.
118
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
114
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
119
115
  // An explicit prop wins; otherwise the app-wide ProfileColorProvider's, so the swatch previews
120
116
  // match the ProfileChips drawn in the list.
121
117
  const contextColorPreview = useProfileColorPreview()
122
118
  const colorPreview = colorPreviewProp ?? contextColorPreview
123
- const { dark, colors } = useAutoPaperTheme()
119
+ const { dark } = useAutoPaperTheme()
124
120
  const insets = useSafeAreaInsets()
125
121
  const { error: showErrorToast } = useToast()
126
122
  // Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
@@ -128,37 +124,31 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
128
124
  const host = usePopoverHost()
129
125
  const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
130
126
  const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
131
- const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
132
- const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
133
- // A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
134
- // danger fill, rather than looking unfinished/secondary beside it.
135
- const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
136
- const cancelFg = dark ? '#FFFFFF' : '#000000'
137
127
 
138
- // 'new' clears back to null once a commit resolves — there's only ever one row open at a time.
128
+ // 'new' clears back to null once a commit resolves - there's only ever one row open at a time.
139
129
  const [editingId, setEditingId] = useState<string | 'new' | null>(null)
140
130
  const [draftName, setDraftName] = useState('')
141
131
  const [draftColor, setDraftColor] = useState(defaultColor)
142
132
  const [draftTag, setDraftTag] = useState('')
143
- // Whether the tag has been typed into directly this session — once true, editing the name stops
133
+ // Whether the tag has been typed into directly this session - once true, editing the name stops
144
134
  // overwriting it (see deriveTag's own doc). Starts true for an existing profile that already has
145
- // a tag (don't clobber a deliberate choice just because the name changed), false otherwise — a
135
+ // a tag (don't clobber a deliberate choice just because the name changed), false otherwise - a
146
136
  // brand new profile, or one that's never had a tag set at all.
147
137
  const [tagManuallySet, setTagManuallySet] = useState(false)
148
138
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
149
139
 
150
- // Commits whichever row is currently open, using its own latest draft state, then closes it —
140
+ // Commits whichever row is currently open, using its own latest draft state, then closes it -
151
141
  // always closes, even when there's nothing valid to save (an empty name just discards the draft
152
142
  // rather than leaving the row stuck open). This is the only way a row ever closes: submitting the
153
- // name field (see EditRow's own focus-chain wiring — tag submits to name, name submits this),
143
+ // name field (see EditRow's own focus-chain wiring - tag submits to name, name submits this),
154
144
  // switching to a different row (see startEdit, which calls this before opening a new one), or
155
- // leaving the screen entirely (see the unmount effect below) — no explicit save/cancel buttons,
145
+ // leaving the screen entirely (see the unmount effect below) - no explicit save/cancel buttons,
156
146
  // just scoped to the whole row instead of one field, since there's more than one field to touch.
157
147
  const commitEdit = useCallback(() => {
158
148
  const name = draftName.trim()
159
149
  if (editingId && name) {
160
150
  const tagValid = isValidTag(draftTag)
161
- // Surfaced here, not just the field's own live `error` outline — that outline disappears the
151
+ // Surfaced here, not just the field's own live `error` outline - that outline disappears the
162
152
  // moment this row closes, so without this a still-invalid tag would just silently vanish
163
153
  // (saved as empty) with no explanation of why it didn't stick.
164
154
  if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
@@ -169,11 +159,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
169
159
  setEditingId(null)
170
160
  }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
171
161
 
172
- // Flushes whatever's currently being edited if this whole screen goes away mid-edit — a hardware
162
+ // Flushes whatever's currently being edited if this whole screen goes away mid-edit - a hardware
173
163
  // back gesture or swipe-back can bypass a host screen's own back button (which should already
174
164
  // call commitEdit before navigating away) but still unmounts this component the same as any other
175
165
  // exit does. Reads the always-current commitEdit through a ref rather than depending on it
176
- // directly in the effect — one that re-ran (and re-cleaned-up) on every keystroke would commit on
166
+ // directly in the effect - one that re-ran (and re-cleaned-up) on every keystroke would commit on
177
167
  // every keystroke too, not just on a genuine unmount.
178
168
  const commitEditRef = useRef(commitEdit)
179
169
  useEffect(() => {
@@ -181,7 +171,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
181
171
  })
182
172
  useEffect(() => () => commitEditRef.current(), [])
183
173
  // The explicit escape hatch for hosts whose "navigate away" doesn't genuinely unmount this
184
- // component — see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
174
+ // component - see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
185
175
  // needs to change identity, since it always calls through the ref to whatever commitEdit is
186
176
  // current at call time, the same indirection the unmount effect above already relies on.
187
177
  useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
@@ -213,8 +203,8 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
213
203
  [tagManuallySet]
214
204
  )
215
205
 
216
- // The functional setDraftTag form — reading `previous` from React's own latest-applied state
217
- // rather than closing over the `draftTag` variable — matters here, not just style: fast/rapid
206
+ // The functional setDraftTag form - reading `previous` from React's own latest-applied state
207
+ // rather than closing over the `draftTag` variable - matters here, not just style: fast/rapid
218
208
  // keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
219
209
  // update, and clamping every one of them against the same stale `draftTag` (captured once at
220
210
  // render time) would let the later ones silently clobber what the earlier ones already clamped
@@ -233,7 +223,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
233
223
  setConfirmDeleteId(null)
234
224
  }, [confirmDeleteId, editingId, onDelete])
235
225
 
236
- // Alphabetical — this list has no other natural order (creation order isn't meaningful to look
226
+ // Alphabetical - this list has no other natural order (creation order isn't meaningful to look
237
227
  // something up by), and it's the one that actually stays usable once there are more than a
238
228
  // handful of profiles on one shared device.
239
229
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
@@ -279,7 +269,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
279
269
  return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
280
270
  }
281
271
 
282
- // No delete affordance at rest — tap the row to start editing, which is the one place
272
+ // No delete affordance at rest - tap the row to start editing, which is the one place
283
273
  // delete shows (see EditRow): a deliberate extra step before a destructive action is
284
274
  // even reachable, not just before it's confirmed.
285
275
  return (
@@ -296,32 +286,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
296
286
  </ScrollView>
297
287
  </KeyboardAvoidingView>
298
288
 
299
- {confirmDeleteProfile && (
300
- <Portal>
301
- <View style={styles.overlay}>
302
- <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
303
- <Icon source='alert-outline' size={64} color={colors.danger} />
304
- <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.danger }]}>
305
- Delete Profile?
306
- </Text>
307
- <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
308
- This permanently deletes {confirmDeleteProfile.name}&rsquo;s profile. This cannot be undone.
309
- </Text>
310
- {/* Contained neutral, not buttonColor={colors.primary} — colors.primary is often a
311
- live gameplay/seat color in host apps, so a contained Cancel in that color would just
312
- be whatever's currently painted there, with no relation to "safe to tap". */}
313
- <Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
314
- Cancel
315
- </Button>
316
- {/* colors.danger/onDanger — auto-paper's actual semantic role for this, not a seat's
317
- own live color by luck of the default palette. */}
318
- <Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
319
- Delete
320
- </Button>
321
- </View>
322
- </View>
323
- </Portal>
324
- )}
289
+ {/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
290
+ Reset Everything?/New Game confirms beside it (Jay, 2026-10-15: one look for every dialog). It
291
+ used to be its own card here, in this screen's literal black/white (or the host's cardBg), with
292
+ two stacked contained buttons. */}
293
+ <ConfirmDialog visible={confirmDeleteProfile !== null} title='Delete Profile?' message={confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : ''} confirmLabel='Delete' cancelLabel='Cancel' icon='alert-outline' destructive onConfirm={handleConfirmDelete} onCancel={() => setConfirmDeleteId(null)} />
325
294
  </>
326
295
  )
327
296
  }
@@ -334,7 +303,7 @@ interface EditRowProps {
334
303
  draftTag: string
335
304
  onTagChange: (tag: string) => void
336
305
  onSubmit: () => void
337
- // Omitted for the 'new' row — nothing saved yet to delete.
306
+ // Omitted for the 'new' row - nothing saved yet to delete.
338
307
  onDelete?: () => void
339
308
  autoFocus?: boolean
340
309
  fgMuted: string
@@ -356,29 +325,29 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
356
325
  return <InlineColorPicker {...props} alignOverride={alignOverride} />
357
326
  }
358
327
 
359
- // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
360
- // own display via its `tag` prop below, since there's no separate visible tag field anymore — an
328
+ // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) - doubling as the tag's
329
+ // own display via its `tag` prop below, since there's no separate visible tag field anymore - an
361
330
  // invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
362
331
  // up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
363
- // name taking up the row's remaining space, and — only for an existing profile — a delete icon at
332
+ // name taking up the row's remaining space, and - only for an existing profile - a delete icon at
364
333
  // the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
365
334
  // the hidden tag field too, so typing a tag and picking a color read as one combined action instead
366
335
  // of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
367
336
  // Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
368
- // own — submitting the *name* field is what actually commits the row, so its registration's own
369
- // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
337
+ // own - submitting the *name* field is what actually commits the row, so its registration's own
338
+ // onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
370
339
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
371
340
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
372
341
  const register = useFocusChain()
373
342
  const tag = register()
374
343
  const name = register()
375
344
 
376
- // tag.props.focus (from @rific/focus-chain's own register(), not tag.ref — that's a callback
345
+ // tag.props.focus (from @rific/focus-chain's own register(), not tag.ref - that's a callback
377
346
  // ref, not a ref object, so it has no .current to read) is the hook's own imperative "focus this
378
347
  // exact registered field" call. Mirrored into a ref every render, same as commitEditRef above,
379
348
  // rather than read directly in the effect below: register() returns a brand-new props object on
380
349
  // every render (it isn't memoized), so depending on tag.props.focus directly would rerun that
381
- // effect — and refocus an already-focused field — on every keystroke into the tag field itself,
350
+ // effect - and refocus an already-focused field - on every keystroke into the tag field itself,
382
351
  // re-triggering its own selectTextOnFocus mid-type and wiping out what's being typed.
383
352
  const tagFocusRef = useRef(tag.props.focus)
384
353
  useEffect(() => {
@@ -397,9 +366,9 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
397
366
  <View style={styles.editRow}>
398
367
  {useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />}
399
368
  {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
400
- doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
369
+ doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
401
370
  value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
402
- way: without it, this field — even at 1x1 and fully transparent — still sits in front of the
371
+ way: without it, this field - even at 1x1 and fully transparent - still sits in front of the
403
372
  swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
404
373
  focusing this field is never done by tapping it directly anyway (see the effect above, which
405
374
  focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
@@ -439,7 +408,7 @@ const styles = StyleSheet.create({
439
408
  gap: 4,
440
409
  paddingBottom: 8
441
410
  },
442
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
411
+ // 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
443
412
  // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
444
413
  // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
445
414
  hiddenTagInput: {
@@ -466,27 +435,6 @@ const styles = StyleSheet.create({
466
435
  borderStyle: 'dashed',
467
436
  borderWidth: 2
468
437
  },
469
- overlay: {
470
- alignItems: 'center',
471
- backgroundColor: 'rgba(0,0,0,0.72)',
472
- bottom: 0,
473
- justifyContent: 'center',
474
- left: 0,
475
- position: 'absolute',
476
- right: 0,
477
- top: 0
478
- },
479
- overlayBody: { textAlign: 'center' },
480
- overlayButton: { width: 160 },
481
- overlayCard: {
482
- alignItems: 'center',
483
- borderRadius: 20,
484
- borderWidth: 1,
485
- gap: 16,
486
- maxWidth: 360,
487
- padding: 32
488
- },
489
- overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
490
438
  row: {
491
439
  borderRadius: 8,
492
440
  paddingVertical: 8