@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay Deaton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # @tastic/profile
2
2
 
3
- Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player
3
+ Player profile identity for local-multiplayer React Native games - a saved name/color/tag a player
4
4
  selects per seat, with a selection popover, an inline-editable roster screen, and a reusable identity
5
5
  badge. Deliberately blind to anything app-specific: a control scheme, a stats bucket, or anything
6
6
  else keyed by profile id lives entirely on the host app's own side.
7
7
 
8
8
  ## Why a separate `Profile` type at all?
9
9
 
10
- Every app built on this package needs its own extra fields on a profile — a key/gesture scheme, a
10
+ Every app built on this package needs its own extra fields on a profile - a key/gesture scheme, a
11
11
  push/pull control mode, whatever's specific to that game. Baking any of those into this package's
12
12
  own `Profile` type would mean every OTHER app carries fields it doesn't use. Instead, this package's
13
13
  `Profile` is deliberately minimal:
@@ -25,73 +25,81 @@ interface Profile {
25
25
 
26
26
  A host app extends this on its own (`interface Profile extends BaseProfile { keyScheme: KeyScheme }`)
27
27
  and structural typing carries the richer shape straight through every component here that only ever
28
- reads the base fields — no wrapper or adapter needed. The one place this needs a little more than
29
- plain structural typing is `ProfilePicker`'s `onSelect` callback, which hands a profile back *out* —
28
+ reads the base fields - no wrapper or adapter needed. The one place this needs a little more than
29
+ plain structural typing is `ProfilePicker`'s `onSelect` callback, which hands a profile back *out* -
30
30
  see its own doc comment for why that component is generic over your own `Profile` type.
31
31
 
32
32
  ## What's in here
33
33
 
34
- - **`Profile`** — the base identity type described above.
35
- - **`isValidProfile` / `isValidTag`** — hand-rolled validators (no RN import, so they stay usable in
34
+ - **`Profile`**: the base identity type described above.
35
+ - **`isValidProfile` / `isValidTag`**: hand-rolled validators (no RN import, so they stay usable in
36
36
  plain-Node contexts like Jest without touching `react-native-paper`/`@rific/auto-paper`'s own
37
37
  RN-touching barrels). `isValidTag` accepts up to `MAX_TAG_LENGTH` plain characters or exactly one
38
38
  emoji (including a ZWJ-joined compound), and rejects mixing the two. `isValidProfile` checks only
39
- this package's own base fields — a host app composes its own richer validator on top the same way
39
+ this package's own base fields - a host app composes its own richer validator on top the same way
40
40
  it extends the type:
41
41
  ```ts
42
42
  function isValidProfile(v: unknown): v is Profile {
43
43
  return isValidBaseProfile(v) && isValidKeyScheme((v as Partial<Profile>).keyScheme)
44
44
  }
45
45
  ```
46
- - **`ProfileChip`** — a profile's identity at a glance: its own color as a filled circle, with its
46
+ - **`ProfileChip`**: a profile's identity at a glance: its own color as a filled circle, with its
47
47
  tag (or a generic fallback icon, if empty) in a contrast-safe color on top. `filled={false}` drops
48
48
  the background fill for a selected-row context that already fills its own background.
49
- - **`ProfilePicker`** — a name trigger + selection popover: pick an existing profile, fall back to a
49
+ - **`ProfilePicker`**: a name trigger + selection popover: pick an existing profile, fall back to a
50
50
  null/"guest" selection, or (if `onManage` is passed) navigate to a management screen. Selection
51
- only — creating, renaming, and deleting all live in `ProfilesManager`. Generic over your own
51
+ only - creating, renaming, and deleting all live in `ProfilesManager`. Generic over your own
52
52
  `Profile` type (see above). Placement props (all optional, see "Fake-rotated frames" below):
53
53
  `align` (force a horizontal side), `alignOverride` (substitute the whole placement result), and
54
54
  `rotation` (forwarded to the automatic measurement). Also exports
55
- `getProfilePickerContentSize(profileCount, hasManageRow?)` — the popover's
55
+ `getProfilePickerContentSize(profileCount, hasManageRow?)` - the popover's
56
56
  `{ width, height }`, for sizing your own placement hook.
57
- - **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
57
+ - **`ProfilesManager`**: the roster screen itself: create/rename/recolor/retag/delete, editing
58
58
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
59
- router, no back button) — a host app renders this as a routed screen's body and supplies its own
59
+ router, no back button) - a host app renders this as a routed screen's body and supplies its own
60
60
  persistence via `profiles`/`onCreate`/`onSave`/`onDelete`. `onCreate`/`onSave` both take a
