@tastic/profile 0.10.0 → 0.12.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.
@@ -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,36 +33,36 @@ 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
65
+ // Independently optional overrides for this screen's text/card colors - each defaults to the same
66
66
  // literal-black/white-by-appearance formula as before when omitted, so every existing caller
67
67
  // (none of which pass these) renders identically to before. Same convention as @tastic/hud's
68
68
  // BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
@@ -74,10 +74,10 @@ export interface ProfilesManagerProps {
74
74
  fgMuted?: string
75
75
  cardBg?: string
76
76
  // '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
77
+ // default and the reasoning behind it - this title sits next to a small headerLeft back-button
78
78
  // glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
79
79
  titleVariant?: MD3TextVariant
80
- // Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
80
+ // Transforms the color swatch grid's own preview per-swatch - forwarded straight to the internal
81
81
  // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
82
82
  // color value untouched; only what's rendered changes. Defaults to ProfileColorProvider's preview.
83
83
  colorPreview?: (hex: string) => string
@@ -97,7 +97,7 @@ export interface ProfilesManagerProps {
97
97
 
98
98
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
99
99
 
100
- // First letter of the name, uppercased — the tag's own default for as long as it hasn't been typed
100
+ // First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
101
101
  // into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
102
102
  // nothing into the tag field yourself would leave it.
103
103
  function deriveTag(name: string): string {
@@ -105,14 +105,14 @@ function deriveTag(name: string): string {
105
105
  return trimmed ? trimmed[0].toUpperCase() : ''
106
106
  }
107
107
 
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
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
110
110
  // this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
111
111
  // would still assume *some* host owns navigation; this component doesn't even go that far), so any
112
112
  // consuming screen just renders it as its body inside its own routed wrapper (back button, native
113
113
  // push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
114
114
  // 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
115
+ // really unmounts it on a given host's router - see ProfilesManagerHandle's own doc for the
116
116
  // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
117
117
  // instead of relying on the unmount fallback alone.
118
118
  export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
@@ -130,35 +130,35 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
130
130
  const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
131
131
  const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
132
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
133
+ // A contained neutral, not an outlined one - reads as "the other button" next to Delete's solid
134
134
  // danger fill, rather than looking unfinished/secondary beside it.
135
135
  const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
136
136
  const cancelFg = dark ? '#FFFFFF' : '#000000'
137
137
 
138
- // 'new' clears back to null once a commit resolves — there's only ever one row open at a time.
138
+ // 'new' clears back to null once a commit resolves - there's only ever one row open at a time.
139
139
  const [editingId, setEditingId] = useState<string | 'new' | null>(null)
140
140
  const [draftName, setDraftName] = useState('')
141
141
  const [draftColor, setDraftColor] = useState(defaultColor)
142
142
  const [draftTag, setDraftTag] = useState('')
143
- // Whether the tag has been typed into directly this session — once true, editing the name stops
143
+ // Whether the tag has been typed into directly this session - once true, editing the name stops
144
144
  // 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
145
+ // a tag (don't clobber a deliberate choice just because the name changed), false otherwise - a
146
146
  // brand new profile, or one that's never had a tag set at all.
147
147
  const [tagManuallySet, setTagManuallySet] = useState(false)
148
148
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
149
149
 
150
- // Commits whichever row is currently open, using its own latest draft state, then closes it —
150
+ // Commits whichever row is currently open, using its own latest draft state, then closes it -
151
151
  // always closes, even when there's nothing valid to save (an empty name just discards the draft
152
152
  // 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),
153
+ // name field (see EditRow's own focus-chain wiring - tag submits to name, name submits this),
154
154
  // 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,
155
+ // leaving the screen entirely (see the unmount effect below) - no explicit save/cancel buttons,
156
156
  // just scoped to the whole row instead of one field, since there's more than one field to touch.
157
157
  const commitEdit = useCallback(() => {
158
158
  const name = draftName.trim()
159
159
  if (editingId && name) {
160
160
  const tagValid = isValidTag(draftTag)
161
- // Surfaced here, not just the field's own live `error` outline — that outline disappears the
161
+ // Surfaced here, not just the field's own live `error` outline - that outline disappears the
162
162
  // moment this row closes, so without this a still-invalid tag would just silently vanish
163
163
  // (saved as empty) with no explanation of why it didn't stick.
164
164
  if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
@@ -169,11 +169,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
169
169
  setEditingId(null)
170
170
  }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
171
171
 
172
- // Flushes whatever's currently being edited if this whole screen goes away mid-edit — a hardware
172
+ // Flushes whatever's currently being edited if this whole screen goes away mid-edit - a hardware
173
173
  // back gesture or swipe-back can bypass a host screen's own back button (which should already
174
174
  // call commitEdit before navigating away) but still unmounts this component the same as any other
175
175
  // 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
176
+ // directly in the effect - one that re-ran (and re-cleaned-up) on every keystroke would commit on
177
177
  // every keystroke too, not just on a genuine unmount.
178
178
  const commitEditRef = useRef(commitEdit)
179
179
  useEffect(() => {
@@ -181,7 +181,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
181
181
  })
182
182
  useEffect(() => () => commitEditRef.current(), [])
183
183
  // 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
184
+ // component - see ProfilesManagerHandle's own doc. `[]` deps: the handle object itself never
185
185
  // needs to change identity, since it always calls through the ref to whatever commitEdit is
186
186
  // current at call time, the same indirection the unmount effect above already relies on.
187
187
  useImperativeHandle(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), [])
@@ -213,8 +213,8 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
213
213
  [tagManuallySet]
214
214
  )
215
215
 
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
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
218
218
  // keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
219
219
  // update, and clamping every one of them against the same stale `draftTag` (captured once at
220
220
  // render time) would let the later ones silently clobber what the earlier ones already clamped
@@ -233,7 +233,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
233
233
  setConfirmDeleteId(null)
234
234
  }, [confirmDeleteId, editingId, onDelete])
235
235
 
236
- // Alphabetical — this list has no other natural order (creation order isn't meaningful to look
236
+ // Alphabetical - this list has no other natural order (creation order isn't meaningful to look
237
237
  // something up by), and it's the one that actually stays usable once there are more than a
238
238
  // handful of profiles on one shared device.
239
239
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
@@ -279,7 +279,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
279
279
  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
280
  }
