@tastic/profile 0.4.0 → 0.7.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/README.md CHANGED
@@ -53,7 +53,17 @@ see its own doc comment for why that component is generic over your own `Profile
53
53
  - **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
54
54
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
55
55
  router, no back button) — a host app renders this as a routed screen's body and supplies its own
56
- persistence via `profiles`/`onCreate`/`onSave`/`onDelete`.
56
+ persistence via `profiles`/`onCreate`/`onSave`/`onDelete`. `onCreate`/`onSave` both take a
57
+ `ProfileEditPatch` (`{ name, color, tag }`) — exported from this package's own public API (it was
58
+ a private, unexported interface inside `ProfilesManager.tsx` until `ProfilesScreen` below needed
59
+ to reference it from its own props).
60
+ - **`ProfilesScreen`** — the routed-screen shell around `ProfilesManager`: the container `View`,
61
+ theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate
62
+ composition every app in the fleet used to hand-roll on its own around `ProfilesManager` (see
63
+ "Routed screens: `ProfilesScreen`" below). Same `ProfileEditPatch`-shaped
64
+ `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` as `ProfilesManager` itself, plus
65
+ `onBack`. Owns its own `ProfilesManagerHandle` ref internally — there's no `ref` prop here, since
66
+ nothing outside it needs one anymore.
57
67
  - **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
58
68
  cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
59
69
  Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
@@ -88,13 +98,71 @@ see its own doc comment for why that component is generic over your own `Profile
88
98
  { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
89
99
  ```
90
100
 
101
+ ### Routed screens: `ProfilesScreen`
102
+
103
+ `ProfilesScreen` is the routed-screen shell every app in the fleet was hand-rolling on its own
104
+ around `ProfilesManager`: a container `View`, theme-derived bg/fg, a `headerLeft` back button, and
105
+ the two-line `managerRef.current?.commitPendingEdit(); onBack()` composition every app's own
106
+ back-button handler used to write out by hand (see `ProfilesManager`'s own `commitPendingEdit` doc
107
+ above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It
108
+ owns that ref itself — there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
109
+ one any more.
110
+
111
+ Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` — still
112
+ `ProfileEditPatch`-shaped, still deliberately not widened to your own richer `CreateProfileInput`;
113
+ compose any extra required fields (a key/control scheme, whatever's specific to your game) in your
114
+ own closure at the call site, exactly as you already would calling `ProfilesManager` directly —
115
+ plus `onBack`. `fg`/`fgMuted`/`bg`/`cardBg`/`titleVariant`/`colorPreview` are all independently
116
+ optional and forward straight through to the internal `ProfilesManager` (`bg` is new here —
117
+ `ProfilesManager` itself has no `bg`, since it doesn't own a container `View` of its own).
118
+
119
+ A whole routed screen, wired to your own store, is usually this small:
120
+
121
+ ```tsx
122
+ // app/profiles.tsx
123
+ import { defaultColors } from '@rific/auto-paper'
124
+ import { ProfilesScreen } from '@tastic/profile'
125
+ import { router } from 'expo-router'
126
+
127
+ import { useGameStats } from '@/hooks/useGameStats'
128
+ import { useProfiles } from '@/hooks/useProfiles'
129
+
130
+ export default function Profiles() {
131
+ const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
132
+ // Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
133
+ // stick around as an orphan once the profile itself is gone.
134
+ const { removeProfileStats } = useGameStats()
135
+
136
+ return (
137
+ <ProfilesScreen
138
+ profiles={profiles}
139
+ defaultColor={defaultColors[0].value}
140
+ onCreate={(patch) => createProfile(patch)}
141
+ onSave={(id, patch) => updateProfile(id, patch)}
142
+ onDelete={(id) => {
143
+ deleteProfile(id)
144
+ removeProfileStats(id)
145
+ }}
146
+ onBack={() => router.back()}
147
+ />
148
+ )
149
+ }
150
+ ```
151
+
152
+ `useProfiles`/`useGameStats` are your own app's hooks, not exports of this package — `profiles` is
153
+ whatever `Profile[]` (or your own extended shape) you already keep in Redux/context/state, and
154
+ `createProfile`/`updateProfile`/`deleteProfile` are however you already persist it. `onBack` can be
155
+ anything with no arguments — `router.back()`, a `safeBack()`-style helper that no-ops with no back
156
+ stack behind it, `navigation.goBack()` — `ProfilesScreen` only ever calls it after flushing any
157
+ in-progress inline edit first.
158
+
91
159
  ### Redux helpers for the shared roster
92
160
 
93
161
  The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
94
162
  (a roster reducer, a per-profile extension table, the initial-load decision, a foreground-resync
95
- listener). These four pieces are that logic extracted into reusable, Redux-shaped building blocks —
96
- plain TypeScript, no dependency on `redux`/`react-redux` themselves, so a host app plugs the
97
- reducer(s) into whatever store it already has:
163
+ listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable,
164
+ Redux-shaped building blocks — plain TypeScript, no dependency on `redux`/`react-redux` themselves,
165
+ so a host app plugs the reducer(s) into whatever store it already has:
98
166
 
99
167
  - **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
100
168
  `add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
@@ -113,30 +181,70 @@ reducer(s) into whatever store it already has:
113
181
  change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
114
182
  returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
115
183
  refresh can't clobber a write that hasn't landed yet).
184
+ - **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
185
+ per-seat piece of state: which profile id each seat currently has selected (parallel to
186
+ `createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
187
+ `{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
188
+ Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
189
+ don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
190
+ `{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
191
+ seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
192
+ currently points at that id — call it when a profile is deleted so no seat is left referencing a
193
+ ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
194
+ App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
116
195
 
117
- ## Install
196
+ ## Per-seat color persistence
118
197
 
119
- ```bash
120
- npm install @tastic/profile
198
+ A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
199
+ color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
200
+ manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
201
+ (AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
202
+ of state to cover all three cases. This is the established pattern every game should follow — it
203
+ was previously undocumented at the package level, which is exactly why two of the five apps
204
+ (AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
205
+ it.
206
+
207
+ - **`lastGuestColor`** — a `Record<Seat, string>`: what a seat's color was the last time it was a
208
+ guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
209
+ seat never reads or writes it.
210
+ - **`lastCpuColor`** — the same idea for the CPU slot, as a single remembered `string` rather than a
211
+ per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
212
+ - **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
213
+ a seat that currently has a profile selected. `null` means "no override — track the profile's own
214
+ saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
215
+ merely leaving and returning to the screen.
216
+
217
+ None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
218
+ entirely, and `profileOverride` is still hand-rolled per app — typically alongside
219
+ `createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
220
+ BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
221
+ no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
222
+ app's author reads the convention here first, rather than hand-rolling something that quietly can't
223
+ interop with the rest of the fleet.
224
+
225
+ The resolution order at a loadout/lobby screen, for one seat:
226
+
227
+ ```ts
228
+ const color = profile
229
+ ? (profileOverride[seat] ?? profile.color) // profile selected: override wins, else the profile's own color
230
+ : isCpu
231
+ ? lastCpuColor // CPU slot: its own remembered color
232
+ : lastGuestColor[seat] // guest seat: that seat's own remembered color
121
233
  ```
122
234
 
123
- Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
124
- `npm run release:patch`/`:minor`/`:major`); no local linking needed for normal use.
235
+ `lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
236
+ all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
237
+ so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
238
+ was ever selected there.
125
239
 
126
- To develop against a local change before it's published, use `yalc` instead:
240
+ ## Install
127
241
 
128
242
  ```bash
129
- cd react-native-game-profile
130
- npm run build
131
- yalc publish
132
-
133
- cd ../your-game
134
- yalc add @tastic/profile
135
- npm install
243
+ npm install @tastic/profile
136
244
  ```
137
245
 
138
- Re-run `npm run build && yalc push` from this package after any change to propagate it to every
139
- linked consumer at once.
246
+ Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
247
+ `npm run release:patch`/`:minor`/`:major`).
140
248
 
141
249
  ## Peer dependencies
142
250
 
package/dist/index.d.mts CHANGED
@@ -35,7 +35,7 @@ interface ProfilePickerProps<P extends Profile> {
35
35
  }
36
36
  declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
37
 
38
- type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
38
+ type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
39
  interface ProfileEditPatch {
40
40
  name: string;
41
41
  color: string;
@@ -55,11 +55,28 @@ interface ProfilesManagerProps {
55
55
  fg?: string;
56
56
  fgMuted?: string;
57
57
  cardBg?: string;
58
- titleVariant?: MD3TextVariant;
58
+ titleVariant?: MD3TextVariant$1;
59
59
  colorPreview?: (hex: string) => string;
60
60
  }
61
61
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
62
62
 
63
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
64
+ interface ProfilesScreenProps {
65
+ profiles: Profile[];
66
+ defaultColor: string;
67
+ onCreate: (patch: ProfileEditPatch) => void;
68
+ onSave: (id: string, patch: ProfileEditPatch) => void;
69
+ onDelete: (id: string) => void;
70
+ onBack: () => void;
71
+ fg?: string;
72
+ fgMuted?: string;
73
+ bg?: string;
74
+ cardBg?: string;
75
+ titleVariant?: MD3TextVariant;
76
+ colorPreview?: (hex: string) => string;
77
+ }
78
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
79
+
63
80
  declare const MAX_PROFILE_NAME_LENGTH = 20;
64
81
  declare const MAX_TAG_LENGTH = 3;
65
82
  declare function isValidTag(value: unknown): value is string;
@@ -93,6 +110,24 @@ interface ProfileExtensionSlice<TExtension> {
93
110
  }
94
111
  declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
112
 
113
+ interface SelectProfilePayload<TSeat> {
114
+ profileId: string | null;
115
+ seat: TSeat;
116
+ }
117
+ interface ProfileSelectionActions<TSeat> {
118
+ clearProfile: ActionCreator<string>;
119
+ select: ActionCreator<SelectProfilePayload<TSeat>>;
120
+ }
121
+ type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
122
+ interface ProfileSelectionSlice<TSeat extends string | number> {
123
+ actions: ProfileSelectionActions<TSeat>;
124
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
125
+ type: string;
126
+ payload?: unknown;
127
+ }) => ProfileSelectionState<TSeat>;
128
+ }
129
+ declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
130
+
96
131
  interface UpdateProfilePayload {
97
132
  id: string;
98
133
  patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
@@ -115,6 +150,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
150
 
116
151
  declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
152
 
153
+ interface SetLastGuestColorPayload<TSeat> {
154
+ color: string;
155
+ seat: TSeat;
156
+ }
157
+ interface SetProfileOverridePayload<TSeat> {
158
+ color: string | null;
159
+ seat: TSeat;
160
+ }
161
+ interface SeatColorsActions<TSeat> {
162
+ setLastCpuColor: ActionCreator<string>;
163
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
164
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
165
+ }
166
+ type SeatColorsState<TSeat extends string | number> = {
167
+ lastCpuColor: string;
168
+ lastGuestColor: Record<TSeat, string>;
169
+ profileOverride: Record<TSeat, string | null>;
170
+ };
171
+ interface SeatColorsSlice<TSeat extends string | number> {
172
+ actions: SeatColorsActions<TSeat>;
173
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: {
174
+ type: string;
175
+ payload?: unknown;
176
+ }) => SeatColorsState<TSeat>;
177
+ }
178
+ declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
179
+
118
180
  interface UseSharedProfilesSyncOptions {
119
181
  groupId: string;
120
182
  onRemoteChange: (profiles: Profile[]) => void;
@@ -128,4 +190,4 @@ declare const isSharedProfileStoreAvailable: boolean;
128
190
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
129
191
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
130
192
 
131
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
193
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, 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, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
package/dist/index.d.ts CHANGED
@@ -35,7 +35,7 @@ interface ProfilePickerProps<P extends Profile> {
35
35
  }
36
36
  declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
37
 
38
- type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
38
+ type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
39
  interface ProfileEditPatch {
40
40
  name: string;
41
41
  color: string;
@@ -55,11 +55,28 @@ interface ProfilesManagerProps {
55
55
  fg?: string;
56
56
  fgMuted?: string;
57
57
  cardBg?: string;
58
- titleVariant?: MD3TextVariant;
58
+ titleVariant?: MD3TextVariant$1;
59
59
  colorPreview?: (hex: string) => string;
60
60
  }
61
61
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
62
62
 
63
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
64
+ interface ProfilesScreenProps {
65
+ profiles: Profile[];
66
+ defaultColor: string;
67
+ onCreate: (patch: ProfileEditPatch) => void;
68
+ onSave: (id: string, patch: ProfileEditPatch) => void;
69
+ onDelete: (id: string) => void;
70
+ onBack: () => void;
71
+ fg?: string;
72
+ fgMuted?: string;
73
+ bg?: string;
74
+ cardBg?: string;
75
+ titleVariant?: MD3TextVariant;
76
+ colorPreview?: (hex: string) => string;
77
+ }
78
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
79
+
63
80
  declare const MAX_PROFILE_NAME_LENGTH = 20;
64
81
  declare const MAX_TAG_LENGTH = 3;
65
82
  declare function isValidTag(value: unknown): value is string;
@@ -93,6 +110,24 @@ interface ProfileExtensionSlice<TExtension> {
93
110
  }
94
111
  declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
112
 
113
+ interface SelectProfilePayload<TSeat> {
114
+ profileId: string | null;
115
+ seat: TSeat;
116
+ }
117
+ interface ProfileSelectionActions<TSeat> {
118
+ clearProfile: ActionCreator<string>;
119
+ select: ActionCreator<SelectProfilePayload<TSeat>>;
120
+ }
121
+ type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
122
+ interface ProfileSelectionSlice<TSeat extends string | number> {
123
+ actions: ProfileSelectionActions<TSeat>;
124
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
125
+ type: string;
126
+ payload?: unknown;
127
+ }) => ProfileSelectionState<TSeat>;
128
+ }
129
+ declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
130
+
96
131
  interface UpdateProfilePayload {
97
132
  id: string;
98
133
  patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
@@ -115,6 +150,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
150
 
116
151
  declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
152
 
153
+ interface SetLastGuestColorPayload<TSeat> {
154
+ color: string;
155
+ seat: TSeat;
156
+ }
157
+ interface SetProfileOverridePayload<TSeat> {
158
+ color: string | null;
159
+ seat: TSeat;
160
+ }
161
+ interface SeatColorsActions<TSeat> {
162
+ setLastCpuColor: ActionCreator<string>;
163
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
164
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
165
+ }
166
+ type SeatColorsState<TSeat extends string | number> = {
167
+ lastCpuColor: string;
168
+ lastGuestColor: Record<TSeat, string>;
169
+ profileOverride: Record<TSeat, string | null>;
170
+ };
171
+ interface SeatColorsSlice<TSeat extends string | number> {
172
+ actions: SeatColorsActions<TSeat>;
173
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: {
174
+ type: string;
175
+ payload?: unknown;
176
+ }) => SeatColorsState<TSeat>;
177
+ }
178
+ declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
179
+
118
180
  interface UseSharedProfilesSyncOptions {
119
181
  groupId: string;
120
182
  onRemoteChange: (profiles: Profile[]) => void;
@@ -128,4 +190,4 @@ declare const isSharedProfileStoreAvailable: boolean;
128
190
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
129
191
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
130
192
 
131
- export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, type SetProfileExtensionPayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
193
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, 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, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useSharedProfilesSync };
package/dist/index.js CHANGED
@@ -25,8 +25,11 @@ __export(index_exports, {
25
25
  ProfileChip: () => ProfileChip,
26
26
  ProfilePicker: () => ProfilePicker,
27
27
  ProfilesManager: () => ProfilesManager,
28
+ ProfilesScreen: () => ProfilesScreen,
28
29
  createProfileExtensionSlice: () => createProfileExtensionSlice,
29
30
  createProfileRecord: () => createProfileRecord,
31
+ createProfileSelectionSlice: () => createProfileSelectionSlice,
32
+ createSeatColorsSlice: () => createSeatColorsSlice,
30
33
  isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
31
34
  isValidProfile: () => isValidProfile,
32
35
  isValidTag: () => isValidTag,
@@ -514,6 +517,29 @@ var styles3 = import_react_native3.StyleSheet.create({
514
517
  }
515
518
  });
516
519
 
520
+ // src/ProfilesScreen.tsx
521
+ var import_auto_paper4 = require("@rific/auto-paper");
522
+ var import_feedback_press3 = require("@rific/feedback-press");
523
+ var import_react3 = require("react");
524
+ var import_react_native4 = require("react-native");
525
+ var import_jsx_runtime4 = require("react/jsx-runtime");
526
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
527
+ const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
528
+ const managerRef = (0, import_react3.useRef)(null);
529
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
530
+ const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
531
+ const handleBack = () => {
532
+ managerRef.current?.commitPendingEdit();
533
+ onBack();
534
+ };
535
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
536
+ }
537
+ var styles4 = import_react_native4.StyleSheet.create({
538
+ container: {
539
+ flex: 1
540
+ }
541
+ });
542
+
517
543
  // src/redux/createActionCreator.ts
518
544
  function createActionCreator(type) {
519
545
  const creator = ((payload) => ({ payload, type }));
@@ -539,6 +565,29 @@ function createProfileExtensionSlice(namespace) {
539
565
  return { actions: { remove: remove2, set }, reducer };
540
566
  }
541
567
 
568
+ // src/redux/profileSelectionSlice.ts
569
+ var REHYDRATE = "persist/REHYDRATE";
570
+ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
571
+ const select = createActionCreator(`profileSelection/${namespace}/select`);
572
+ const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
573
+ function reducer(state = defaultState, action) {
574
+ if (action.type === REHYDRATE) {
575
+ const persisted = action.payload?.[mountKey];
576
+ return { ...defaultState, ...state, ...persisted };
577
+ }
578
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
579
+ if (clearProfile.match(action)) {
580
+ const next = { ...state };
581
+ for (const seat of Object.keys(state)) {
582
+ if (next[seat] === action.payload) next[seat] = null;
583
+ }
584
+ return next;
585
+ }
586
+ return state;
587
+ }
588
+ return { actions: { clearProfile, select }, reducer };
589
+ }
590
+
542
591
  // src/redux/profilesSlice.ts
543
592
  var setAll = createActionCreator("profiles/setAll");
544
593
  var add = createActionCreator("profiles/add");
@@ -609,19 +658,38 @@ async function resolveInitialProfiles(groupId, localFallback) {
609
658
  return shared;
610
659
  }
611
660
 
661
+ // src/redux/seatColorsSlice.ts
662
+ var REHYDRATE2 = "persist/REHYDRATE";
663
+ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
664
+ const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
665
+ const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
666
+ const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
667
+ function reducer(state = defaultState, action) {
668
+ if (action.type === REHYDRATE2) {
669
+ const persisted = action.payload?.[mountKey];
670
+ return { ...defaultState, ...state, ...persisted };
671
+ }
672
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
673
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
674
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
675
+ return state;
676
+ }
677
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
678
+ }
679
+
612
680
  // src/redux/useSharedProfilesSync.ts
613
- var import_react3 = require("react");
614
- var import_react_native4 = require("react-native");
681
+ var import_react4 = require("react");
682
+ var import_react_native5 = require("react-native");
615
683
  function useSharedProfilesSync({ groupId, onRemoteChange }) {
616
- const pendingWriteRef = (0, import_react3.useRef)(false);
617
- const onRemoteChangeRef = (0, import_react3.useRef)(onRemoteChange);
618
- (0, import_react3.useEffect)(() => {
684
+ const pendingWriteRef = (0, import_react4.useRef)(false);
685
+ const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
686
+ (0, import_react4.useEffect)(() => {
619
687
  onRemoteChangeRef.current = onRemoteChange;
620
688
  }, [onRemoteChange]);
621
- (0, import_react3.useEffect)(() => {
689
+ (0, import_react4.useEffect)(() => {
622
690
  if (!isSharedProfileStoreAvailable) return;
623
691
  let cancelled = false;
624
- const sub = import_react_native4.AppState.addEventListener("change", (next) => {
692
+ const sub = import_react_native5.AppState.addEventListener("change", (next) => {
625
693
  if (next !== "active" || pendingWriteRef.current) return;
626
694
  loadSharedProfiles(groupId).then((shared) => {
627
695
  if (!cancelled) onRemoteChangeRef.current(shared);
@@ -632,7 +700,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
632
700
  sub.remove();
633
701
  };
634
702
  }, [groupId]);
635
- const syncToShared = (0, import_react3.useCallback)(
703
+ const syncToShared = (0, import_react4.useCallback)(
636
704
  (next) => {
637
705
  if (!isSharedProfileStoreAvailable) return;
638
706
  pendingWriteRef.current = true;
@@ -652,8 +720,11 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
652
720
  ProfileChip,
653
721
  ProfilePicker,
654
722
  ProfilesManager,
723
+ ProfilesScreen,
655
724
  createProfileExtensionSlice,
656
725
  createProfileRecord,
726
+ createProfileSelectionSlice,
727
+ createSeatColorsSlice,
657
728
  isSharedProfileStoreAvailable,
658
729
  isValidProfile,
659
730
  isValidTag,
package/dist/index.mjs CHANGED
@@ -480,6 +480,29 @@ var styles3 = StyleSheet3.create({
480
480
  }
481
481
  });
482
482
 
483
+ // src/ProfilesScreen.tsx
484
+ import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
485
+ import { IconButton } from "@rific/feedback-press";
486
+ import { useRef as useRef2 } from "react";
487
+ import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
488
+ import { jsx as jsx4 } from "react/jsx-runtime";
489
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
490
+ const { dark } = useAutoPaperTheme2();
491
+ const managerRef = useRef2(null);
492
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
493
+ const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
494
+ const handleBack = () => {
495
+ managerRef.current?.commitPendingEdit();
496
+ onBack();
497
+ };
498
+ return /* @__PURE__ */ jsx4(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx4(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, headerLeft: /* @__PURE__ */ jsx4(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
499
+ }
500
+ var styles4 = StyleSheet4.create({
501
+ container: {
502
+ flex: 1
503
+ }
504
+ });
505
+
483
506
  // src/redux/createActionCreator.ts
484
507
  function createActionCreator(type) {
485
508
  const creator = ((payload) => ({ payload, type }));
@@ -505,6 +528,29 @@ function createProfileExtensionSlice(namespace) {
505
528
  return { actions: { remove: remove2, set }, reducer };
506
529
  }
507
530
 
531
+ // src/redux/profileSelectionSlice.ts
532
+ var REHYDRATE = "persist/REHYDRATE";
533
+ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
534
+ const select = createActionCreator(`profileSelection/${namespace}/select`);
535
+ const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
536
+ function reducer(state = defaultState, action) {
537
+ if (action.type === REHYDRATE) {
538
+ const persisted = action.payload?.[mountKey];
539
+ return { ...defaultState, ...state, ...persisted };
540
+ }
541
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
542
+ if (clearProfile.match(action)) {
543
+ const next = { ...state };
544
+ for (const seat of Object.keys(state)) {
545
+ if (next[seat] === action.payload) next[seat] = null;
546
+ }
547
+ return next;
548
+ }
549
+ return state;
550
+ }
551
+ return { actions: { clearProfile, select }, reducer };
552
+ }
553
+
508
554
  // src/redux/profilesSlice.ts
509
555
  var setAll = createActionCreator("profiles/setAll");
510
556
  var add = createActionCreator("profiles/add");
@@ -575,12 +621,31 @@ async function resolveInitialProfiles(groupId, localFallback) {
575
621
  return shared;
576
622
  }
577
623
 
624
+ // src/redux/seatColorsSlice.ts
625
+ var REHYDRATE2 = "persist/REHYDRATE";
626
+ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
627
+ const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
628
+ const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
629
+ const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
630
+ function reducer(state = defaultState, action) {
631
+ if (action.type === REHYDRATE2) {
632
+ const persisted = action.payload?.[mountKey];
633
+ return { ...defaultState, ...state, ...persisted };
634
+ }
635
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
636
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
637
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
638
+ return state;
639
+ }
640
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
641
+ }
642
+
578
643
  // src/redux/useSharedProfilesSync.ts
579
- import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
644
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef3 } from "react";
580
645
  import { AppState } from "react-native";
581
646
  function useSharedProfilesSync({ groupId, onRemoteChange }) {
582
- const pendingWriteRef = useRef2(false);
583
- const onRemoteChangeRef = useRef2(onRemoteChange);
647
+ const pendingWriteRef = useRef3(false);
648
+ const onRemoteChangeRef = useRef3(onRemoteChange);
584
649
  useEffect2(() => {
585
650
  onRemoteChangeRef.current = onRemoteChange;
586
651
  }, [onRemoteChange]);
@@ -617,8 +682,11 @@ export {
617
682
  ProfileChip,
618
683
  ProfilePicker,
619
684
  ProfilesManager,
685
+ ProfilesScreen,
620
686
  createProfileExtensionSlice,
621
687
  createProfileRecord,
688
+ createProfileSelectionSlice,
689
+ createSeatColorsSlice,
622
690
  isSharedProfileStoreAvailable,
623
691
  isValidProfile,
624
692
  isValidTag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
5
  "keywords": [
6
6
  "react-native",
@@ -63,14 +63,14 @@
63
63
  "prettier": "@infinitetoken/eslint-config/prettier",
64
64
  "devDependencies": {
65
65
  "@infinitetoken/eslint-config": "0.2.0",
66
- "@infinitetoken/jest-config": "^0.2.4",
67
- "@infinitetoken/tsconfig": "^0.4.1",
68
- "@rific/auto-paper": "^0.11.0",
69
- "@rific/core": "^0.1.1",
70
- "@rific/feedback-press": "^0.11.1",
66
+ "@infinitetoken/jest-config": "^0.4.0",
67
+ "@infinitetoken/tsconfig": "^0.5.0",
68
+ "@rific/auto-paper": "^0.12.0",
69
+ "@rific/core": "^0.2.0",
70
+ "@rific/feedback-press": "^0.12.0",
71
71
  "@rific/focus-chain": "^0.4.7",
72
72
  "@rific/toaster": "^0.6.3",
73
- "@tastic/core": "^0.2.0",
73
+ "@tastic/core": "^0.8.0",
74
74
  "@tastic/hud": "^0.8.5",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
@@ -21,7 +21,10 @@ const CHIP_SIZE = 40
21
21
  // as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
22
22
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
23
23
 
24
- interface ProfileEditPatch {
24
+ // Exported (not just used internally) so a host composing a screen around this component — e.g.
25
+ // @tastic/profile's own ProfilesScreen — can reference this exact shape in its own onCreate/onSave
26
+ // prop types without redeclaring it.
27
+ export interface ProfileEditPatch {
25
28
  name: string
26
29
  color: string
27
30
  tag: string
@@ -0,0 +1,83 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { IconButton } from '@rific/feedback-press'
3
+ import { useRef } from 'react'
4
+ import { StyleSheet, View } from 'react-native'
5
+
6
+ import { ProfileEditPatch, ProfilesManager, ProfilesManagerHandle } from './ProfilesManager'
7
+ import { Profile } from './types'
8
+
9
+ // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
10
+ // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
11
+ // as ProfilesManager's own identical titleVariant prop does.
12
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
13
+
14
+ export interface ProfilesScreenProps {
15
+ profiles: Profile[]
16
+ // Same reasoning as ProfilesManager's own defaultColor: this component has no opinion on the
17
+ // host's palette, so it stays required rather than defaulted. Callers already differ here today
18
+ // (most apps pass @rific/auto-paper's defaultColors[0].value; one passes its own local
19
+ // DEFAULT_P1_COLOR constant instead) — this prop is exactly what lets both keep working unchanged.
20
+ defaultColor: string
21
+ // Same ProfileEditPatch shape ProfilesManager's own onCreate/onSave already take — deliberately
22
+ // NOT widened to accept a host's own richer CreateProfileInput. A host whose profile shape has
23
+ // extra required fields (e.g. a control/key scheme) composes those in its own closure at the call
24
+ // site, exactly as it already does calling <ProfilesManager> today.
25
+ onCreate: (patch: ProfileEditPatch) => void
26
+ onSave: (id: string, patch: ProfileEditPatch) => void
27
+ // Irreversible, same as ProfilesManager's own onDelete — fires once, after this component's
28
+ // internal confirmation card. If the host tracks anything else keyed by profile id (stats,
29
+ // achievement-unlock history), clear that on its own side inside this callback, same as every
30
+ // current app's `(id) => { deleteProfile(id); removeProfileStats(id) }`.
31
+ onDelete: (id: string) => void
32
+ onBack: () => void
33
+ // Independently optional overrides, each defaulting to the same literal-black/white-by-appearance
34
+ // formula every current caller renders with unchanged (none of them pass these today) — same
35
+ // convention as BaseStatsScreen's and ProfilesManager's own fg/bg-shaped overrides. fg/fgMuted/
36
+ // cardBg/titleVariant/colorPreview are forwarded straight through to the internal ProfilesManager.
37
+ fg?: string
38
+ fgMuted?: string
39
+ bg?: string
40
+ cardBg?: string
41
+ titleVariant?: MD3TextVariant
42
+ colorPreview?: (hex: string) => string
43
+ }
44
+
45
+ // The routed profiles-roster screen shell every app in this fleet hand-rolls around ProfilesManager
46
+ // today: the container View + theme-derived bg/fg + back button + commit-pending-edit-before-
47
+ // navigate composition. Lives here rather than in @tastic/hud since it has zero generic/non-profile
48
+ // UI content — it's one layer up from ProfilesManager, not a different kind of thing. Deliberately
49
+ // still doesn't call useProfiles()/useGameStats() (or any host-specific hook) itself — those two
50
+ // hooks' shapes are genuinely app-specific (different CreateProfileInput/GameStatsContextValue per
51
+ // app), so this stays props-in/callbacks-out, exactly like ProfilesManager itself.
52
+ export function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps) {
53
+ const { dark } = useAutoPaperTheme()
54
+ // Owns the ref itself — nothing outside this component ever needs it, since this is now the one
55
+ // thing that both renders ProfilesManager AND owns the only navigation trigger next to it.
56
+ const managerRef = useRef<ProfilesManagerHandle>(null)
57
+ // Same literal-black/white-by-appearance formula as ProfilesManager's own fg default, resolved
58
+ // here (rather than left to ProfilesManager) so this component's own headerLeft IconButton and
59
+ // ProfilesManager's title always share the identical color.
60
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
61
+ const bg = bgOverride ?? (dark ? '#000000' : '#FFFFFF')
62
+
63
+ // Folds in the exact 2-line composition every app hand-rolls today: a hardware back gesture or
64
+ // swipe-back unmounts ProfilesManager (which flushes on its own), but a host router that keeps a
65
+ // popped screen mounted-but-hidden instead of unmounting it (expo-router's web renderer) needs
66
+ // this explicit flush before navigating away, or an in-progress edit is silently lost.
67
+ const handleBack = () => {
68
+ managerRef.current?.commitPendingEdit()
69
+ onBack()
70
+ }
71
+
72
+ return (
73
+ <View style={[styles.container, { backgroundColor: bg }]}>
74
+ <ProfilesManager ref={managerRef} profiles={profiles} defaultColor={defaultColor} onCreate={onCreate} onSave={onSave} onDelete={onDelete} fg={fg} fgMuted={fgMuted} cardBg={cardBg} titleVariant={titleVariant} colorPreview={colorPreview} headerLeft={<IconButton icon='arrow-left' iconColor={fg} size={24} onPress={handleBack} accessibilityLabel='Back' />} />
75
+ </View>
76
+ )
77
+ }
78
+
79
+ const styles = StyleSheet.create({
80
+ container: {
81
+ flex: 1
82
+ }
83
+ })
package/src/index.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  export { ProfileChip } from './ProfileChip'
2
2
  export { ProfilePicker } from './ProfilePicker'
3
- export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
3
+ export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
4
+ export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
4
5
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
5
6
  export { createProfileExtensionSlice, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, type SetProfileExtensionPayload } from './redux/profileExtensionSlice'
7
+ export { createProfileSelectionSlice, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, type SelectProfilePayload } from './redux/profileSelectionSlice'
6
8
  export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
7
9
  export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
10
+ export { createSeatColorsSlice, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SetLastGuestColorPayload, type SetProfileOverridePayload } from './redux/seatColorsSlice'
8
11
  export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
9
12
  export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
10
13
  export type { Profile } from './types'
@@ -0,0 +1,81 @@
1
+ import { ActionCreator, createActionCreator } from './createActionCreator'
2
+
3
+ // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
+ // dependency on the package itself — this factory otherwise has no opinion on whether the
5
+ // consuming app uses redux-persist at all, and the string itself is a long-stable part of
6
+ // redux-persist's own public contract (unchanged across every major version). See the reducer's
7
+ // own REHYDRATE case below for why this needs handling at all.
8
+ const REHYDRATE = 'persist/REHYDRATE'
9
+
10
+ export interface SelectProfilePayload<TSeat> {
11
+ profileId: string | null
12
+ seat: TSeat
13
+ }
14
+
15
+ export interface ProfileSelectionActions<TSeat> {
16
+ clearProfile: ActionCreator<string>
17
+ select: ActionCreator<SelectProfilePayload<TSeat>>
18
+ }
19
+
20
+ export type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>
21
+
22
+ export interface ProfileSelectionSlice<TSeat extends string | number> {
23
+ actions: ProfileSelectionActions<TSeat>
24
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: { type: string; payload?: unknown }) => ProfileSelectionState<TSeat>
25
+ }
26
+
27
+ // Which profile each seat last picked — always local, never part of the shared App Group roster
28
+ // (this package's own profiles slice, see profilesSlice.ts): who's selected on THIS device is
29
+ // naturally a per-app, per-device thing, not something a sibling @tastic game should see or
30
+ // influence. Five @tastic games (AirHockey, BoxHockey, Pong, LightCycles, Snake) each hand-rolled
31
+ // an identical Record<Seat, string | null> reducer before this factory existed — same duplication
32
+ // createProfileExtensionSlice generalized away for per-profile extensions, this is that shape for
33
+ // per-seat selection instead.
34
+ //
35
+ // `namespace` prefixes every action type (e.g. 'profileSelection/loadout/select') so an app with
36
+ // more than one independent seat-selection concern never collides between them — same convention
37
+ // createProfileExtensionSlice's own namespace argument follows.
38
+ //
39
+ // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
40
+ // exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
41
+ // `{ 1: null, 2: null }`) each app already declares as its own `defaultProfileSelectionState`
42
+ // constant today. `clearProfile` walks that state's own keys rather than assuming any particular
43
+ // seat count, so it reproduces the 5 apps' current two-seat behavior without hardcoding "1"/"2".
44
+ //
45
+ // `mountKey` defaults to 'profileSelection' — the literal key every current consumer (AirHockey,
46
+ // BoxHockey, Pong, LightCycles, Snake) already mounts this reducer under in its own
47
+ // combineReducers({...}) call (e.g. `combineReducers({ ..., profileSelection })`). It has to be
48
+ // passed explicitly rather than inferred, since redux-persist's REHYDRATE payload is keyed by
49
+ // whatever name the app's own root reducer happens to use, which this factory has no way to see
50
+ // from inside its own reducer function — only worth overriding if some future consumer ever mounts
51
+ // this under a different key. Note `namespace` above is a different thing: it's the app's own name
52
+ // (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
53
+ export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
54
+ const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
55
+ const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
56
+
57
+ function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
58
+ // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
59
+ // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
60
+ // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
61
+ // so a device with a blob persisted before a new seat existed would otherwise rehydrate with
62
+ // that seat's key missing entirely, and every consumer indexes into it (`state[seat]`) with no
63
+ // fallback. Spreading `defaultState` first, then the incoming persisted value, backfills exactly
64
+ // that gap — the same pattern createSeatColorsSlice's own REHYDRATE case already uses.
65
+ if (action.type === REHYDRATE) {
66
+ const persisted = (action.payload as Record<string, Partial<ProfileSelectionState<TSeat>>> | undefined)?.[mountKey]
67
+ return { ...defaultState, ...state, ...persisted }
68
+ }
69
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId } as ProfileSelectionState<TSeat>
70
+ if (clearProfile.match(action)) {
71
+ const next = { ...state }
72
+ for (const seat of Object.keys(state) as TSeat[]) {
73
+ if (next[seat] === action.payload) next[seat] = null
74
+ }
75
+ return next
76
+ }
77
+ return state
78
+ }
79
+
80
+ return { actions: { clearProfile, select }, reducer }
81
+ }
@@ -0,0 +1,96 @@
1
+ import { ActionCreator, createActionCreator } from './createActionCreator'
2
+
3
+ // redux-persist's own REHYDRATE action-type constant, inlined as a literal rather than taking a
4
+ // dependency on the package itself — this factory otherwise has no opinion on whether the
5
+ // consuming app uses redux-persist at all, and the string itself is a long-stable part of
6
+ // redux-persist's own public contract (unchanged across every major version). See the reducer's
7
+ // own REHYDRATE case below for why this needs handling at all.
8
+ const REHYDRATE = 'persist/REHYDRATE'
9
+
10
+ export interface SetLastGuestColorPayload<TSeat> {
11
+ color: string
12
+ seat: TSeat
13
+ }
14
+
15
+ export interface SetProfileOverridePayload<TSeat> {
16
+ color: string | null
17
+ seat: TSeat
18
+ }
19
+
20
+ export interface SeatColorsActions<TSeat> {
21
+ setLastCpuColor: ActionCreator<string>
22
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>
23
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>
24
+ }
25
+
26
+ export type SeatColorsState<TSeat extends string | number> = {
27
+ lastCpuColor: string
28
+ lastGuestColor: Record<TSeat, string>
29
+ profileOverride: Record<TSeat, string | null>
30
+ }
31
+
32
+ export interface SeatColorsSlice<TSeat extends string | number> {
33
+ actions: SeatColorsActions<TSeat>
34
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: { type: string; payload?: unknown }) => SeatColorsState<TSeat>
35
+ }
36
+
37
+ // Which color a seat should show right now, remembered independently for three sources that must
38
+ // never fight over one value: a guest seat's own last pick, the CPU seat's own last pick, and a
39
+ // manual recolor/clash-swap override landing on a seat that currently has a profile selected (null
40
+ // meaning "no override, track that profile's own saved color live"). Four @tastic games (AirHockey,
41
+ // BoxHockey, LightCycles, Pong) each hand-rolled an identical dedicated seatColorsSlice.ts — same
42
+ // three fields, same createSlice-based reducer, differing only in their own seat type's name
43
+ // (Player) and default-color constants — before this factory existed; this is that shape,
44
+ // generalized over the seat type exactly like createProfileSelectionSlice generalized per-seat
45
+ // profile selection.
46
+ //
47
+ // Snake has the same three fields too, but folded directly into its own catch-all gameSlice.ts
48
+ // alongside a dozen unrelated persisted settings (wrapEdges, cpuDifficulty, speedTier, ...) rather
49
+ // than in a standalone slice of their own. There's no separate `seatColors` sub-state there for this
50
+ // factory's reducer to own without restructuring gameSlice's top-level shape to nest these three
51
+ // fields under their own key first — a real state-shape migration (and a redux-persist rehydration
52
+ // concern, see gameSlice's own REHYDRATE extraReducer), not a drop-in swap. This factory
53
+ // intentionally targets the standalone-slice shape the other four apps already have; Snake staying
54
+ // hand-rolled inside gameSlice is a deliberate scope call for this pass, not an oversight.
55
+ //
56
+ // `namespace` prefixes every action type (e.g. 'seatColors/loadout/setLastGuestColor') so an app
57
+ // with more than one independent seat-colors concern never collides between them — same convention
58
+ // createProfileSelectionSlice's and createProfileExtensionSlice's own namespace arguments follow.
59
+ //
60
+ // Unlike createProfileExtensionSlice, the seat set can't be derived from TSeat alone (types don't
61
+ // exist at runtime), so callers pass their own `defaultState` — same reasoning, and the same
62
+ // {1: ..., 2: ...}-shaped literal each app already declares as its own defaultSeatColorsState
63
+ // constant today, as createProfileSelectionSlice's own defaultState parameter.
64
+ //
65
+ // `mountKey` defaults to 'seatColors' — the literal key every current consumer (AirHockey,
66
+ // BoxHockey, Pong) already mounts this reducer under in its own combineReducers({...}) call (e.g.
67
+ // `combineReducers({ ..., seatColors })`). It has to be passed explicitly rather than inferred,
68
+ // since redux-persist's REHYDRATE payload is keyed by whatever name the app's own root reducer
69
+ // happens to use, which this factory has no way to see from inside its own reducer function — only
70
+ // worth overriding if some future consumer ever mounts this under a different key.
71
+ export function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey = 'seatColors'): SeatColorsSlice<TSeat> {
72
+ const setLastCpuColor = createActionCreator<string>(`seatColors/${namespace}/setLastCpuColor`)
73
+ const setLastGuestColor = createActionCreator<SetLastGuestColorPayload<TSeat>>(`seatColors/${namespace}/setLastGuestColor`)
74
+ const setProfileOverride = createActionCreator<SetProfileOverridePayload<TSeat>>(`seatColors/${namespace}/setProfileOverride`)
75
+
76
+ function reducer(state: SeatColorsState<TSeat> = defaultState, action: { type: string; payload?: unknown }): SeatColorsState<TSeat> {
77
+ // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
78
+ // entire persisted sub-state on rehydration rather than backfilling missing fields (confirmed
79
+ // against autoMergeLevel1's actual source: `newState[key] = inboundState[key]`, not a merge) —
80
+ // so a device with a blob persisted before `profileOverride` existed would otherwise rehydrate
81
+ // with `state.profileOverride === undefined`, and every consumer indexes into it (`profileOverride[seat]`)
82
+ // with no fallback, throwing. Spreading `defaultState` first, then the incoming persisted
83
+ // value, backfills exactly that gap — the same pattern Snake's own hand-rolled `gameSlice.ts`
84
+ // already uses for its equivalent (non-factory) fields via its own REHYDRATE extraReducer.
85
+ if (action.type === REHYDRATE) {
86
+ const persisted = (action.payload as Record<string, Partial<SeatColorsState<TSeat>>> | undefined)?.[mountKey]
87
+ return { ...defaultState, ...state, ...persisted }
88
+ }
89
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload }
90
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } as Record<TSeat, string> }
91
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } as Record<TSeat, string | null> }
92
+ return state
93
+ }
94
+
95
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer }
96
+ }