61
- `ProfileEditPatch` (`{ name, color, tag }`) — exported from this package's own public API (it was
61
+ `ProfileEditPatch` (`{ name, color, tag }`) - exported from this package's own public API (it was
62
62
  a private, unexported interface inside `ProfilesManager.tsx` until `ProfilesScreen` below needed
63
63
  to reference it from its own props).
64
- - **`ProfilesScreen`** — the routed-screen shell around `ProfilesManager`: the container `View`,
64
+ - **`ProfilesScreen`**: the routed-screen shell around `ProfilesManager`: the container `View`,
65
65
  theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate
66
66
  composition every app in the fleet used to hand-roll on its own around `ProfilesManager` (see
67
67
  "Routed screens: `ProfilesScreen`" below). Same `ProfileEditPatch`-shaped
68
68
  `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` as `ProfilesManager` itself, plus
69
- `onBack`. Owns its own `ProfilesManagerHandle` ref internally — there's no `ref` prop here, since
69
+ `onBack`. Owns its own `ProfilesManagerHandle` ref internally - there's no `ref` prop here, since
70
70
  nothing outside it needs one anymore.
71
- - **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
71
+ - **`readSharedProfiles` / `loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`**: an *optional*
72
72
  cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
73
73
  Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
74
- participating app). This only ever carries the base `Profile` shape above — a host app's own
74
+ participating app). This only ever carries the base `Profile` shape above - a host app's own
75
75
  extension fields (a key scheme, a control scheme, anything else) are NOT part of it and have to
76
76
  keep living in that app's own *local* storage, keyed by profile id, merged onto the shared base
77
77
  roster at read time (falling back to some per-app default whenever a shared profile's id isn't
78
- in that app's own local extension table yet — e.g. it was created on a different app in the
78
+ in that app's own local extension table yet - e.g. it was created on a different app in the
79
79
  group). This split is deliberate, not a limitation to work around: it's this package's own
80
80
  base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
81
81
  TypeScript type.
82
82
 
83
83
  ```ts
84
- // Conceptual sketch of the pattern — see "Redux helpers for the shared roster" below for a
84
+ // Conceptual sketch of the pattern - see "Redux helpers for the shared roster" below for a
85
85
  // ready-made version of it.
86
86
  const GROUP_ID = 'group.com.yourteam.yourgames'
87
- const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
87
+ // null is a failed read, not an empty roster: keep the local copy and write nothing back.
88
+ const base = (isSharedProfileStoreAvailable ? await readSharedProfiles(GROUP_ID) : null) ?? localBaseFallback
88
89
  const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
89
90
  // ...on create/update/delete, write the base fields back with saveSharedProfiles(GROUP_ID, nextBase)
90
91
  // and the extension field to the app's own local storage, same as today.
91
92
  ```
92
93
 
94
+ `readSharedProfiles` resolves to `[]` when nothing has been shared yet and to `null` when the
95
+ roster is there but couldn't be read (the native call failed, the blob is corrupt, or every entry
96
+ in it is invalid). Never treat that `null` as an empty roster: an app that replaces its roster
97
+ with `[]` and then saves deletes every profile from the shared store, for every app in the group.
98
+ `loadSharedProfiles` is the older form that flattens a failed read to `[]`; use it only where the
99
+ result can't replace a roster or lead to a write.
100
+
93
101
  Backed by a small native Expo Module (`ios/TasticProfileModule.swift`) wrapping
