@tastic/profile 0.11.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 +21 -0
- package/README.md +87 -63
- package/dist/index.d.mts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +50 -15
- package/dist/index.mjs +48 -15
- package/ios/TasticProfileModule.swift +2 -2
- package/package.json +2 -2
- package/src/ProfileChip.tsx +5 -5
- package/src/ProfilePicker.tsx +31 -31
- package/src/ProfilesManager.tsx +45 -45
- package/src/ProfilesScreen.tsx +8 -8
- package/src/index.ts +2 -1
- package/src/profilesValidation.ts +9 -9
- package/src/redux/createActionCreator.ts +1 -1
- package/src/redux/profileExtensionSlice.ts +3 -3
- package/src/redux/profileSelectionSlice.ts +39 -11
- package/src/redux/resolveInitialProfiles.ts +10 -6
- package/src/redux/seatColorsSlice.ts +10 -10
- package/src/redux/useSharedProfilesSync.ts +11 -10
- package/src/seatColorClash.ts +13 -0
- package/src/sharedProfileStore.ts +27 -12
- package/src/types.ts +3 -3
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
|
|
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
|
|
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
|
|
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
|
|
35
|
-
- **`isValidProfile` / `isValidTag
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?)`
|
|
55
|
+
`getProfilePickerContentSize(profileCount, hasManageRow?)` - the popover's
|
|
56
56
|
`{ width, height }`, for sizing your own placement hook.
|
|
57
|
-
- **`ProfilesManager
|
|
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)
|
|
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 }`)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:)`
|
|
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'` |
|
|
117
|
-
| `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) |
|
|
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)
|
|
122
|
-
`@tastic/core` at runtime
|
|
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
|
|
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)`
|
|
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)
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
216
|
-
stack behind it, `navigation.goBack()`
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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)
|
|
237
|
-
store unavailable → `localFallback` verbatim; shared
|
|
238
|
-
|
|
239
|
-
|
|
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)
|
|
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`
|
|
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
|
|
253
|
-
|
|
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,26 +294,26 @@ 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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
298
|
-
a seat that currently has a profile selected. `null` means "no override
|
|
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
315
|
None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
|
|
303
|
-
entirely, and `profileOverride` is still hand-rolled per app
|
|
316
|
+
entirely, and `profileOverride` is still hand-rolled per app - typically alongside
|
|
304
317
|
`createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
|
|
305
318
|
BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
|
|
306
319
|
no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
|
|
@@ -318,17 +331,28 @@ const color = profile
|
|
|
318
331
|
```
|
|
319
332
|
|
|
320
333
|
`lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
|
|
321
|
-
all
|
|
334
|
+
all - a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
|
|
322
335
|
so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
|
|
323
336
|
was ever selected there.
|
|
324
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
|
+
|
|
325
349
|
## Install
|
|
326
350
|
|
|
327
351
|
```bash
|
|
328
352
|
npm install @tastic/profile
|
|
329
353
|
```
|
|
330
354
|
|
|
331
|
-
Published to the public npm registry via tag-based CI (OIDC trusted publishing
|
|
355
|
+
Published to the public npm registry via tag-based CI (OIDC trusted publishing - see
|
|
332
356
|
`npm run release:patch`/`:minor`/`:major`).
|
|
333
357
|
|
|
334
358
|
## Peer dependencies
|
|
@@ -337,9 +361,9 @@ Published to the public npm registry via tag-based CI (OIDC trusted publishing
|
|
|
337
361
|
(`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
|
|
338
362
|
`@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
|
|
339
363
|
(`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
|
|
340
|
-
(a peer of `@tastic/hud` itself)
|
|
364
|
+
(a peer of `@tastic/hud` itself) - none of these are bundled, so use whatever versions your app
|
|
341
365
|
already has.
|
|
342
366
|
|
|
343
|
-
`expo-modules-core` is an *optional* peer
|
|
367
|
+
`expo-modules-core` is an *optional* peer - only resolved by `loadSharedProfiles`/`saveSharedProfiles`
|
|
344
368
|
for the native App Group bridge described above; safe to omit entirely on an Android/web-only
|
|
345
369
|
consumer.
|
package/dist/index.d.mts
CHANGED
|
@@ -212,8 +212,11 @@ interface UseSharedProfilesSyncResult {
|
|
|
212
212
|
}
|
|
213
213
|
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
214
214
|
|
|
215
|
+
declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
|
|
216
|
+
|
|
215
217
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
218
|
+
declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
|
|
216
219
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
217
220
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
218
221
|
|
|
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 };
|
|
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
|
@@ -212,8 +212,11 @@ interface UseSharedProfilesSyncResult {
|
|
|
212
212
|
}
|
|
213
213
|
declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
|
|
214
214
|
|
|
215
|
+
declare function avoidSeatColorClash(p1Color: string, p2Color: string, defaults: readonly [p1Default: string, p2Default: string]): string;
|
|
216
|
+
|
|
215
217
|
declare const isSharedProfileStoreAvailable: boolean;
|
|
218
|
+
declare function readSharedProfiles(groupId: string): Promise<Profile[] | null>;
|
|
216
219
|
declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
|
|
217
220
|
declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
|
|
218
221
|
|
|
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 };
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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)
|
|
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
|
|
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: {
|
|
@@ -611,9 +613,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
|
|
|
611
613
|
function reducer(state = defaultState, action) {
|
|
612
614
|
if (action.type === REHYDRATE) {
|
|
613
615
|
const persisted = action.payload?.[mountKey];
|
|
614
|
-
|
|
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;
|
|
615
636
|
}
|
|
616
|
-
if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
|
|
617
637
|
if (clearProfile.match(action)) {
|
|
618
638
|
const next = { ...state };
|
|
619
639
|
for (const seat of Object.keys(state)) {
|
|
@@ -673,22 +693,27 @@ try {
|
|
|
673
693
|
}
|
|
674
694
|
var PROFILES_KEY = "sharedProfiles";
|
|
675
695
|
var isSharedProfileStoreAvailable = native !== null;
|
|
676
|
-
async function
|
|
696
|
+
async function readSharedProfiles(groupId) {
|
|
677
697
|
if (!native) return [];
|
|
678
698
|
let json = null;
|
|
679
699
|
try {
|
|
680
700
|
json = await native.getSharedJSON(groupId, PROFILES_KEY);
|
|
681
701
|
} catch {
|
|
682
|
-
return
|
|
702
|
+
return null;
|
|
683
703
|
}
|
|
684
704
|
if (!json) return [];
|
|
685
705
|
try {
|
|
686
706
|
const parsed = JSON.parse(json);
|
|
687
|
-
|
|
707
|
+
if (!Array.isArray(parsed)) return null;
|
|
708
|
+
const profiles = parsed.filter(isValidProfile);
|
|
709
|
+
return parsed.length > 0 && profiles.length === 0 ? null : profiles;
|
|
688
710
|
} catch {
|
|
689
|
-
return
|
|
711
|
+
return null;
|
|
690
712
|
}
|
|
691
713
|
}
|
|
714
|
+
async function loadSharedProfiles(groupId) {
|
|
715
|
+
return await readSharedProfiles(groupId) ?? [];
|
|
716
|
+
}
|
|
692
717
|
async function saveSharedProfiles(groupId, profiles) {
|
|
693
718
|
if (!native) return;
|
|
694
719
|
try {
|
|
@@ -700,7 +725,8 @@ async function saveSharedProfiles(groupId, profiles) {
|
|
|
700
725
|
// src/redux/resolveInitialProfiles.ts
|
|
701
726
|
async function resolveInitialProfiles(groupId, localFallback) {
|
|
702
727
|
if (!isSharedProfileStoreAvailable) return localFallback;
|
|
703
|
-
const shared = await
|
|
728
|
+
const shared = await readSharedProfiles(groupId);
|
|
729
|
+
if (shared === null) return localFallback;
|
|
704
730
|
if (shared.length === 0 && localFallback.length > 0) {
|
|
705
731
|
saveSharedProfiles(groupId, localFallback).catch(() => {
|
|
706
732
|
});
|
|
@@ -742,8 +768,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
742
768
|
let cancelled = false;
|
|
743
769
|
const sub = import_react_native5.AppState.addEventListener("change", (next) => {
|
|
744
770
|
if (next !== "active" || pendingWriteRef.current) return;
|
|
745
|
-
|
|
746
|
-
if (!cancelled) onRemoteChangeRef.current(shared);
|
|
771
|
+
readSharedProfiles(groupId).then((shared) => {
|
|
772
|
+
if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
|
|
747
773
|
});
|
|
748
774
|
});
|
|
749
775
|
return () => {
|
|
@@ -764,6 +790,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
764
790
|
);
|
|
765
791
|
return { syncToShared };
|
|
766
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
|
+
}
|
|
767
800
|
// Annotate the CommonJS export names for ESM import in node:
|
|
768
801
|
0 && (module.exports = {
|
|
769
802
|
MAX_PROFILE_NAME_LENGTH,
|
|
@@ -773,6 +806,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
773
806
|
ProfilePicker,
|
|
774
807
|
ProfilesManager,
|
|
775
808
|
ProfilesScreen,
|
|
809
|
+
avoidSeatColorClash,
|
|
776
810
|
createProfileExtensionSlice,
|
|
777
811
|
createProfileRecord,
|
|
778
812
|
createProfileSelectionSlice,
|
|
@@ -784,6 +818,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
|
|
|
784
818
|
loadSharedProfiles,
|
|
785
819
|
profilesActions,
|
|
786
820
|
profilesReducer,
|
|
821
|
+
readSharedProfiles,
|
|
787
822
|
resolveInitialProfiles,
|
|
788
823
|
saveSharedProfiles,
|
|
789
824
|
useProfileColorPreview,
|