@tastic/profile 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 —
175
- 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 —
182
+ own closure at the call site, exactly as you already would calling `ProfilesManager` directly -
183
+ plus `onBack`. `fg`/`fgMuted`/`bg`/`titleVariant`/`colorPreview` are all independently
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,41 @@ 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
256
  `{ actions: { select, clearProfile, pruneToRoster }, reducer }` pair over a plain
248
257
  `Record<TSeat, string | null>`.
249
258
  Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
250
- 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.
251
260
  `{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
252
- seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
253
- currently points at that id — call it when a profile is deleted so no seat is left referencing a
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
254
267
  ghost id. `pruneToRoster(ids)` nulls every seat whose id is no longer in the given roster; dispatch
255
268
  it after both `profilesActions.setAll` paths (the initial `resolveInitialProfiles` reconcile and
256
269
  `onRemoteChange`), since a sibling app can delete a profile without this app's own delete ever
@@ -281,31 +294,28 @@ untouched. `useProfileColorPreview()` returns the provider's function (or `undef
281
294
  ## Per-seat color persistence
282
295
 
283
296
  A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
284
- 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
285
298
  manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
286
299
  (AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
287
- 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
288
301
  was previously undocumented at the package level, which is exactly why two of the five apps
289
302
  (AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
290
303
  it.
291
304
 
292
- - **`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
293
306
  guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
294
307
  seat never reads or writes it.
295
- - **`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
296
309
  per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
297
- - **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
298
- 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
299
312
  saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
300
313
  merely leaving and returning to the screen.
301
314
 
302
- None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
303
- entirely, and `profileOverride` is still hand-rolled per app — typically alongside
304
- `createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
305
- BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
306
- no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
307
- app's author reads the convention here first, rather than hand-rolling something that quietly can't
308
- interop with the rest of the fleet.
315
+ All three live in one slice from this package's `createSeatColorsSlice` (since 0.5.0), which every
316
+ flagship game uses (see e.g. BoxHockey's or Snake's `src/redux/seatColorsSlice.ts`). This section
317
+ exists so a new app's author reads the convention here first, rather than hand-rolling something
318
+ that quietly can't interop with the rest of the fleet.
309
319
 
310
320
  The resolution order at a loadout/lobby screen, for one seat:
311
321
 
@@ -318,17 +328,28 @@ const color = profile
318
328
  ```
319
329
 
320
330
  `lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
321
- all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
331
+ all - a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
322
332
  so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
323
333
  was ever selected there.
324
334
 
335
+ Each seat resolves from its own source, so two of them can land on the same color (a profile saved
336
+ as the other seat's guest color, say). Run seat 2's result through `avoidSeatColorClash` after
337
+ resolving both:
338
+
339
+ ```ts
340
+ const p2Display = avoidSeatColorClash(p1Color, p2Color, [DEFAULT_P1_COLOR, DEFAULT_P2_COLOR])
341
+ ```
342
+
343
+ On a clash, seat 2 shows seat 1's default color, or seat 2's default when seat 1 is already on its
344
+ own. It's for display only: don't persist it, so a guest's or the CPU's remembered color stays put.
345
+
325
346
  ## Install
326
347
 
327
348
  ```bash
328
349
  npm install @tastic/profile
329
350
  ```
330
351
 
331
- Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
352
+ Published to the public npm registry via tag-based CI (OIDC trusted publishing - see
332
353
  `npm run release:patch`/`:minor`/`:major`).
333
354
 
334
355
  ## Peer dependencies
@@ -337,9 +358,9 @@ Published to the public npm registry via tag-based CI (OIDC trusted publishing
337
358
  (`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
338
359
  `@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
339
360
  (`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
340
- (a peer of `@tastic/hud` itself) — none of these are bundled, so use whatever versions your app
361
+ (a peer of `@tastic/hud` itself) - none of these are bundled, so use whatever versions your app
341
362
  already has.
342
363
 
343
- `expo-modules-core` is an *optional* peer — only resolved by `loadSharedProfiles`/`saveSharedProfiles`
364
+ `expo-modules-core` is an *optional* peer - only resolved by `loadSharedProfiles`/`saveSharedProfiles`
344
365
  for the native App Group bridge described above; safe to omit entirely on an Android/web-only
345
366
  consumer.
package/dist/index.d.mts CHANGED
@@ -77,12 +77,11 @@ interface ProfilesManagerProps {
77
77
  ref?: Ref<ProfilesManagerHandle>;
78
78
  fg?: string;
79
79
  fgMuted?: string;
80
- cardBg?: string;
81
80
  titleVariant?: MD3TextVariant$1;
82
81
  colorPreview?: (hex: string) => string;
83
82
  useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
84
83
  }
85
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
84
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
86
85
 
87
86
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
88
87
  interface ProfilesScreenProps {
@@ -95,12 +94,11 @@ interface ProfilesScreenProps {
95
94
  fg?: string;
96
95
  fgMuted?: string;
97
96
  bg?: string;
98
- cardBg?: string;
99
97
  titleVariant?: MD3TextVariant;
100
98
  colorPreview?: (hex: string) => string;
101
99
  useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign'];
102
100
  }
103
- declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
101
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
104
102
 
105
103
  declare const MAX_PROFILE_NAME_LENGTH = 20;
106
104
  declare const MAX_TAG_LENGTH = 3;
@@ -212,8 +210,11 @@ interface UseSharedProfilesSyncResult {
212
210
  }
213
211
  declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
214
212
 
213
+ declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
214
+
215
215
  declare const isSharedProfileStoreAvailable: boolean;
216
+ declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
216
217
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
217
218
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
218
219
 
219
- 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 };
220
+ 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
@@ -77,12 +77,11 @@ interface ProfilesManagerProps {
77
77
  ref?: Ref<ProfilesManagerHandle>;
78
78
  fg?: string;
79
79
  fgMuted?: string;
80
- cardBg?: string;
81
80
  titleVariant?: MD3TextVariant$1;
82
81
  colorPreview?: (hex: string) => string;
83
82
  useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
84
83
  }
85
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
84
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
86
85
 
87
86
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
88
87
  interface ProfilesScreenProps {
@@ -95,12 +94,11 @@ interface ProfilesScreenProps {
95
94
  fg?: string;
96
95
  fgMuted?: string;
97
96
  bg?: string;
98
- cardBg?: string;
99
97
  titleVariant?: MD3TextVariant;
100
98
  colorPreview?: (hex: string) => string;
101
99
  useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign'];
102
100
  }
103
- declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
101
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesScreenProps): react.JSX.Element;
104
102
 
105
103
  declare const MAX_PROFILE_NAME_LENGTH = 20;
106
104
  declare const MAX_TAG_LENGTH = 3;
@@ -212,8 +210,11 @@ interface UseSharedProfilesSyncResult {
212
210
  }
213
211
  declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
214
212
 
213
+ declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
214
+
215
215
  declare const isSharedProfileStoreAvailable: boolean;
216
+ declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
216
217
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
217
218
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
218
219
 
219
- 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 };
220
+ 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 };