94
- `UserDefaults(suiteName:)` — iOS only (see `expo-module.config.json`'s `"apple"`-only platform
102
+ `UserDefaults(suiteName:)` - iOS only (see `expo-module.config.json`'s `"apple"`-only platform
95
103
  list); `isSharedProfileStoreAvailable` is `false` on Android/web and on any iOS build that hasn't
96
104
  run `expo prebuild` since adding this App Group's entitlement, so a host app always needs its own
97
105
  local-storage fallback path regardless of platform. Requires the App Group itself to be declared
@@ -107,19 +115,19 @@ see its own doc comment for why that component is generic over your own `Profile
107
115
  `ProfilePicker` positions its popover with `@tastic/hud`'s `useAutoAlign`, which measures the
108
116
  trigger with `measureInWindow` and compares that rect against the raw window. Inside a *fake*
109
117
  rotation (a `View` turned +-90deg while the OS stays portrait-locked) the rect is in true screen
110
- space but the frame is not, so the hook measures along the wrong axes and can pick the wrong side —
118
+ space but the frame is not, so the hook measures along the wrong axes and can pick the wrong side -
111
119
  flipping the list upward off-screen, or running it past the frame's bottom. Two optional props give
112
120
  you the same seam `@tastic/hud`'s `InlineColorPicker` has:
113
121
 
114
122
  | Prop | Type | Default | Effect |
115
123
  | --- | --- | --- | --- |
116
- | `align` | `'left' \| 'right' \| 'center'` | — | Forces the horizontal side; always beats the placement's own `align`. |
117
- | `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) | — | Replaces the automatic placement wholesale: its `align`/`verticalAlign`/`maxHeight`/`measured` drive the popover, and its `triggerRef` is the ref attached to the measured trigger. |
124
+ | `align` | `'left' \| 'right' \| 'center'` | - | Forces the horizontal side; always beats the placement's own `align`. |
125
+ | `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) | - | Replaces the automatic placement wholesale: its `align`/`verticalAlign`/`maxHeight`/`measured` drive the popover, and its `triggerRef` is the ref attached to the measured trigger. |
118
126
  | `rotation` | `0 \| 90 \| -90 \| 180` | `0` | Forwarded to the automatic `useAutoAlign` call (needs `@tastic/hud` >= 0.8.0). Ignored when `alignOverride` is given. |
119
127
 
120
128
  With none of them set the picker behaves exactly as it always has. Note `rotation` is never read
121
- from an ambient provider here (unlike `@tastic/hud`'s own pickers) — this package doesn't touch
122
- `@tastic/core` at runtime — so pass it explicitly.
129
+ from an ambient provider here (unlike `@tastic/hud`'s own pickers) - this package doesn't touch
130
+ `@tastic/core` at runtime - so pass it explicitly.
123
131
 
124
132
  To supply your own placement, run a rotation-aware placement hook against the popover's real size,
125
133
  using `getProfilePickerContentSize` so the two can't drift:
@@ -140,7 +148,7 @@ row per profile, and the Manage row when `onManage` is passed).
140
148
  #### The Profiles screen's color swatch: `useColorPickerAlign`
141
149
 
142
150
  `ProfilesManager` (and `ProfilesScreen`, which forwards it) renders `@tastic/hud`'s `InlineColorPicker`
143
- for the swatch popover in an edit row, and owns the popover state internally — so the host cannot pass
151
+ for the swatch popover in an edit row, and owns the popover state internally - so the host cannot pass
144
152
  `alignOverride` to it directly. Inside a fake-rotated frame that popover has the same misplacement (and
145
153
  `InlineColorPicker` also feeds `useAutoAlign` its ambient rotation, double-rotating an already-transformed
146
154
  rect). Pass a placement **hook** as `useColorPickerAlign`:
@@ -149,13 +157,13 @@ rect). Pass a placement **hook** as `useColorPickerAlign`:
149
157
  <ProfilesScreen useColorPickerAlign={useFrameAutoAlign} /* ...the usual props */ />
150
158
  ```
151
159
 
152
- It is called as `useColorPickerAlign(open, contentWidth, contentHeight)` — `open` is whether the swatch
160
+ It is called as `useColorPickerAlign(open, contentWidth, contentHeight)` - `open` is whether the swatch
153
161
  popover is showing, and the size is what `InlineColorPicker` itself computes (`@tastic/hud`'s
154
- `getInlineColorPickerContentSize` for the default swatch list at the raw window width) — and must return a
162
+ `getInlineColorPickerContentSize` for the default swatch list at the raw window width) - and must return a
155
163
  `ProfilePickerAlignResult`, which is handed to `InlineColorPicker` as its `alignOverride` (its `triggerRef`
156
164
  is the ref that gets measured). It is a hook, not a plain function: give it a stable identity for the life of
157
165
  the mount (a module-level function), since switching it at runtime remounts the swatch. Omit it and behaviour is