281
281
 
282
- // No delete affordance at rest — tap the row to start editing, which is the one place
282
+ // No delete affordance at rest - tap the row to start editing, which is the one place
283
283
  // delete shows (see EditRow): a deliberate extra step before a destructive action is
284
284
  // even reachable, not just before it's confirmed.
285
285
  return (
@@ -307,13 +307,13 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
307
307
  <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
308
308
  This permanently deletes {confirmDeleteProfile.name}&rsquo;s profile. This cannot be undone.
309
309
  </Text>
310
- {/* Contained neutral, not buttonColor={colors.primary} — colors.primary is often a
310
+ {/* Contained neutral, not buttonColor={colors.primary} - colors.primary is often a
311
311
  live gameplay/seat color in host apps, so a contained Cancel in that color would just
312
312
  be whatever's currently painted there, with no relation to "safe to tap". */}
313
313
  <Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
314
314
  Cancel
315
315
  </Button>
316
- {/* colors.danger/onDanger — auto-paper's actual semantic role for this, not a seat's
316
+ {/* colors.danger/onDanger - auto-paper's actual semantic role for this, not a seat's
317
317
  own live color by luck of the default palette. */}
318
318
  <Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
319
319
  Delete
@@ -334,7 +334,7 @@ interface EditRowProps {
334
334
  draftTag: string
335
335
  onTagChange: (tag: string) => void
336
336
  onSubmit: () => void
337
- // Omitted for the 'new' row — nothing saved yet to delete.
337
+ // Omitted for the 'new' row - nothing saved yet to delete.
338
338
  onDelete?: () => void
339
339
  autoFocus?: boolean
340
340
  fgMuted: string
@@ -356,29 +356,29 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
356
356
  return <InlineColorPicker {...props} alignOverride={alignOverride} />
357
357
  }
358
358
 
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
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
361
361
  // invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
362
362
  // 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
363
+ // name taking up the row's remaining space, and - only for an existing profile - a delete icon at
364
364
  // the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
365
365
  // the hidden tag field too, so typing a tag and picking a color read as one combined action instead
366
366
  // of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
367
367
  // 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
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
370
370
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
371
371
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
372
372
  const register = useFocusChain()
373
373
  const tag = register()
374
374
  const name = register()
375
375
 
376
- // tag.props.focus (from @rific/focus-chain's own register(), not tag.ref — that's a callback
376
+ // tag.props.focus (from @rific/focus-chain's own register(), not tag.ref - that's a callback
377
377
  // ref, not a ref object, so it has no .current to read) is the hook's own imperative "focus this
378
378
  // exact registered field" call. Mirrored into a ref every render, same as commitEditRef above,
379
379
  // rather than read directly in the effect below: register() returns a brand-new props object on
380
380
  // 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,
381
+ // effect - and refocus an already-focused field - on every keystroke into the tag field itself,
382
382
  // re-triggering its own selectTextOnFocus mid-type and wiping out what's being typed.
383
383
  const tagFocusRef = useRef(tag.props.focus)
384
384
  useEffect(() => {
@@ -397,9 +397,9 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
397
397
  <View style={styles.editRow}>
398
398
  {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
399
  {/* 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
400
+ doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
401
401
  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
402
+ way: without it, this field - even at 1x1 and fully transparent - still sits in front of the
403
403
  swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
404
404
  focusing this field is never done by tapping it directly anyway (see the effect above, which
405
405
  focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
@@ -439,7 +439,7 @@ const styles = StyleSheet.create({
439
439
  gap: 4,
440
440
  paddingBottom: 8
441
441
  },
442
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
442
+ // 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
443
443
  // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
444
444
  // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
445
445
  hiddenTagInput: {
@@ -6,7 +6,7 @@ import { StyleSheet, View } from 'react-native'
6
6
  import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle, ProfilesManagerProps } from './ProfilesManager'
7
7
  import { Profile } from './types'
8
8
 
9
- // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
9
+ // react-native-paper's own MD3 Text variant names - mirrored locally rather than importing its
10
10
  // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
11
11
  // as ProfilesManager's own identical titleVariant prop does.
12
12
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
@@ -16,22 +16,22 @@ export interface ProfilesScreenProps {
16
16
  // Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
17
17
  // host's palette, so it stays required rather than defaulted. Callers already differ here today
18
18
  // (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
19
- // DEFAULT_P1_COLOR constant instead) — this prop is exactly what lets both keep working unchanged.
19
+ // DEFAULT_P1_COLOR constant instead) - this prop is exactly what lets both keep working unchanged.
20
20
  defaultColor: string
21
- // Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take — deliberately
21
+ // Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take - deliberately
22
22
  // NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
23
23
  // extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
24
24
  // site, exactly as it already does calling <ProfilesManager> today.
25
25
  onCreate: (patch: ProfileEditPatch) => void
26
26
  onSave: (id: string, patch: ProfileEditPatch) => void
27
- // Irreversible, same as ProfilesManager's own onDelete — fires once, after this component's
27
+ // Irreversible, same as ProfilesManager's own onDelete - fires once, after this component's
28
28
  // internal confirmation card. If the host tracks anything else keyed by profile id (stats,
29
29
  // achievement-unlock history), clear that on its own side inside this callback, same as every
30
30
  // current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
31
31
  onDelete: (id: string) => void
32
32
  onBack: () => void
33
33
  // Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
34
- // formula every current caller renders with unchanged (none of them pass these today) — same
34
+ // formula every current caller renders with unchanged (none of them pass these today) - same
35
35
  // convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
36
36
  // cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
37
37
  fg?: string
@@ -49,13 +49,13 @@ export interface ProfilesScreenProps {
49
49
  // The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
50
50
  // today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
51
51
  // navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
52
- // UI content — it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
53
- // still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
52
+ // UI content - it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
53
+ // still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself - those two
54
54
  // hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
55
55
  // app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
56
56
  export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps) {
57
57
  const { dark } = useAutoPaperTheme()
58
- // Owns the ref itself — nothing outside this component ever needs it, since this is now the one
58
+ // Owns the ref itself - nothing outside this component ever needs it, since this is now the one
59
59
  // thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
60
60
  const managerRef = useRef<ProfilesManagerHandle>(null)
61
61
  // Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
package/src/index.ts CHANGED
@@ -10,5 +10,6 @@ export { type CreateProfileInput, createProfileRecord, profilesActions, profiles
10
10
  export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
11
11
  export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
12
12
  export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
13
- export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
13
+ export { avoidSeatColorClash } from './seatColorClash'
14
+ export { isSharedProfileStoreAvailable, loadSharedProfiles, readSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
14
15
  export type { Profile } from './types'
@@ -1,7 +1,7 @@
1
1
  import { Profile } from './types'
2
2
 
3
3
  export const MAX_PROFILE_NAME_LENGTH = 20
4
- // In code points, not JS string length — a single emoji can span more than one UTF-16 code unit
4
+ // In code points, not JS string length - a single emoji can span more than one UTF-16 code unit
5
5
  // (surrogate pairs, variation selectors), which .length would overcount.
6
6
  export const MAX_TAG_LENGTH = 3
7
7
 
@@ -11,23 +11,23 @@ function isValidHexColor(value: unknown): value is string {
11
11
 
12
12
  // Codepoint 0xFE0F is the variation selector that forces an emoji-style rendering of an otherwise
13
13
  // dual-purpose glyph; 0x200D is the zero-width joiner that chains multiple pictographs into one
14
- // compound emoji (a family, a flag, a skin-tone variant) — built from String.fromCodePoint rather
14
+ // compound emoji (a family, a flag, a skin-tone variant) - built from String.fromCodePoint rather
15
15
  // than embedded directly in a regex literal so this file's own source has no invisible characters
16
16
  // in it.
17
17
  const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f)
18
18
  const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
19
- // Matches one emoji as a single unit, including a ZWJ-joined compound — good enough for telling
19
+ // Matches one emoji as a single unit, including a ZWJ-joined compound - good enough for telling
20
20
  // "the user typed an emoji" from "the user typed letters" without needing full Unicode grapheme
21
21
  // segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
22
22
  const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
23
23
 
24
- // Presence check, not the full anchored EMOJI_PATTERN above — clampTag below only needs to know
24
+ // Presence check, not the full anchored EMOJI_PATTERN above - clampTag below only needs to know
25
25
  // whether a just-typed chunk contains a pictograph at all, not whether an entire value is exactly
26
26
  // one (possibly ZWJ-joined) emoji. Same distinction @tastic/hud's InlineColorPicker draws for its
27
27
  // own font-sizing check.
28
28
  const EMOJI_PRESENT = /\p{Extended_Pictographic}/u
29
29
 
30
- // Empty is valid too — a profile can have no tag yet, same as it can go without ever entering an
30
+ // Empty is valid too - a profile can have no tag yet, same as it can go without ever entering an
31
31
  // editor's own tag field at all; every place this renders falls back to a generic icon in that case
32
32
  // (see ProfileChip).
33
33
  export function isValidTag(value: unknown): value is string {
@@ -45,8 +45,8 @@ export function clampTag(previous: string, next: string): string {
45
45
  const previousCodepoints = Array.from(previous)
46
46
  const nextCodepoints = Array.from(next)
47
47
 
48
- // Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement — see
49
- // ProfilesManager's own EditRow — reporting its own replacement text as no longer than what it
48
+ // Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement - see
49
+ // ProfilesManager's own EditRow - reporting its own replacement text as no longer than what it
50
50
  // overwrote) always passes through untouched: there's nothing to clamp about removing content.
51
51
  if (nextCodepoints.length <= previousCodepoints.length) return next
52
52
 
@@ -54,7 +54,7 @@ export function clampTag(previous: string, next: string): string {
54
54
  while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++
55
55
  const added = nextCodepoints.slice(sharedPrefixLength).join('')
56
56
 
57
- // A tag is either a handful of plain characters or exactly one emoji, never a mix — typing more
57
+ // A tag is either a handful of plain characters or exactly one emoji, never a mix - typing more
58
58
  // of either kind on top of the other one starts fresh with just what's newly typed, rather than
59
59
  // producing a combination isValidTag would reject anyway (an emoji followed by letters, or a
60
60
  // second emoji tacked onto the first).
@@ -63,7 +63,7 @@ export function clampTag(previous: string, next: string): string {
63
63
  return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next
64
64
  }
65
65
 
66
- // Validates only the fields this package's own `Profile` type declares — an app that extends
66
+ // Validates only the fields this package's own `Profile` type declares - an app that extends
67
67
  // Profile with its own fields (a control scheme, anything else) needs its own wrapper that runs
68
68
  // this first and then checks its own additions, same as it extends the type itself. See this
69
69
  // package's own README for the composition pattern.
@@ -10,7 +10,7 @@ export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
10
10
 
11
11
  // Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
12
12
  // (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
13
- // profileExtensionSlice.ts rather than each hand-rolling its own copy — not exported from
13
+ // profileExtensionSlice.ts rather than each hand-rolling its own copy - not exported from
14
14
  // index.ts, since it's an implementation detail of how this package's own reducers are built, not
15
15
  // something a consuming app needs.
16
16
  export function createActionCreator<P>(type: string): ActionCreator<P> {
@@ -19,7 +19,7 @@ export interface ProfileExtensionSlice<TExtension> {
19
19
 
20
20
  // A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
21
21
  // type (a control scheme, a key-binding preference, a difficulty setting, ...) but still needs to
22
- // travel WITH a specific profile id, entirely locally — never synced to the shared App Group
22
+ // travel WITH a specific profile id, entirely locally - never synced to the shared App Group
23
23
  // roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
24
24
  // same as this package's own README describes for any app-specific field). Two real @tastic games
25
25
  // (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
@@ -28,10 +28,10 @@ export interface ProfileExtensionSlice<TExtension> {
28
28
  // need a third copy.
29
29
  //
30
30
  // `namespace` prefixes every action type (e.g. 'profileExtension/controlScheme/set') so an app with
31
- // more than one independent per-profile extension never collides between them — same convention
31
+ // more than one independent per-profile extension never collides between them - same convention
32
32
  // @rific/core's createSettingsSlice uses its own namespace argument for.
33
33
  //
34
- // Deliberately NOT auto-cleared when the base `profiles` slice removes an id — this stays a plain,
34
+ // Deliberately NOT auto-cleared when the base `profiles` slice removes an id - this stays a plain,
35
35
  // self-contained reducer rather than importing profilesActions.remove's action type to listen for.
36
36
  // An app already dispatches multiple independent actions when deleting a profile (profilesActions.
37
37
  // remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
@@ -1,7 +1,7 @@
1
1
  import { ActionCreator, createActionCreator } from './createActionCreator'
2
2
 
3
3
  // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
- // dependency on the package itself — this factory otherwise has no opinion on whether the
4
+ // dependency on the package itself - this factory otherwise has no opinion on whether the
5
5
  // consuming app uses redux-persist at all, and the string itself is a long-stable part of
6
6
  // redux-persist's own public contract (unchanged across every major version). See the reducer's
7
7
  // own REHYDRATE case below for why this needs handling at all.
@@ -14,6 +14,7 @@ export interface SelectProfilePayload<TSeat> {
14
14
 
15
15
  export interface ProfileSelectionActions<TSeat> {
16
16
  clearProfile: ActionCreator<string>
17
+ pruneToRoster: ActionCreator<string[]>
17
18
  select: ActionCreator<SelectProfilePayload<TSeat>>
18
19
  }
19
20
 
@@ -24,49 +25,83 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
24
25
  reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
25
26
  }
26
27
 
27
- // Which profile each seat last picked — always local, never part of the shared App Group roster
28
+ // Which profile each seat last picked - always local, never part of the shared App Group roster
28
29
  // (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
29
30
  // naturally a per-app, per-device thing, not something a sibling @tastic game should see or
30
31
  // influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
31
- // an identical Record<Seat, string | null> reducer before this factory existed — same duplication
32
+ // an identical Record<Seat, string | null> reducer before this factory existed - same duplication
32
33
  // createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
33
34
  // per-seat selection instead.
34
35
  //
35
36
  // `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
36
- // more than one independent seat-selection concern never collides between them — same convention
37
+ // more than one independent seat-selection concern never collides between them - same convention
37
38
  // createProfileExtensionSlice's own namespace argument follows.
38
39
  //
39
40
  // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
40
- // exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
41
+ // exist at runtime), so callers pass their own `defaultState` - the same literal object (e.g.
41
42
  // `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
42
43
  // constant today. `clearProfile` walks that state's own keys rather than assuming any particular
43
44
  // seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
44
45
  //
45
- // `mountKey` defaults to 'profileSelection' — the literal key every current consumer (AirHockey,
46
+ // `mountKey` defaults to 'profileSelection' - the literal key every current consumer (AirHockey,
46
47
  // BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
47
48
  // combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
48
49
  // passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
49
50
  // whatever name the app's own root reducer happens to use, which this factory has no way to see
50
- // from inside its own reducer function — only worth overriding if some future consumer ever mounts
51
+ // from inside its own reducer function - only worth overriding if some future consumer ever mounts
51
52
  // this under a different key. Note `namespace` above is a different thing: it's the app's own name
52
53
  // (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
54
+ //
55
+ // `pruneToRoster(ids)` nulls every seat whose profile id is no longer in the given roster. Call it
56
+ // after any profilesActions.setAll (initial reconcile, onRemoteChange), since a sibling app can
57
+ // delete a profile without this app's own deleteProfile ever running. It returns the same state
58
+ // object when nothing changes, so dispatching it on every foreground resume costs no re-render.
59
+ //
60
+ // One profile is never on two seats. `select` picking a profile another seat already has swaps
61
+ // the two: that seat takes this seat's previous pick (a profile, or null for guest), the same rule
62
+ // as @tastic/input's assignSeatScheme. A picker's takenProfileId only hides the other seat's
63
+ // profile while both seats are on screen, so without the swap a 1 Player pick of seat 2's saved
64
+ // profile left both seats on it, and every later 2 Player round recorded a win and a loss for that
65
+ // one profile. A host showing per-seat state tied to the selection (a profile's color override)
66
+ // should reset it for both seats when a pick swaps. REHYDRATE also drops any duplicate saved before
67
+ // this rule existed, keeping the profile on the first seat.
53
68
  export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
54
69
  const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
55
70
  const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
71
+ const pruneToRoster = createActionCreator<string[]>(`profileSelection/${namespace}/pruneToRoster`)
56
72
 
57
73
  function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
58
74
  // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
59
75
  // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
60
- // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
76
+ // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) -
61
77
  // so a device with a blob persisted before a new seat existed would otherwise rehydrate with
62
78
  // that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
63
79
  // fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
64
- // that gap — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
80
+ // that gap - the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
65
81
  if (action.type === REHYDRATE) {
66
82
  const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
67
- return { ...defaultState, ...state, ...persisted }
83
+ const merged = { ...defaultState, ...state, ...persisted }
84
+ const seen = new Set<string>()
85
+ for (const seat of Object.keys(merged) as TSeat[]) {
86
+ const profileId = merged[seat]
87
+ if (profileId === null) continue
88
+ if (seen.has(profileId)) merged[seat] = null
89
+ else seen.add(profileId)
90
+ }
91
+ return merged
92
+ }
93
+ if (select.match(action)) {
94
+ const { profileId, seat } = action.payload
95
+ const previous = state[seat] ?? null
96
+ const next = { ...state, [seat]: profileId } as ProfileSelectionState<TSeat>
97
+ if (profileId !== null) {
98
+ // Object.keys yields strings, so a numeric seat compares as a string.
99
+ for (const other of Object.keys(state) as TSeat[]) {
100
+ if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous
101
+ }
102
+ }
103
+ return next
68
104
  }
69
- if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
70
105
  if (clearProfile.match(action)) {
71
106
  const next = { ...state }
72
107
  for (const seat of Object.keys(state) as TSeat[]) {
@@ -74,8 +109,21 @@ export function createProfileSelectionSlice<TSeat extends string | number>(names
74
109
  }
75
110
  return next
76
111
  }
112
+ if (pruneToRoster.match(action)) {
113
+ const roster = new Set(action.payload)
114
+ const next = { ...state }
115
+ let changed = false
116
+ for (const seat of Object.keys(state) as TSeat[]) {
117
+ const profileId = state[seat]
118
+ if (profileId !== null && !roster.has(profileId)) {
119
+ next[seat] = null
120
+ changed = true
121
+ }
122
+ }
123
+ return changed ? next : state
124
+ }
77
125
  return state
78
126
  }
79
127
 
80
- return { actions: { clearProfile, select }, reducer }
128
+ return { actions: { clearProfile, pruneToRoster, select }, reducer }
81
129
  }