@tastic/profile 0.4.0 → 0.8.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
@@ -49,11 +49,25 @@ see its own doc comment for why that component is generic over your own `Profile
49
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
51
  only — creating, renaming, and deleting all live in `ProfilesManager`. Generic over your own
52
- `Profile` type (see above).
52
+ `Profile` type (see above). Placement props (all optional, see "Fake-rotated frames" below):
53
+ `align` (force a horizontal side), `alignOverride` (substitute the whole placement result), and
54
+ `rotation` (forwarded to the automatic measurement). Also exports
55
+ `getProfilePickerContentSize(profileCount, hasManageRow?)` — the popover's
56
+ `{ width, height }`, for sizing your own placement hook.
53
57
  - **`ProfilesManager`** — the roster screen itself: create/rename/recolor/retag/delete, editing
54
58
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
55
59
  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`.
60
+ persistence via `profiles`/`onCreate`/`onSave`/`onDelete`. `onCreate`/`onSave` both take a
61
+ `ProfileEditPatch` (`{ name, color, tag }`) — exported from this package's own public API (it was
62
+ a private, unexported interface inside `ProfilesManager.tsx` until `ProfilesScreen` below needed
63
+ to reference it from its own props).
64
+ - **`ProfilesScreen`** — the routed-screen shell around `ProfilesManager`: the container `View`,
65
+ theme-derived background/foreground, a back button, and the commit-pending-edit-before-navigate
66
+ composition every app in the fleet used to hand-roll on its own around `ProfilesManager` (see
67
+ "Routed screens: `ProfilesScreen`" below). Same `ProfileEditPatch`-shaped
68
+ `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` as `ProfilesManager` itself, plus
69
+ `onBack`. Owns its own `ProfilesManagerHandle` ref internally — there's no `ref` prop here, since
70
+ nothing outside it needs one anymore.
57
71
  - **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
58
72
  cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
59
73
  Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
@@ -88,13 +102,105 @@ see its own doc comment for why that component is generic over your own `Profile
88
102
  { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
89
103
  ```
90
104
 