158
- identical to before. **Needs `@tastic/hud` >= 0.12.0** to have any effect — older hud has no `alignOverride`
166
+ identical to before. **Needs `@tastic/hud` >= 0.12.0** to have any effect - older hud has no `alignOverride`
159
167
  on `InlineColorPicker` and ignores it.
160
168
 
161
169
  ### Routed screens: `ProfilesScreen`
@@ -165,15 +173,15 @@ around `ProfilesManager`: a container `View`, theme-derived bg/fg, a `headerLeft
165
173
  the two-line `managerRef.current?.commitPendingEdit(); onBack()` composition every app's own
166
174
  back-button handler used to write out by hand (see `ProfilesManager`'s own `commitPendingEdit` doc
167
175
  above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It
168
- owns that ref itself — there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
176
+ owns that ref itself - there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
169
177
  one any more.
170
178
 
171
- Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` — still
179
+ Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` - still
172
180
  `ProfileEditPatch`-shaped, still deliberately not widened to your own richer `CreateProfileInput`;
173
181
  compose any extra required fields (a key/control scheme, whatever's specific to your game) in your
174
- own closure at the call site, exactly as you already would calling `ProfilesManager` directly —
182
+ own closure at the call site, exactly as you already would calling `ProfilesManager` directly -
175
183
  plus `onBack`. `fg`/`fgMuted`/`bg`/`cardBg`/`titleVariant`/`colorPreview` are all independently
176
- optional and forward straight through to the internal `ProfilesManager` (`bg` is new here —
184
+ optional and forward straight through to the internal `ProfilesManager` (`bg` is new here -
177
185
  `ProfilesManager` itself has no `bg`, since it doesn't own a container `View` of its own).
178
186
 
179
187
  A whole routed screen, wired to your own store, is usually this small:
@@ -189,7 +197,7 @@ import { useProfiles } from '@/hooks/useProfiles'
189
197
 
190
198
  export default function Profiles() {
191
199
  const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
192
- // Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
200
+ // Only for wiring onDelete below - clears a deleted profile's own stats record so it doesn't
193
201
  // stick around as an orphan once the profile itself is gone.
194
202
  const { removeProfileStats } = useGameStats()
195
203
 
@@ -209,11 +217,11 @@ export default function Profiles() {
209
217
  }
210
218
  ```
211
219
 
212
- `useProfiles`/`useGameStats` are your own app's hooks, not exports of this package — `profiles` is
220
+ `useProfiles`/`useGameStats` are your own app's hooks, not exports of this package - `profiles` is
213
221
  whatever `Profile[]` (or your own extended shape) you already keep in Redux/context/state, and
214
222
  `createProfile`/`updateProfile`/`deleteProfile` are however you already persist it. `onBack` can be
215
- anything with no arguments — `router.back()`, a `safeBack()`-style helper that no-ops with no back
216
- stack behind it, `navigation.goBack()` — `ProfilesScreen` only ever calls it after flushing any
223
+ anything with no arguments - `router.back()`, a `safeBack()`-style helper that no-ops with no back
224
+ stack behind it, `navigation.goBack()` - `ProfilesScreen` only ever calls it after flushing any
217
225
  in-progress inline edit first.
218
226
 
219
227
  ### Redux helpers for the shared roster
@@ -221,36 +229,46 @@ in-progress inline edit first.
221
229
  The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
222
230
  (a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync
223
231
  listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable,
224
- Redux-shaped building blocks — plain TypeScript, no dependency on `redux`/`react-redux` themselves,
232
+ Redux-shaped building blocks - plain TypeScript, no dependency on `redux`/`react-redux` themselves,
225
233
  so a host app plugs the reducer(s) into whatever store it already has:
226
234
 
227
- - **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
235
+ - **`profilesActions` / `profilesReducer` / `createProfileRecord`**: the base roster as a reducer:
228
236
  `add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
229
237
  `Profile` (id + timestamps) ready to dispatch via `profilesActions.add`.
230
- - **`createProfileExtensionSlice<TExtension>(namespace)`** — a factory for a host app's own
238
+ - **`createProfileExtensionSlice<TExtension>(namespace)`**: a factory for a host app's own
231
239
  per-profile extension fields (a control scheme, a key scheme, ...): returns a
232
240
  `{ actions: { set, remove }, reducer }` pair over a plain `Record<profileId, TExtension>`,
233
241
  namespaced so more than one extension slice in the same app never collides. Not auto-cleared when
234
- `profilesReducer` removes a profile — an app already dispatches its own cleanup action(s) on
242
+ `profilesReducer` removes a profile - an app already dispatches its own cleanup action(s) on
235
243
  delete, so this stays a self-contained reducer rather than new cross-slice wiring.
236
- - **`resolveInitialProfiles(groupId, localFallback)`** — the one-time initial-load decision: shared
237
- store unavailable → `localFallback` verbatim; shared store empty → seed it from `localFallback`;
238
- otherwise the shared roster always wins over a possibly-stale local snapshot.
239
- - **`useSharedProfilesSync({ groupId, onRemoteChange })`** — keeps a Redux-backed roster in sync for
244
+ - **`resolveInitialProfiles(groupId, localFallback)`**: the one-time initial-load decision: shared
245
+ store unavailable → `localFallback` verbatim; shared roster unreadable → `localFallback`, writing
246
+ nothing; shared store empty → seed it from `localFallback`; otherwise the shared roster always
247
+ wins over a possibly-stale local snapshot.
248
+ - **`useSharedProfilesSync({ groupId, onRemoteChange })`**: keeps a Redux-backed roster in sync for
240
249
  the rest of the app's lifetime: call the returned `syncToShared(next)` right after a local roster
241
250
  change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
242
251
  returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
243
- refresh can't clobber a write that hasn't landed yet).
244
- - **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
252
+ refresh can't clobber a write that hasn't landed yet, and skipped when the read fails).
253
+ - **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`**: a factory for the *other*
245
254
  per-seat piece of state: which profile id each seat currently has selected (parallel to
246
255
  `createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
247
- `{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
256
+ `{ actions: { select, clearProfile, pruneToRoster }, reducer }` pair over a plain
257
+ `Record<TSeat, string | null>`.
248
258
  Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
249
- don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
259
+ don't exist at runtime), so callers pass their own `defaultState` - the same literal object (e.g.
250
260
  `{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
251
- seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
252
- currently points at that id — call it when a profile is deleted so no seat is left referencing a
253
- ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
261
+ seat's selection; one profile is never on two seats, so picking a profile another seat already
262
+ has swaps the two (that seat takes this seat's previous pick, or guest), and REHYDRATE drops any
263
+ duplicate saved before this rule, keeping the first seat's. A swap moves a profile off a seat
264
+ without that seat's own select handler running, so reset per-seat state tied to the selection
265
+ (a profile color override, below) for both seats when a pick swaps. `clearProfile(profileId)` walks every seat and nulls out any that
266
+ currently points at that id - call it when a profile is deleted so no seat is left referencing a
267
+ ghost id. `pruneToRoster(ids)` nulls every seat whose id is no longer in the given roster; dispatch
268
+ it after both `profilesActions.setAll` paths (the initial `resolveInitialProfiles` reconcile and
269
+ `onRemoteChange`), since a sibling app can delete a profile without this app's own delete ever
270
+ running, and it returns the same state object when nothing changes.
271
+ Always local, like the guest/CPU/override colors below, and never part of the shared
254
272
  App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
255
273
 
256
274
  ## Muted profile colors (`ProfileColorProvider`)
@@ -276,26 +294,26 @@ untouched. `useProfileColorPreview()` returns the provider's function (or `undef
276
294
  ## Per-seat color persistence
277
295
 
278
296
  A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
279
- color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
297
+ color" - a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
280
298
  manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
281
299
  (AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
282
- of state to cover all three cases. This is the established pattern every game should follow — it
300
+ of state to cover all three cases. This is the established pattern every game should follow - it
283
301
  was previously undocumented at the package level, which is exactly why two of the five apps
284
302
  (AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
285
303
  it.
286
304
 
287
- - **`lastGuestColor`** — a `Record<Seat, string>`: what a seat's color was the last time it was a
305
+ - **`lastGuestColor`**: a `Record<Seat, string>`: what a seat's color was the last time it was a
288
306
  guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
289
307
  seat never reads or writes it.
290
- - **`lastCpuColor`** — the same idea for the CPU slot, as a single remembered `string` rather than a
308
+ - **`lastCpuColor`**: the same idea for the CPU slot, as a single remembered `string` rather than a
291
309
  per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
292
- - **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
293
- a seat that currently has a profile selected. `null` means "no override — track the profile's own
310
+ - **`profileOverride`**: a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
311
+ a seat that currently has a profile selected. `null` means "no override - track the profile's own
294
312
  saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
295
313
  merely leaving and returning to the screen.
296
314
 
297
315
  None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
298
- entirely, and `profileOverride` is still hand-rolled per app — typically alongside
316
+ entirely, and `profileOverride` is still hand-rolled per app - typically alongside
299
317
  `createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
300
318
  BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
301
319
  no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
@@ -313,17 +331,28 @@ const color = profile
313
331
  ```
314
332
 
315
333
  `lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
316
- all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
334
+ all - a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
317
335
  so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
318
336
  was ever selected there.
319
337
 
338
+ Each seat resolves from its own source, so two of them can land on the same color (a profile saved
339
+ as the other seat's guest color, say). Run seat 2's result through `avoidSeatColorClash` after
340
+ resolving both:
341
+
342
+ ```ts
343
+ const p2Display = avoidSeatColorClash(p1Color, p2Color, [DEFAULT_P1_COLOR, DEFAULT_P2_COLOR])
344
+ ```
345
+
346
+ On a clash, seat 2 shows seat 1's default color, or seat 2's default when seat 1 is already on its
347
+ own. It's for display only: don't persist it, so a guest's or the CPU's remembered color stays put.
348
+
320
349
  ## Install
321
350
 
322
351
  ```bash
323
352
  npm install @tastic/profile
324
353
  ```
325
354
 
326
- Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
355
+ Published to the public npm registry via tag-based CI (OIDC trusted publishing - see
327
356
  `npm run release:patch`/`:minor`/`:major`).
328
357
 
329
358
  ## Peer dependencies
@@ -332,9 +361,9 @@ Published to the public npm registry via tag-based CI (OIDC trusted publishing
332
361
  (`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
333
362
  `@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
334
363
  (`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
335
- (a peer of `@tastic/hud` itself) — none of these are bundled, so use whatever versions your app
364
+ (a peer of `@tastic/hud` itself) - none of these are bundled, so use whatever versions your app
336
365
  already has.
337
366
 
338
- `expo-modules-core` is an *optional* peer — only resolved by `loadSharedProfiles`/`saveSharedProfiles`
367
+ `expo-modules-core` is an *optional* peer - only resolved by `loadSharedProfiles`/`saveSharedProfiles`
339
368
  for the native App Group bridge described above; safe to omit entirely on an Android/web-only
340
369
  consumer.
package/dist/index.d.mts CHANGED
@@ -141,6 +141,7 @@ interface SelectProfilePayload<TSeat> {
141
141
  }
142
142
  interface ProfileSelectionActions<TSeat> {
143
143
  clearProfile: ActionCreator<string>;
144
+ pruneToRoster: ActionCreator<string[]>;
144
145
  select: ActionCreator<SelectProfilePayload<TSeat>>;
145
146
  }
146
147
  type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
@@ -211,8 +212,11 @@ interface UseSharedProfilesSyncResult {
211
212
  }
212
213
  declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
213
214
 
215
+ declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
216
+
214
217
  declare const isSharedProfileStoreAvailable: boolean;
218
+ declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
215
219
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
216
220
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
217
221
 
218
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
222
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, avoidSeatColorClash, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, readSharedProfiles, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
package/dist/index.d.ts CHANGED
@@ -141,6 +141,7 @@ interface SelectProfilePayload<TSeat> {
141
141
  }
142
142
  interface ProfileSelectionActions<TSeat> {
143
143
  clearProfile: ActionCreator<string>;
144
+ pruneToRoster: ActionCreator<string[]>;
144
145
  select: ActionCreator<SelectProfilePayload<TSeat>>;
145
146
  }
146
147
  type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
@@ -211,8 +212,11 @@ interface UseSharedProfilesSyncResult {
211
212
  }
212
213
  declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
213
214
 
215
+ declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
216
+
214
217
  declare const isSharedProfileStoreAvailable: boolean;
218
+ declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
215
219
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
216
220
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
217
221
 
218
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
222
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, avoidSeatColorClash, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, readSharedProfiles, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  ProfilePicker: () => ProfilePicker,
28
28
  ProfilesManager: () => ProfilesManager,
29
29
  ProfilesScreen: () => ProfilesScreen,
30
+ avoidSeatColorClash: () => avoidSeatColorClash,
30
31
  createProfileExtensionSlice: () => createProfileExtensionSlice,
31
32
  createProfileRecord: () => createProfileRecord,
32
33
  createProfileSelectionSlice: () => createProfileSelectionSlice,
@@ -38,6 +39,7 @@ __export(index_exports, {
38
39
  loadSharedProfiles: () => loadSharedProfiles,
39
40
  profilesActions: () => profilesActions,
40
41
  profilesReducer: () => profilesReducer,
42
+ readSharedProfiles: () => readSharedProfiles,
41
43
  resolveInitialProfiles: () => resolveInitialProfiles,
42
44
  saveSharedProfiles: () => saveSharedProfiles,
43
45
  useProfileColorPreview: () => useProfileColorPreview,
@@ -189,12 +191,12 @@ var styles2 = import_react_native2.StyleSheet.create({
189
191
  anchor: {
190
192
  position: "relative"
191
193
  },
192
- // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
194
+ // See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
193
195
  // view its own stacking context, so the elevation has to live on the anchor itself.
194
196
  anchorOpen: {
195
197
  zIndex: 100
196
198
  },
197
- // Separates Manage (a navigation action) from the selectable rows above it — different kind of
199
+ // Separates Manage (a navigation action) from the selectable rows above it - different kind of
198
200
  // action, not just another row in the same list. Tinted with the seat's own color (passed in as
199
201
  // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
200
202
  divider: {
@@ -203,7 +205,7 @@ var styles2 = import_react_native2.StyleSheet.create({
203
205
  opacity: 0.25
204
206
  },
205
207
  // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
206
- // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
208
+ // - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
207
209
  // own to fill it with, and the render site now colors the icon directly instead.
208
210
  guestChip: {
209
211
  alignItems: "center",
@@ -217,7 +219,7 @@ var styles2 = import_react_native2.StyleSheet.create({
217
219
  fontWeight: "700",
218
220
  letterSpacing: 0.5
219
221
  },
220
- // Centered rather than left-aligned like a normal row — see this row's own render-site comment
222
+ // Centered rather than left-aligned like a normal row - see this row's own render-site comment
221
223
  // for why (a footer action, not another selectable identity).
222
224
  manageRowInner: {
223
225
  alignItems: "center",
@@ -235,7 +237,7 @@ var styles2 = import_react_native2.StyleSheet.create({
235
237
  shadowOpacity: 0.3,
236
238
  shadowRadius: 8
237
239
  },
238
- // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
240
+ // Padded on every side (not just top/bottom) - a selected row's own full-color background (see
239
241
  // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
240
242
  // pill instead of looking clipped against them.
241
243
  menuContent: {
@@ -478,7 +480,7 @@ var styles3 = import_react_native3.StyleSheet.create({
478
480
  gap: 4,
479
481
  paddingBottom: 8
480
482
  },
481
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
483
+ // 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
482
484
  // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
483
485
  // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
484
486
  hiddenTagInput: {
@@ -607,12 +609,31 @@ var REHYDRATE = "persist/REHYDRATE";
607
609
  function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
608
610
  const select = createActionCreator(`profileSelection/${namespace}/select`);
609
611
  const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
612
+ const pruneToRoster = createActionCreator(`profileSelection/${namespace}/pruneToRoster`);
610
613
  function reducer(state = defaultState, action) {
611
614
  if (action.type === REHYDRATE) {
612
615
  const persisted = action.payload?.[mountKey];
613
- return { ...defaultState, ...state, ...persisted };
616
+ const merged = { ...defaultState, ...state, ...persisted };
617
+ const seen = /* @__PURE__ */ new Set();
618
+ for (const seat of Object.keys(merged)) {
619
+ const profileId = merged[seat];
620
+ if (profileId === null) continue;
621
+ if (seen.has(profileId)) merged[seat] = null;
622
+ else seen.add(profileId);
623
+ }
624
+ return merged;
625
+ }
626
+ if (select.match(action)) {
627
+ const { profileId, seat } = action.payload;
628
+ const previous = state[seat] ?? null;
629
+ const next = { ...state, [seat]: profileId };
630
+ if (profileId !== null) {
631
+ for (const other of Object.keys(state)) {
632
+ if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
633
+ }
634
+ }
635
+ return next;
614
636
  }
615
- if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
616
637
  if (clearProfile.match(action)) {
617
638
  const next = { ...state };
618
639
  for (const seat of Object.keys(state)) {
@@ -620,9 +641,22 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
620
641
  }
621
642
  return next;
622
643
  }
644
+ if (pruneToRoster.match(action)) {
645
+ const roster = new Set(action.payload);
646
+ const next = { ...state };
647
+ let changed = false;
648
+ for (const seat of Object.keys(state)) {
649
+ const profileId = state[seat];
650
+ if (profileId !== null && !roster.has(profileId)) {
651
+ next[seat] = null;
652
+ changed = true;
653
+ }
654
+ }
655
+ return changed ? next : state;
656
+ }
623
657
  return state;
624
658
  }
625
- return { actions: { clearProfile, select }, reducer };
659
+ return { actions: { clearProfile, pruneToRoster, select }, reducer };
626
660
  }
627
661
 
628
662
  // src/redux/profilesSlice.ts
@@ -659,22 +693,27 @@ try {
659
693
  }
660
694
  var PROFILES_KEY = "sharedProfiles";
661
695
  var isSharedProfileStoreAvailable = native !== null;
662
- async function loadSharedProfiles(groupId) {
696
+ async function readSharedProfiles(groupId) {
663
697
  if (!native) return [];
664
698
  let json = null;
665
699
  try {
666
700
  json = await native.getSharedJSON(groupId, PROFILES_KEY);
667
701
  } catch {
668
- return [];
702
+ return null;
669
703
  }
670
704
  if (!json) return [];
671
705
  try {
672
706
  const parsed = JSON.parse(json);
673
- return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
707
+ if (!Array.isArray(parsed)) return null;
708
+ const profiles = parsed.filter(isValidProfile);
709
+ return parsed.length > 0 && profiles.length === 0 ? null : profiles;
674
710
  } catch {
675
- return [];
711
+ return null;
676
712
  }
677
713
  }
714
+ async function loadSharedProfiles(groupId) {
715
+ return await readSharedProfiles(groupId) ?? [];
716
+ }
678
717
  async function saveSharedProfiles(groupId, profiles) {
679
718
  if (!native) return;
680
719
  try {
@@ -686,7 +725,8 @@ async function saveSharedProfiles(groupId, profiles) {
686
725
  // src/redux/resolveInitialProfiles.ts
687
726
  async function resolveInitialProfiles(groupId, localFallback) {
688
727
  if (!isSharedProfileStoreAvailable) return localFallback;
689
- const shared = await loadSharedProfiles(groupId);
728
+ const shared = await readSharedProfiles(groupId);
729
+ if (shared === null) return localFallback;
690
730
  if (shared.length === 0 && localFallback.length > 0) {
691
731
  saveSharedProfiles(groupId, localFallback).catch(() => {
692
732
  });
@@ -728,8 +768,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
728
768
  let cancelled = false;
729
769
  const sub = import_react_native5.AppState.addEventListener("change", (next) => {
730
770
  if (next !== "active" || pendingWriteRef.current) return;
731
- loadSharedProfiles(groupId).then((shared) => {
732
- if (!cancelled) onRemoteChangeRef.current(shared);
771
+ readSharedProfiles(groupId).then((shared) => {
772
+ if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
733
773
  });
734
774
  });
735
775
  return () => {
@@ -750,6 +790,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
750
790
  );
751
791
  return { syncToShared };
752
792
  }
793
+
794
+ // src/seatColorClash.ts
795
+ function avoidSeatColorClash(p1Color, p2Color, defaults) {
796
+ if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
797
+ const [p1Default, p2Default] = defaults;
798
+ return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
799
+ }
753
800
  // Annotate the CommonJS export names for ESM import in node:
754
801
  0 && (module.exports = {
755
802
  MAX_PROFILE_NAME_LENGTH,
@@ -759,6 +806,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
759
806
  ProfilePicker,
760
807
  ProfilesManager,
761
808
  ProfilesScreen,
809
+ avoidSeatColorClash,
762
810
  createProfileExtensionSlice,
763
811
  createProfileRecord,
764
812
  createProfileSelectionSlice,
@@ -770,6 +818,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
770
818
  loadSharedProfiles,
771
819
  profilesActions,
772
820
  profilesReducer,
821
+ readSharedProfiles,
773
822
  resolveInitialProfiles,
774
823
  saveSharedProfiles,
775
824
  useProfileColorPreview,