105
+ ### Fake-rotated frames: `ProfilePicker` placement
106
+
107
+ `ProfilePicker` positions its popover with `@tastic/hud`'s `useAutoAlign`, which measures the
108
+ trigger with `measureInWindow` and compares that rect against the raw window. Inside a *fake*
109
+ 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 —
111
+ flipping the list upward off-screen, or running it past the frame's bottom. Two optional props give
112
+ you the same seam `@tastic/hud`'s `InlineColorPicker` has:
113
+
114
+ | Prop | Type | Default | Effect |
115
+ | --- | --- | --- | --- |
116
+ | `align` | `'left' \| 'right' \| 'center'` | — | Forces the horizontal side; always beats the placement's own `align`. |
117
+ | `alignOverride` | `ProfilePickerAlignResult` (`{ align, verticalAlign, maxHeight, measured, triggerRef }`, the same shape as `useAutoAlign`'s return / `@tastic/hud`'s `AlignResult`) | — | Replaces the automatic placement wholesale: its `align`/`verticalAlign`/`maxHeight`/`measured` drive the popover, and its `triggerRef` is the ref attached to the measured trigger. |
118
+ | `rotation` | `0 \| 90 \| -90 \| 180` | `0` | Forwarded to the automatic `useAutoAlign` call (needs `@tastic/hud` >= 0.8.0). Ignored when `alignOverride` is given. |
119
+
120
+ With none of them set the picker behaves exactly as it always has. Note `rotation` is never read
121
+ from an ambient provider here (unlike `@tastic/hud`'s own pickers) — this package doesn't touch
122
+ `@tastic/core` at runtime — so pass it explicitly.
123
+
124
+ To supply your own placement, run a rotation-aware placement hook against the popover's real size,
125
+ using `getProfilePickerContentSize` so the two can't drift:
126
+
127
+ ```tsx
128
+ const open = host.openId === `${idPrefix}-profile`
129
+ const { width, height } = getProfilePickerContentSize(profiles.length, !!onManage)
130
+ const placement = useMyRotatedAlign(open, width, height) // returns a ProfilePickerAlignResult
131
+
132
+ <ProfilePicker idPrefix={idPrefix} host={host} profiles={profiles} /* ... */ alignOverride={placement} />
133
+ ```
134
+
135
+ `getProfilePickerContentSize(profileCount, hasManageRow = false)` returns `{ width, height }`: 220
136
+ wide, and `8 * 2 + (profileCount + 1 + (hasManageRow ? 1 : 0)) * 36` tall (the null/"guest" row, one
137
+ row per profile, and the Manage row when `onManage` is passed).
138
+
139
+ ### Routed screens: `ProfilesScreen`
140
+
141
+ `ProfilesScreen` is the routed-screen shell every app in the fleet was hand-rolling on its own
142
+ around `ProfilesManager`: a container `View`, theme-derived bg/fg, a `headerLeft` back button, and
143
+ the two-line `managerRef.current?.commitPendingEdit(); onBack()` composition every app's own
144
+ back-button handler used to write out by hand (see `ProfilesManager`'s own `commitPendingEdit` doc
145
+ above for why that flush matters on a router that doesn't genuinely unmount a popped screen). It
146
+ owns that ref itself — there's no `ref` prop on `ProfilesScreen`, since nothing outside it needs
147
+ one any more.
148
+
149
+ Same `profiles`/`defaultColor`/`onCreate`/`onSave`/`onDelete` props as `ProfilesManager` — still
150
+ `ProfileEditPatch`-shaped, still deliberately not widened to your own richer `CreateProfileInput`;
151
+ compose any extra required fields (a key/control scheme, whatever's specific to your game) in your
152
+ own closure at the call site, exactly as you already would calling `ProfilesManager` directly —
153
+ plus `onBack`. `fg`/`fgMuted`/`bg`/`cardBg`/`titleVariant`/`colorPreview` are all independently
154
+ optional and forward straight through to the internal `ProfilesManager` (`bg` is new here —
155
+ `ProfilesManager` itself has no `bg`, since it doesn't own a container `View` of its own).
156
+
157
+ A whole routed screen, wired to your own store, is usually this small:
158
+
159
+ ```tsx
160
+ // app/profiles.tsx
161
+ import { defaultColors } from '@rific/auto-paper'
162
+ import { ProfilesScreen } from '@tastic/profile'
163
+ import { router } from 'expo-router'
164
+
165
+ import { useGameStats } from '@/hooks/useGameStats'
166
+ import { useProfiles } from '@/hooks/useProfiles'
167
+
168
+ export default function Profiles() {
169
+ const { profiles, createProfile, updateProfile, deleteProfile } = useProfiles()
170
+ // Only for wiring onDelete below — clears a deleted profile's own stats record so it doesn't
171
+ // stick around as an orphan once the profile itself is gone.
172
+ const { removeProfileStats } = useGameStats()
173
+
174
+ return (
175
+ <ProfilesScreen
176
+ profiles={profiles}
177
+ defaultColor={defaultColors[0].value}
178
+ onCreate={(patch) => createProfile(patch)}
179
+ onSave={(id, patch) => updateProfile(id, patch)}
180
+ onDelete={(id) => {
181
+ deleteProfile(id)
182
+ removeProfileStats(id)
183
+ }}
184
+ onBack={() => router.back()}
185
+ />
186
+ )
187
+ }
188
+ ```
189
+
190
+ `useProfiles`/`useGameStats` are your own app's hooks, not exports of this package — `profiles` is
191
+ whatever `Profile[]` (or your own extended shape) you already keep in Redux/context/state, and
192
+ `createProfile`/`updateProfile`/`deleteProfile` are however you already persist it. `onBack` can be
193
+ anything with no arguments — `router.back()`, a `safeBack()`-style helper that no-ops with no back
194
+ stack behind it, `navigation.goBack()` — `ProfilesScreen` only ever calls it after flushing any
195
+ in-progress inline edit first.
196
+
91
197
  ### Redux helpers for the shared roster
92
198
 
93
199
  The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
94
200
  (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:
201
+ listener, a per-seat selection reducer). These five pieces are that logic extracted into reusable,
202
+ Redux-shaped building blocks — plain TypeScript, no dependency on `redux`/`react-redux` themselves,
203
+ so a host app plugs the reducer(s) into whatever store it already has:
98
204
 
99
205
  - **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
100
206
  `add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
@@ -113,30 +219,70 @@ reducer(s) into whatever store it already has:
113
219
  change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
114
220
  returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
115
221
  refresh can't clobber a write that hasn't landed yet).
222
+ - **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
223
+ per-seat piece of state: which profile id each seat currently has selected (parallel to
224
+ `createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
225
+ `{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
226
+ Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
227
+ don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
228
+ `{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
229
+ seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
230
+ currently points at that id — call it when a profile is deleted so no seat is left referencing a
231
+ ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
232
+ App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
116
233
 
117
- ## Install
234
+ ## Per-seat color persistence
118
235
 
119
- ```bash
120
- npm install @tastic/profile
236
+ A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
237
+ color" — a seat can be a guest (no profile selected), a CPU opponent, or a profile the player
238
+ manually recolored for a clash-swap without touching the profile itself. Every `@tastic` game
239
+ (AirHockey, BoxHockey, Pong, LightCycles, Snake) ends up needing the same three cooperating pieces
240
+ of state to cover all three cases. This is the established pattern every game should follow — it
241
+ was previously undocumented at the package level, which is exactly why two of the five apps
242
+ (AirHockey, Snake) didn't pick up the third piece below until after they'd already shipped without
243
+ it.
244
+
245
+ - **`lastGuestColor`** — a `Record<Seat, string>`: what a seat's color was the last time it was a
246
+ guest (no profile selected). Written only when a guest seat's color changes; a profile-selected
247
+ seat never reads or writes it.
248
+ - **`lastCpuColor`** — the same idea for the CPU slot, as a single remembered `string` rather than a
249
+ per-seat record (a CPU opponent only ever occupies one fixed seat in every shipped game so far).
250
+ - **`profileOverride`** — a `Record<Seat, string | null>`: a manual recolor or clash-swap landing on
251
+ a seat that currently has a profile selected. `null` means "no override — track the profile's own
252
+ saved color live." Cleared only when that seat's own profile selection genuinely changes, never by
253
+ merely leaving and returning to the screen.
254
+
255
+ None of these three are exports of this package today. `lastGuestColor`/`lastCpuColor` predate it
256
+ entirely, and `profileOverride` is still hand-rolled per app — typically alongside
257
+ `createProfileSelectionSlice`'s own selection state, in the same seat-scoped slice (see e.g.
258
+ BoxHockey's `src/redux/seatColorsSlice.ts`) or folded into the app's own `gameSlice.ts` when there's
259
+ no dedicated slice to put it in (see Snake's `src/redux/gameSlice.ts`). This section exists so a new
260
+ app's author reads the convention here first, rather than hand-rolling something that quietly can't
261
+ interop with the rest of the fleet.
262
+
263
+ The resolution order at a loadout/lobby screen, for one seat:
264
+
265
+ ```ts
266
+ const color = profile
267
+ ? (profileOverride[seat] ?? profile.color) // profile selected: override wins, else the profile's own color
268
+ : isCpu
269
+ ? lastCpuColor // CPU slot: its own remembered color
270
+ : lastGuestColor[seat] // guest seat: that seat's own remembered color
121
271
  ```
122
272
 
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.
273
+ `lastGuestColor`/`lastCpuColor` are only ever consulted when no profile is selected for that seat at
274
+ all — a profile-selected seat's guest/CPU memory is neither read nor overwritten while it's active,
275
+ so switching a seat back to "guest" later still recalls whatever guest color it had before a profile
276
+ was ever selected there.
125
277
 
126
- To develop against a local change before it's published, use `yalc` instead:
278
+ ## Install
127
279
 
128
280
  ```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
281
+ npm install @tastic/profile
136
282
  ```
137
283
 
138
- Re-run `npm run build && yalc push` from this package after any change to propagate it to every
139
- linked consumer at once.
284
+ Published to the public npm registry via tag-based CI (OIDC trusted publishing — see
285
+ `npm run release:patch`/`:minor`/`:major`).
140
286
 
141
287
  ## Peer dependencies
142
288
 
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, Ref } from 'react';
3
- import { PopoverHost } from '@tastic/hud';
2
+ import { RefObject, ReactNode, Ref } from 'react';
3
+ import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
+ import { View } from 'react-native';
4
5
 
5
6
  interface Profile {
6
7
  id: string;
@@ -18,6 +19,18 @@ interface Props {
18
19
  }
19
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
20
21
 
22
+ interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center';
24
+ verticalAlign: 'below' | 'above';
25
+ maxHeight: number;
26
+ measured: boolean;
27
+ triggerRef: RefObject<View | null>;
28
+ }
29
+ interface ProfilePickerContentSize {
30
+ width: number;
31
+ height: number;
32
+ }
33
+ declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
21
34
  interface ProfilePickerProps<P extends Profile> {
22
35
  idPrefix: string;
23
36
  host: PopoverHost;
@@ -27,15 +40,17 @@ interface ProfilePickerProps<P extends Profile> {
27
40
  color: string;
28
41
  dark: boolean;
29
42
  align?: 'left' | 'right' | 'center';
43
+ alignOverride?: ProfilePickerAlignResult;
44
+ rotation?: PopoverRotation;
30
45
  guestLabel: string;
31
46
  nullLabel?: string;
32
47
  nullIcon?: string;
33
48
  onSelect: (profile: P | null) => void;
34
49
  onManage?: () => void;
35
50
  }
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;
51
+ declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
52
 
38
- type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
53
+ type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
54
  interface ProfileEditPatch {
40
55
  name: string;
41
56
  color: string;
@@ -55,11 +70,28 @@ interface ProfilesManagerProps {
55
70
  fg?: string;
56
71
  fgMuted?: string;
57
72
  cardBg?: string;
58
- titleVariant?: MD3TextVariant;
73
+ titleVariant?: MD3TextVariant$1;
59
74
  colorPreview?: (hex: string) => string;
60
75
  }
61
76
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
62
77
 
78
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
79
+ interface ProfilesScreenProps {
80
+ profiles: Profile[];
81
+ defaultColor: string;
82
+ onCreate: (patch: ProfileEditPatch) => void;
83
+ onSave: (id: string, patch: ProfileEditPatch) => void;
84
+ onDelete: (id: string) => void;
85
+ onBack: () => void;
86
+ fg?: string;
87
+ fgMuted?: string;
88
+ bg?: string;
89
+ cardBg?: string;
90
+ titleVariant?: MD3TextVariant;
91
+ colorPreview?: (hex: string) => string;
92
+ }
93
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
94
+
63
95
  declare const MAX_PROFILE_NAME_LENGTH = 20;
64
96
  declare const MAX_TAG_LENGTH = 3;
65
97
  declare function isValidTag(value: unknown): value is string;
@@ -93,6 +125,24 @@ interface ProfileExtensionSlice<TExtension> {
93
125
  }
94
126
  declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
127
 
128
+ interface SelectProfilePayload<TSeat> {
129
+ profileId: string | null;
130
+ seat: TSeat;
131
+ }
132
+ interface ProfileSelectionActions<TSeat> {
133
+ clearProfile: ActionCreator<string>;
134
+ select: ActionCreator<SelectProfilePayload<TSeat>>;
135
+ }
136
+ type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
137
+ interface ProfileSelectionSlice<TSeat extends string | number> {
138
+ actions: ProfileSelectionActions<TSeat>;
139
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
140
+ type: string;
141
+ payload?: unknown;
142
+ }) => ProfileSelectionState<TSeat>;
143
+ }
144
+ declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
145
+
96
146
  interface UpdateProfilePayload {
97
147
  id: string;
98
148
  patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
@@ -115,6 +165,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
165
 
116
166
  declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
167
 
168
+ interface SetLastGuestColorPayload<TSeat> {
169
+ color: string;
170
+ seat: TSeat;
171
+ }
172
+ interface SetProfileOverridePayload<TSeat> {
173
+ color: string | null;
174
+ seat: TSeat;
175
+ }
176
+ interface SeatColorsActions<TSeat> {
177
+ setLastCpuColor: ActionCreator<string>;
178
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
179
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
180
+ }
181
+ type SeatColorsState<TSeat extends string | number> = {
182
+ lastCpuColor: string;
183
+ lastGuestColor: Record<TSeat, string>;
184
+ profileOverride: Record<TSeat, string | null>;
185
+ };
186
+ interface SeatColorsSlice<TSeat extends string | number> {
187
+ actions: SeatColorsActions<TSeat>;
188
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: {
189
+ type: string;
190
+ payload?: unknown;
191
+ }) => SeatColorsState<TSeat>;
192
+ }
193
+ declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
194
+
118
195
  interface UseSharedProfilesSyncOptions {
119
196
  groupId: string;
120
197
  onRemoteChange: (profiles: Profile[]) => void;
@@ -128,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
128
205
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
129
206
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
130
207
 
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 };
208
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, 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, useSharedProfilesSync };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as react from 'react';
2
- import { ReactNode, Ref } from 'react';
3
- import { PopoverHost } from '@tastic/hud';
2
+ import { RefObject, ReactNode, Ref } from 'react';
3
+ import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
+ import { View } from 'react-native';
4
5
 
5
6
  interface Profile {
6
7
  id: string;
@@ -18,6 +19,18 @@ interface Props {
18
19
  }
19
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
20
21
 
22
+ interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center';
24
+ verticalAlign: 'below' | 'above';
25
+ maxHeight: number;
26
+ measured: boolean;
27
+ triggerRef: RefObject<View | null>;
28
+ }
29
+ interface ProfilePickerContentSize {
30
+ width: number;
31
+ height: number;
32
+ }
33
+ declare function getProfilePickerContentSize(profileCount: number, hasManageRow?: boolean): ProfilePickerContentSize;
21
34
  interface ProfilePickerProps<P extends Profile> {
22
35
  idPrefix: string;
23
36
  host: PopoverHost;
@@ -27,15 +40,17 @@ interface ProfilePickerProps<P extends Profile> {
27
40
  color: string;
28
41
  dark: boolean;
29
42
  align?: 'left' | 'right' | 'center';
43
+ alignOverride?: ProfilePickerAlignResult;
44
+ rotation?: PopoverRotation;
30
45
  guestLabel: string;
31
46
  nullLabel?: string;
32
47
  nullIcon?: string;
33
48
  onSelect: (profile: P | null) => void;
34
49
  onManage?: () => void;
35
50
  }
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;
51
+ declare function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation, guestLabel, nullLabel, nullIcon, onSelect, onManage }: ProfilePickerProps<P>): react.JSX.Element;
37
52
 
38
- type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
53
+ type MD3TextVariant$1 = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
39
54
  interface ProfileEditPatch {
40
55
  name: string;
41
56
  color: string;
@@ -55,11 +70,28 @@ interface ProfilesManagerProps {
55
70
  fg?: string;
56
71
  fgMuted?: string;
57
72
  cardBg?: string;
58
- titleVariant?: MD3TextVariant;
73
+ titleVariant?: MD3TextVariant$1;
59
74
  colorPreview?: (hex: string) => string;
60
75
  }
61
76
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
62
77
 
78
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
79
+ interface ProfilesScreenProps {
80
+ profiles: Profile[];
81
+ defaultColor: string;
82
+ onCreate: (patch: ProfileEditPatch) => void;
83
+ onSave: (id: string, patch: ProfileEditPatch) => void;
84
+ onDelete: (id: string) => void;
85
+ onBack: () => void;
86
+ fg?: string;
87
+ fgMuted?: string;
88
+ bg?: string;
89
+ cardBg?: string;
90
+ titleVariant?: MD3TextVariant;
91
+ colorPreview?: (hex: string) => string;
92
+ }
93
+ declare function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }: ProfilesScreenProps): react.JSX.Element;
94
+
63
95
  declare const MAX_PROFILE_NAME_LENGTH = 20;
64
96
  declare const MAX_TAG_LENGTH = 3;
65
97
  declare function isValidTag(value: unknown): value is string;
@@ -93,6 +125,24 @@ interface ProfileExtensionSlice<TExtension> {
93
125
  }
94
126
  declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
127
 
128
+ interface SelectProfilePayload<TSeat> {
129
+ profileId: string | null;
130
+ seat: TSeat;
131
+ }
132
+ interface ProfileSelectionActions<TSeat> {
133
+ clearProfile: ActionCreator<string>;
134
+ select: ActionCreator<SelectProfilePayload<TSeat>>;
135
+ }
136
+ type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
137
+ interface ProfileSelectionSlice<TSeat extends string | number> {
138
+ actions: ProfileSelectionActions<TSeat>;
139
+ reducer: (state: ProfileSelectionState<TSeat> | undefined, action: {
140
+ type: string;
141
+ payload?: unknown;
142
+ }) => ProfileSelectionState<TSeat>;
143
+ }
144
+ declare function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey?: string): ProfileSelectionSlice<TSeat>;
145
+
96
146
  interface UpdateProfilePayload {
97
147
  id: string;
98
148
  patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
@@ -115,6 +165,33 @@ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
165
 
116
166
  declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
167
 
168
+ interface SetLastGuestColorPayload<TSeat> {
169
+ color: string;
170
+ seat: TSeat;
171
+ }
172
+ interface SetProfileOverridePayload<TSeat> {
173
+ color: string | null;
174
+ seat: TSeat;
175
+ }
176
+ interface SeatColorsActions<TSeat> {
177
+ setLastCpuColor: ActionCreator<string>;
178
+ setLastGuestColor: ActionCreator<SetLastGuestColorPayload<TSeat>>;
179
+ setProfileOverride: ActionCreator<SetProfileOverridePayload<TSeat>>;
180
+ }
181
+ type SeatColorsState<TSeat extends string | number> = {
182
+ lastCpuColor: string;
183
+ lastGuestColor: Record<TSeat, string>;
184
+ profileOverride: Record<TSeat, string | null>;
185
+ };
186
+ interface SeatColorsSlice<TSeat extends string | number> {
187
+ actions: SeatColorsActions<TSeat>;
188
+ reducer: (state: SeatColorsState<TSeat> | undefined, action: {
189
+ type: string;
190
+ payload?: unknown;
191
+ }) => SeatColorsState<TSeat>;
192
+ }
193
+ declare function createSeatColorsSlice<TSeat extends string | number>(namespace: string, defaultState: SeatColorsState<TSeat>, mountKey?: string): SeatColorsSlice<TSeat>;
194
+
118
195
  interface UseSharedProfilesSyncOptions {
119
196
  groupId: string;
120
197
  onRemoteChange: (profiles: Profile[]) => void;
@@ -128,4 +205,4 @@ declare const isSharedProfileStoreAvailable: boolean;
128
205
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
129
206
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
130
207
 
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 };
208
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, 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, useSharedProfilesSync };
package/dist/index.js CHANGED
@@ -25,8 +25,12 @@ __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,
33
+ getProfilePickerContentSize: () => getProfilePickerContentSize,
30
34
  isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
31
35
  isValidProfile: () => isValidProfile,
32
36
  isValidTag: () => isValidTag,
@@ -73,17 +77,22 @@ var TRIGGER_HEIGHT = 28;
73
77
  var POPOVER_WIDTH = 220;
74
78
  var ROW_HEIGHT = 36;
75
79
  var LIST_PADDING = 8;
76
- function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
80
+ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
81
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
82
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
83
+ }
84
+ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
77
85
  const id = `${idPrefix}-profile`;
78
86
  const menuBg = dark ? "#000000" : "#FFFFFF";
79
87
  const fg = dark ? "#FFFFFF" : "#000000";
80
88
  const open = host.openId === id;
81
89
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
82
90
  const sortedProfiles = (0, import_react.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
83
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0);
84
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT;
85
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = (0, import_hud.useAutoAlign)(open, POPOVER_WIDTH, contentHeight);
86
- const align = alignOverride ?? autoAlign;
91
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
92
+ const auto = (0, import_hud.useAutoAlign)(open, contentWidth, contentHeight, rotation);
93
+ const placement = alignOverride ?? auto;
94
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement;
95
+ const align = forcedAlign ?? placement.align;
87
96
  const handleSelectGuest = () => {
88
97
  onSelect(null);
89
98
  host.close();
@@ -514,6 +523,29 @@ var styles3 = import_react_native3.StyleSheet.create({
514
523
  }
515
524
  });
516
525
 
526
+ // src/ProfilesScreen.tsx
527
+ var import_auto_paper4 = require("@rific/auto-paper");
528
+ var import_feedback_press3 = require("@rific/feedback-press");
529
+ var import_react3 = require("react");
530
+ var import_react_native4 = require("react-native");
531
+ var import_jsx_runtime4 = require("react/jsx-runtime");
532
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
533
+ const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
534
+ const managerRef = (0, import_react3.useRef)(null);
535
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
536
+ const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
537
+ const handleBack = () => {
538
+ managerRef.current?.commitPendingEdit();
539
+ onBack();
540
+ };
541
+ 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" }) }) });
542
+ }
543
+ var styles4 = import_react_native4.StyleSheet.create({
544
+ container: {
545
+ flex: 1
546
+ }
547
+ });
548
+
517
549
  // src/redux/createActionCreator.ts
518
550
  function createActionCreator(type) {
519
551
  const creator = ((payload) => ({ payload, type }));
@@ -539,6 +571,29 @@ function createProfileExtensionSlice(namespace) {
539
571
  return { actions: { remove: remove2, set }, reducer };
540
572
  }
541
573
 
574
+ // src/redux/profileSelectionSlice.ts
575
+ var REHYDRATE = "persist/REHYDRATE";
576
+ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
577
+ const select = createActionCreator(`profileSelection/${namespace}/select`);
578
+ const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
579
+ function reducer(state = defaultState, action) {
580
+ if (action.type === REHYDRATE) {
581
+ const persisted = action.payload?.[mountKey];
582
+ return { ...defaultState, ...state, ...persisted };
583
+ }
584
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
585
+ if (clearProfile.match(action)) {
586
+ const next = { ...state };
587
+ for (const seat of Object.keys(state)) {
588
+ if (next[seat] === action.payload) next[seat] = null;
589
+ }
590
+ return next;
591
+ }
592
+ return state;
593
+ }
594
+ return { actions: { clearProfile, select }, reducer };
595
+ }
596
+
542
597
  // src/redux/profilesSlice.ts
543
598
  var setAll = createActionCreator("profiles/setAll");
544
599
  var add = createActionCreator("profiles/add");
@@ -609,19 +664,38 @@ async function resolveInitialProfiles(groupId, localFallback) {
609
664
  return shared;
610
665
  }
611
666
 
667
+ // src/redux/seatColorsSlice.ts
668
+ var REHYDRATE2 = "persist/REHYDRATE";
669
+ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
670
+ const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
671
+ const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
672
+ const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
673
+ function reducer(state = defaultState, action) {
674
+ if (action.type === REHYDRATE2) {
675
+ const persisted = action.payload?.[mountKey];
676
+ return { ...defaultState, ...state, ...persisted };
677
+ }
678
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
679
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
680
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
681
+ return state;
682
+ }
683
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
684
+ }
685
+
612
686
  // src/redux/useSharedProfilesSync.ts
613
- var import_react3 = require("react");
614
- var import_react_native4 = require("react-native");
687
+ var import_react4 = require("react");
688
+ var import_react_native5 = require("react-native");
615
689
  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)(() => {
690
+ const pendingWriteRef = (0, import_react4.useRef)(false);
691
+ const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
692
+ (0, import_react4.useEffect)(() => {
619
693
  onRemoteChangeRef.current = onRemoteChange;
620
694
  }, [onRemoteChange]);
621
- (0, import_react3.useEffect)(() => {
695
+ (0, import_react4.useEffect)(() => {
622
696
  if (!isSharedProfileStoreAvailable) return;
623
697
  let cancelled = false;
624
- const sub = import_react_native4.AppState.addEventListener("change", (next) => {
698
+ const sub = import_react_native5.AppState.addEventListener("change", (next) => {
625
699
  if (next !== "active" || pendingWriteRef.current) return;
626
700
  loadSharedProfiles(groupId).then((shared) => {
627
701
  if (!cancelled) onRemoteChangeRef.current(shared);
@@ -632,7 +706,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
632
706
  sub.remove();
633
707
  };
634
708
  }, [groupId]);
635
- const syncToShared = (0, import_react3.useCallback)(
709
+ const syncToShared = (0, import_react4.useCallback)(
636
710
  (next) => {
637
711
  if (!isSharedProfileStoreAvailable) return;
638
712
  pendingWriteRef.current = true;
@@ -652,8 +726,12 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
652
726
  ProfileChip,
653
727
  ProfilePicker,
654
728
  ProfilesManager,
729
+ ProfilesScreen,
655
730
  createProfileExtensionSlice,
656
731
  createProfileRecord,
732
+ createProfileSelectionSlice,
733
+ createSeatColorsSlice,
734
+ getProfilePickerContentSize,
657
735
  isSharedProfileStoreAvailable,
658
736
  isValidProfile,
659
737
  isValidTag,
package/dist/index.mjs CHANGED
@@ -39,17 +39,22 @@ var TRIGGER_HEIGHT = 28;
39
39
  var POPOVER_WIDTH = 220;
40
40
  var ROW_HEIGHT = 36;
41
41
  var LIST_PADDING = 8;
42
- function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
42
+ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
43
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0);
44
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT };
45
+ }
46
+ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
43
47
  const id = `${idPrefix}-profile`;
44
48
  const menuBg = dark ? "#000000" : "#FFFFFF";
45
49
  const fg = dark ? "#FFFFFF" : "#000000";
46
50
  const open = host.openId === id;
47
51
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
48
52
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
49
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0);
50
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT;
51
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight);
52
- const align = alignOverride ?? autoAlign;
53
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
54
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation);
55
+ const placement = alignOverride ?? auto;
56
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement;
57
+ const align = forcedAlign ?? placement.align;
53
58
  const handleSelectGuest = () => {
54
59
  onSelect(null);
55
60
  host.close();
@@ -480,6 +485,29 @@ var styles3 = StyleSheet3.create({
480
485
  }
481
486
  });
482
487
 
488
+ // src/ProfilesScreen.tsx
489
+ import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
490
+ import { IconButton } from "@rific/feedback-press";
491
+ import { useRef as useRef2 } from "react";
492
+ import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
493
+ import { jsx as jsx4 } from "react/jsx-runtime";
494
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview }) {
495
+ const { dark } = useAutoPaperTheme2();
496
+ const managerRef = useRef2(null);
497
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
498
+ const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
499
+ const handleBack = () => {
500
+ managerRef.current?.commitPendingEdit();
501
+ onBack();
502
+ };
503
+ 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" }) }) });
504
+ }
505
+ var styles4 = StyleSheet4.create({
506
+ container: {
507
+ flex: 1
508
+ }
509
+ });
510
+
483
511
  // src/redux/createActionCreator.ts
484
512
  function createActionCreator(type) {
485
513
  const creator = ((payload) => ({ payload, type }));
@@ -505,6 +533,29 @@ function createProfileExtensionSlice(namespace) {
505
533
  return { actions: { remove: remove2, set }, reducer };
506
534
  }
507
535
 
536
+ // src/redux/profileSelectionSlice.ts
537
+ var REHYDRATE = "persist/REHYDRATE";
538
+ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
539
+ const select = createActionCreator(`profileSelection/${namespace}/select`);
540
+ const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
541
+ function reducer(state = defaultState, action) {
542
+ if (action.type === REHYDRATE) {
543
+ const persisted = action.payload?.[mountKey];
544
+ return { ...defaultState, ...state, ...persisted };
545
+ }
546
+ if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
547
+ if (clearProfile.match(action)) {
548
+ const next = { ...state };
549
+ for (const seat of Object.keys(state)) {
550
+ if (next[seat] === action.payload) next[seat] = null;
551
+ }
552
+ return next;
553
+ }
554
+ return state;
555
+ }
556
+ return { actions: { clearProfile, select }, reducer };
557
+ }
558
+
508
559
  // src/redux/profilesSlice.ts
509
560
  var setAll = createActionCreator("profiles/setAll");
510
561
  var add = createActionCreator("profiles/add");
@@ -575,12 +626,31 @@ async function resolveInitialProfiles(groupId, localFallback) {
575
626
  return shared;
576
627
  }
577
628
 
629
+ // src/redux/seatColorsSlice.ts
630
+ var REHYDRATE2 = "persist/REHYDRATE";
631
+ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors") {
632
+ const setLastCpuColor = createActionCreator(`seatColors/${namespace}/setLastCpuColor`);
633
+ const setLastGuestColor = createActionCreator(`seatColors/${namespace}/setLastGuestColor`);
634
+ const setProfileOverride = createActionCreator(`seatColors/${namespace}/setProfileOverride`);
635
+ function reducer(state = defaultState, action) {
636
+ if (action.type === REHYDRATE2) {
637
+ const persisted = action.payload?.[mountKey];
638
+ return { ...defaultState, ...state, ...persisted };
639
+ }
640
+ if (setLastCpuColor.match(action)) return { ...state, lastCpuColor: action.payload };
641
+ if (setLastGuestColor.match(action)) return { ...state, lastGuestColor: { ...state.lastGuestColor, [action.payload.seat]: action.payload.color } };
642
+ if (setProfileOverride.match(action)) return { ...state, profileOverride: { ...state.profileOverride, [action.payload.seat]: action.payload.color } };
643
+ return state;
644
+ }
645
+ return { actions: { setLastCpuColor, setLastGuestColor, setProfileOverride }, reducer };
646
+ }
647
+
578
648
  // src/redux/useSharedProfilesSync.ts
579
- import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
649
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef3 } from "react";
580
650
  import { AppState } from "react-native";
581
651
  function useSharedProfilesSync({ groupId, onRemoteChange }) {
582
- const pendingWriteRef = useRef2(false);
583
- const onRemoteChangeRef = useRef2(onRemoteChange);
652
+ const pendingWriteRef = useRef3(false);
653
+ const onRemoteChangeRef = useRef3(onRemoteChange);
584
654
  useEffect2(() => {
585
655
  onRemoteChangeRef.current = onRemoteChange;
586
656
  }, [onRemoteChange]);
@@ -617,8 +687,12 @@ export {
617
687
  ProfileChip,
618
688
  ProfilePicker,
619
689
  ProfilesManager,
690
+ ProfilesScreen,
620
691
  createProfileExtensionSlice,
621
692
  createProfileRecord,
693
+ createProfileSelectionSlice,
694
+ createSeatColorsSlice,
695
+ getProfilePickerContentSize,
622
696
  isSharedProfileStoreAvailable,
623
697
  isValidProfile,
624
698
  isValidTag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.4.0",
3
+ "version": "0.8.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,15 +63,15 @@
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
- "@rific/toaster": "^0.6.3",
73
- "@tastic/core": "^0.2.0",
74
- "@tastic/hud": "^0.8.5",
72
+ "@rific/toaster": "^0.7.0",
73
+ "@tastic/core": "^0.8.0",
74
+ "@tastic/hud": "^0.12.0",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
77
77
  "@types/jest": "^30.0.0",
@@ -97,7 +97,7 @@
97
97
  "@rific/focus-chain": ">=0.4.0",
98
98
  "@rific/toaster": ">=0.4.0",
99
99
  "@tastic/core": ">=0.1.0",
100
- "@tastic/hud": ">=0.1.3",
100
+ "@tastic/hud": ">=0.8.0",
101
101
  "expo-modules-core": ">=1.0.0",
102
102
  "react": ">=19.0.0",
103
103
  "react-native": ">=0.76.0",
@@ -1,7 +1,7 @@
1
1
  import { getContrastColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import { PopoverBody, PopoverHost, useAutoAlign } from '@tastic/hud'
4
- import { useMemo } from 'react'
3
+ import { PopoverBody, PopoverHost, PopoverRotation, useAutoAlign } from '@tastic/hud'
4
+ import { RefObject, useMemo } from 'react'
5
5
  import { ScrollView, StyleSheet, View } from 'react-native'
6
6
  import { Icon, Text } from 'react-native-paper'
7
7
 
@@ -13,6 +13,39 @@ const POPOVER_WIDTH = 220
13
13
  const ROW_HEIGHT = 36
14
14
  const LIST_PADDING = 8
15
15
 
16
+ // A complete placement decision for the popover: which way it opens, how tall it may grow, whether
17
+ // this open's measurement has landed yet, and the ref attached to the measured trigger.
18
+ // Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
19
+ // same shape `useAutoAlign` itself returns) — declared here rather than imported so this package's
20
+ // public types don't start requiring a newer @tastic/hud than its peer range already admits. A
21
+ // hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
22
+ export interface ProfilePickerAlignResult {
23
+ align: 'left' | 'right' | 'center'
24
+ verticalAlign: 'below' | 'above'
25
+ maxHeight: number
26
+ measured: boolean
27
+ triggerRef: RefObject<View | null>
28
+ }
29
+
30
+ export interface ProfilePickerContentSize {
31
+ width: number
32
+ height: number
33
+ }
34
+
35
+ // The popover's content footprint for a given roster size: always POPOVER_WIDTH wide, tall enough
36
+ // for the null/"guest" row + one row per profile + (when `hasManageRow`, i.e. when the picker is
37
+ // given an `onManage`) the trailing Manage row, plus the list's own padding. This is the size
38
+ // ProfilePicker hands to `useAutoAlign` — the ONE formula, used by the component itself (it leaves
39
+ // out the menu's 2pt border and the Manage divider, ~13pt in all, so treat it as a placement
40
+ // estimate, not a pixel-exact measurement) — exported
41
+ // so a host supplying its own placement through `alignOverride` can size that placement hook (its
42
+ // own useAutoAlign/useZoneClampedAlign call) to match the popover it is actually positioning,
43
+ // instead of copying these constants and drifting whenever they change here.
44
+ export function getProfilePickerContentSize(profileCount: number, hasManageRow = false): ProfilePickerContentSize {
45
+ const rowCount = profileCount + 1 + (hasManageRow ? 1 : 0)
46
+ return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + rowCount * ROW_HEIGHT }
47
+ }
48
+
16
49
  interface ProfilePickerProps<P extends Profile> {
17
50
  // Namespaces this trigger's id — pass something unique per instance whenever more than one of
18
51
  // these shares a PopoverHost (e.g. one per seat).
@@ -31,7 +64,30 @@ interface ProfilePickerProps<P extends Profile> {
31
64
  // trigger reads as one identity with whatever it's paired with.
32
65
  color: string
33
66
  dark: boolean
67
+ // Forces a specific horizontal alignment, beating whatever the placement (the automatic
68
+ // measurement, or `alignOverride` below) would have picked. Omit to let placement decide.
34
69
  align?: 'left' | 'right' | 'center'
70
+ // Substitutes the popover's whole placement — horizontal/vertical alignment, max height, the
71
+ // `measured` gate and the trigger ref — for the automatic `useAutoAlign` one, for a host whose
72
+ // trigger sits somewhere that hook's plain window-relative measurement gets wrong. The motivating
73
+ // case is a FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked):
74
+ // `measureInWindow` reports a rect in true screen space, `useAutoAlign` compares it against the
75
+ // raw portrait window, and so measures along the wrong axes — flipping the list upward off-screen
76
+ // or running it past the frame's bottom. The host runs its own rotation-aware placement hook
77
+ // (sized with `getProfilePickerContentSize`, passing `open` from its own `host.openId === ...`
78
+ // check) and hands the result in here; `align` above still wins over this result's own `align`.
79
+ // The result's `triggerRef` is the one attached to the measured trigger, so the hook that
80
+ // produced it is measuring the right element. Omit for the default automatic placement.
81
+ alignOverride?: ProfilePickerAlignResult
82
+ // Rotation of an ancestor frame turned by a fake landscape (or equivalent), forwarded to the
83
+ // automatic `useAutoAlign` measurement — the same seam @tastic/hud's InlineColorPicker/
84
+ // SectionedDropdown expose (needs @tastic/hud >= 0.8.0; older versions ignore it). Defaults to 0
85
+ // (measurement used as-is), which is exactly how this picker behaved before this prop existed.
86
+ // Deliberately NOT read from an ambient provider the way hud's own pickers do: this component
87
+ // never touches @tastic/core at runtime, and an ambient read would silently change placement for
88
+ // every existing caller already living under a rotation provider. Ignored when `alignOverride` is
89
+ // supplied (the override replaces the placement this feeds).
90
+ rotation?: PopoverRotation
35
91
  // Trigger's own idle-state text (uppercased here regardless of casing passed in) — shown until a
36
92
  // profile is selected.
37
93
  guestLabel: string
@@ -63,7 +119,7 @@ interface ProfilePickerProps<P extends Profile> {
63
119
  // it keeps this popover from needing its own destructive-delete confirmation. Fully prop-driven —
64
120
  // no internal profile-storage access of its own, so the host app stays the one place that reads
65
121
  // its own persistence hook.
66
- export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: alignOverride, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
122
+ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = 'Player', nullIcon = 'account-off-outline', onSelect, onManage }: ProfilePickerProps<P>) {
67
123
  const id = `${idPrefix}-profile`
68
124
  const menuBg = dark ? '#000000' : '#FFFFFF'
69
125
  const fg = dark ? '#FFFFFF' : '#000000'
@@ -74,10 +130,14 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
74
130
  // most from staying scannable once there are more than a handful of saved profiles.
75
131
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
76
132
  // Null row + each saved profile + (if passed) the manage row.
77
- const rowCount = profiles.length + 1 + (onManage ? 1 : 0)
78
- const contentHeight = LIST_PADDING * 2 + rowCount * ROW_HEIGHT
79
- const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, POPOVER_WIDTH, contentHeight)
80
- const align = alignOverride ?? autoAlign
133
+ const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage)
134
+ // Always called, even when `alignOverride` is supplied and this result goes unused — hooks can't
135
+ // be conditional (see @tastic/hud's InlineColorPicker for the identical pattern). With an
136
+ // override its triggerRef is never attached, so this hook's own measurement is a no-op.
137
+ const auto = useAutoAlign(open, contentWidth, contentHeight, rotation)
138
+ const placement = alignOverride ?? auto
139
+ const { maxHeight, measured, triggerRef, verticalAlign } = placement
140
+ const align = forcedAlign ?? placement.align
81
141
 
82
142
  const handleSelectGuest = () => {
83
143
  onSelect(null)
@@ -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
- export { ProfilePicker } from './ProfilePicker'
3
- export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
2
+ export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize } from './ProfilePicker'
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
+ }