@tastic/profile 0.3.7 → 0.4.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
@@ -67,7 +67,8 @@ see its own doc comment for why that component is generic over your own `Profile
67
67
  TypeScript type.
68
68
 
69
69
  ```ts
70
- // App-side sketch — see BoxHockey's or LightCycles' own useProfiles.tsx for a full example.
70
+ // Conceptual sketch of the pattern — see "Redux helpers for the shared roster" below for a
71
+ // ready-made version of it.
71
72
  const GROUP_ID = 'group.com.yourteam.yourgames'
72
73
  const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
73
74
  const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
@@ -87,9 +88,42 @@ see its own doc comment for why that component is generic over your own `Profile
87
88
  { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
88
89
  ```
89
90
 
90
- ## Install (local dev via yalc)
91
+ ### Redux helpers for the shared roster
92
+
93
+ The cross-app-roster sketch above is a pattern every `@tastic` game was hand-rolling on its own
94
+ (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:
98
+
99
+ - **`profilesActions` / `profilesReducer` / `createProfileRecord`** — the base roster as a reducer:
100
+ `add`/`update`/`remove`/`setAll` actions, plus `createProfileRecord(input)` to mint a complete
101
+ `Profile` (id + timestamps) ready to dispatch via `profilesActions.add`.
102
+ - **`createProfileExtensionSlice<TExtension>(namespace)`** — a factory for a host app's own
103
+ per-profile extension fields (a control scheme, a key scheme, ...): returns a
104
+ `{ actions: { set, remove }, reducer }` pair over a plain `Record<profileId, TExtension>`,
105
+ namespaced so more than one extension slice in the same app never collides. Not auto-cleared when
106
+ `profilesReducer` removes a profile — an app already dispatches its own cleanup action(s) on
107
+ delete, so this stays a self-contained reducer rather than new cross-slice wiring.
108
+ - **`resolveInitialProfiles(groupId, localFallback)`** — the one-time initial-load decision: shared
109
+ store unavailable → `localFallback` verbatim; shared store empty → seed it from `localFallback`;
110
+ otherwise the shared roster always wins over a possibly-stale local snapshot.
111
+ - **`useSharedProfilesSync({ groupId, onRemoteChange })`** — keeps a Redux-backed roster in sync for
112
+ the rest of the app's lifetime: call the returned `syncToShared(next)` right after a local roster
113
+ change to mirror it out, and `onRemoteChange` fires with a freshly-loaded roster whenever the app
114
+ returns to the foreground (skipped while a `syncToShared` write is still in flight, so a remote
115
+ refresh can't clobber a write that hasn't landed yet).
116
+
117
+ ## Install
91
118
 
92
- Not published to the public npm registry yet.
119
+ ```bash
120
+ npm install @tastic/profile
121
+ ```
122
+
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.
125
+
126
+ To develop against a local change before it's published, use `yalc` instead:
93
127
 
94
128
  ```bash
95
129
  cd react-native-game-profile
@@ -109,5 +143,10 @@ linked consumer at once.
109
143
  `react`, `react-native`, `react-native-paper`, `react-native-safe-area-context`, `@rific/auto-paper`
110
144
  (`getContrastColor`, `useAutoPaperTheme`), `@rific/feedback-press` (`TouchableRipple`),
111
145
  `@rific/focus-chain` (`useFocusChain`), `@rific/toaster` (`useToast`), `@tastic/hud`
112
- (`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`) — none of these
113
- are bundled, so use whatever versions your app already has.
146
+ (`InlineColorPicker`, `PopoverBody`, `PopoverHost`, `useAutoAlign`, `usePopoverHost`), `@tastic/core`
147
+ (a peer of `@tastic/hud` itself) — none of these are bundled, so use whatever versions your app
148
+ already has.
149
+
150
+ `expo-modules-core` is an *optional* peer — only resolved by `loadSharedProfiles`/`saveSharedProfiles`
151
+ for the native App Group bridge described above; safe to omit entirely on an Android/web-only
152
+ consumer.
package/dist/index.d.mts CHANGED
@@ -35,6 +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
39
  interface ProfileEditPatch {
39
40
  name: string;
40
41
  color: string;
@@ -51,16 +52,80 @@ interface ProfilesManagerProps {
51
52
  onDelete: (id: string) => void;
52
53
  headerLeft?: ReactNode;
53
54
  ref?: Ref<ProfilesManagerHandle>;
55
+ fg?: string;
56
+ fgMuted?: string;
57
+ cardBg?: string;
58
+ titleVariant?: MD3TextVariant;
59
+ colorPreview?: (hex: string) => string;
54
60
  }
55
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
61
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
56
62
 
57
63
  declare const MAX_PROFILE_NAME_LENGTH = 20;
58
64
  declare const MAX_TAG_LENGTH = 3;
59
65
  declare function isValidTag(value: unknown): value is string;
60
66
  declare function isValidProfile(value: unknown): value is Profile;
61
67
 
68
+ interface ReduxAction<P> {
69
+ payload: P;
70
+ type: string;
71
+ }
72
+ type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
73
+ type: string;
74
+ match: (action: {
75
+ type: string;
76
+ }) => action is ReduxAction<P>;
77
+ };
78
+
79
+ interface SetProfileExtensionPayload<TExtension> {
80
+ extension: TExtension;
81
+ id: string;
82
+ }
83
+ interface ProfileExtensionActions<TExtension> {
84
+ remove: ActionCreator<string>;
85
+ set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
86
+ }
87
+ type ProfileExtensionState<TExtension> = Record<string, TExtension>;
88
+ interface ProfileExtensionSlice<TExtension> {
89
+ actions: ProfileExtensionActions<TExtension>;
90
+ reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
91
+ type: string;
92
+ }) => ProfileExtensionState<TExtension>;
93
+ }
94
+ declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
+
96
+ interface UpdateProfilePayload {
97
+ id: string;
98
+ patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
99
+ }
100
+ declare const profilesActions: {
101
+ add: ActionCreator<Profile>;
102
+ remove: ActionCreator<string>;
103
+ setAll: ActionCreator<Profile[]>;
104
+ update: ActionCreator<UpdateProfilePayload>;
105
+ };
106
+ declare function profilesReducer(state: Profile[] | undefined, action: {
107
+ type: string;
108
+ }): Profile[];
109
+ interface CreateProfileInput {
110
+ name: string;
111
+ color: string;
112
+ tag: string;
113
+ }
114
+ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
+
116
+ declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
+
118
+ interface UseSharedProfilesSyncOptions {
119
+ groupId: string;
120
+ onRemoteChange: (profiles: Profile[]) => void;
121
+ }
122
+ interface UseSharedProfilesSyncResult {
123
+ syncToShared: (next: Profile[]) => void;
124
+ }
125
+ declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
126
+
62
127
  declare const isSharedProfileStoreAvailable: boolean;
63
128
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
64
129
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
65
130
 
66
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
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 };
package/dist/index.d.ts CHANGED
@@ -35,6 +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
39
  interface ProfileEditPatch {
39
40
  name: string;
40
41
  color: string;
@@ -51,16 +52,80 @@ interface ProfilesManagerProps {
51
52
  onDelete: (id: string) => void;
52
53
  headerLeft?: ReactNode;
53
54
  ref?: Ref<ProfilesManagerHandle>;
55
+ fg?: string;
56
+ fgMuted?: string;
57
+ cardBg?: string;
58
+ titleVariant?: MD3TextVariant;
59
+ colorPreview?: (hex: string) => string;
54
60
  }
55
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps): react.JSX.Element;
61
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview }: ProfilesManagerProps): react.JSX.Element;
56
62
 
57
63
  declare const MAX_PROFILE_NAME_LENGTH = 20;
58
64
  declare const MAX_TAG_LENGTH = 3;
59
65
  declare function isValidTag(value: unknown): value is string;
60
66
  declare function isValidProfile(value: unknown): value is Profile;
61
67
 
68
+ interface ReduxAction<P> {
69
+ payload: P;
70
+ type: string;
71
+ }
72
+ type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
73
+ type: string;
74
+ match: (action: {
75
+ type: string;
76
+ }) => action is ReduxAction<P>;
77
+ };
78
+
79
+ interface SetProfileExtensionPayload<TExtension> {
80
+ extension: TExtension;
81
+ id: string;
82
+ }
83
+ interface ProfileExtensionActions<TExtension> {
84
+ remove: ActionCreator<string>;
85
+ set: ActionCreator<SetProfileExtensionPayload<TExtension>>;
86
+ }
87
+ type ProfileExtensionState<TExtension> = Record<string, TExtension>;
88
+ interface ProfileExtensionSlice<TExtension> {
89
+ actions: ProfileExtensionActions<TExtension>;
90
+ reducer: (state: ProfileExtensionState<TExtension> | undefined, action: {
91
+ type: string;
92
+ }) => ProfileExtensionState<TExtension>;
93
+ }
94
+ declare function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension>;
95
+
96
+ interface UpdateProfilePayload {
97
+ id: string;
98
+ patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>;
99
+ }
100
+ declare const profilesActions: {
101
+ add: ActionCreator<Profile>;
102
+ remove: ActionCreator<string>;
103
+ setAll: ActionCreator<Profile[]>;
104
+ update: ActionCreator<UpdateProfilePayload>;
105
+ };
106
+ declare function profilesReducer(state: Profile[] | undefined, action: {
107
+ type: string;
108
+ }): Profile[];
109
+ interface CreateProfileInput {
110
+ name: string;
111
+ color: string;
112
+ tag: string;
113
+ }
114
+ declare function createProfileRecord(input: CreateProfileInput): Profile;
115
+
116
+ declare function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]>;
117
+
118
+ interface UseSharedProfilesSyncOptions {
119
+ groupId: string;
120
+ onRemoteChange: (profiles: Profile[]) => void;
121
+ }
122
+ interface UseSharedProfilesSyncResult {
123
+ syncToShared: (next: Profile[]) => void;
124
+ }
125
+ declare function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult;
126
+
62
127
  declare const isSharedProfileStoreAvailable: boolean;
63
128
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
64
129
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
65
130
 
66
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
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 };
package/dist/index.js CHANGED
@@ -25,11 +25,17 @@ __export(index_exports, {
25
25
  ProfileChip: () => ProfileChip,
26
26
  ProfilePicker: () => ProfilePicker,
27
27
  ProfilesManager: () => ProfilesManager,
28
+ createProfileExtensionSlice: () => createProfileExtensionSlice,
29
+ createProfileRecord: () => createProfileRecord,
28
30
  isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
29
31
  isValidProfile: () => isValidProfile,
30
32
  isValidTag: () => isValidTag,
31
33
  loadSharedProfiles: () => loadSharedProfiles,
32
- saveSharedProfiles: () => saveSharedProfiles
34
+ profilesActions: () => profilesActions,
35
+ profilesReducer: () => profilesReducer,
36
+ resolveInitialProfiles: () => resolveInitialProfiles,
37
+ saveSharedProfiles: () => saveSharedProfiles,
38
+ useSharedProfilesSync: () => useSharedProfilesSync
33
39
  });
34
40
  module.exports = __toCommonJS(index_exports);
35
41
 
@@ -287,14 +293,14 @@ function deriveTag(name) {
287
293
  const trimmed = name.trim();
288
294
  return trimmed ? trimmed[0].toUpperCase() : "";
289
295
  }
290
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
296
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
291
297
  const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
292
298
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
293
299
  const { error: showErrorToast } = (0, import_toaster.useToast)();
294
300
  const host = (0, import_hud2.usePopoverHost)();
295
- const fg = dark ? "#FFFFFF" : "#000000";
296
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
297
- const cardBg = dark ? "#111111" : "#F2F2F2";
301
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
302
+ const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
303
+ const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
298
304
  const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
299
305
  const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
300
306
  const cancelFg = dark ? "#FFFFFF" : "#000000";
@@ -363,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
363
369
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
364
370
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
365
371
  headerLeft,
366
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "displaySmall", style: [styles3.title, { color: fg }], children: "Profiles" })
372
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
367
373
  ] }),
368
374
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.KeyboardAvoidingView, { behavior: import_react_native3.Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.ScrollView, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
369
375
  if (row.kind === "new") {
@@ -373,11 +379,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
373
379
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
374
380
  ] }) }, "new");
375
381
  }
376
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
382
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
377
383
  }
378
384
  const { profile } = row;
379
385
  if (editingId === profile.id) {
380
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
386
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
381
387
  }
382
388
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
383
389
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -397,7 +403,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
397
403
  ] }) }) })
398
404
  ] });
399
405
  }
400
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
406
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
401
407
  const register = (0, import_focus_chain.useFocusChain)();
402
408
  const tag = register();
403
409
  const name = register();
@@ -409,7 +415,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
409
415
  if (host.openId === "color") tagFocusRef.current();
410
416
  }, [host.openId]);
411
417
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
412
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
418
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
413
419
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
414
420
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
415
421
  onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
@@ -432,6 +438,15 @@ var styles3 = import_react_native3.StyleSheet.create({
432
438
  gap: 4,
433
439
  paddingBottom: 8
434
440
  },
441
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
442
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
443
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
444
+ hiddenTagInput: {
445
+ height: 1,
446
+ opacity: 0,
447
+ position: "absolute",
448
+ width: 1
449
+ },
435
450
  iconButton: {
436
451
  alignItems: "center",
437
452
  borderRadius: 8,
@@ -493,21 +508,61 @@ var styles3 = import_react_native3.StyleSheet.create({
493
508
  scrollView: {
494
509
  flex: 1
495
510
  },
496
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
497
- // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
498
- // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
499
- hiddenTagInput: {
500
- height: 1,
501
- opacity: 0,
502
- position: "absolute",
503
- width: 1
504
- },
505
511
  title: {
506
512
  flexShrink: 1,
507
513
  fontWeight: "bold"
508
514
  }
509
515
  });
510
516
 
517
+ // src/redux/createActionCreator.ts
518
+ function createActionCreator(type) {
519
+ const creator = ((payload) => ({ payload, type }));
520
+ creator.type = type;
521
+ creator.match = (action) => action.type === type;
522
+ return creator;
523
+ }
524
+
525
+ // src/redux/profileExtensionSlice.ts
526
+ function createProfileExtensionSlice(namespace) {
527
+ const set = createActionCreator(`profileExtension/${namespace}/set`);
528
+ const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
529
+ function reducer(state = {}, action) {
530
+ if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
531
+ if (remove2.match(action)) {
532
+ if (!(action.payload in state)) return state;
533
+ const next = { ...state };
534
+ delete next[action.payload];
535
+ return next;
536
+ }
537
+ return state;
538
+ }
539
+ return { actions: { remove: remove2, set }, reducer };
540
+ }
541
+
542
+ // src/redux/profilesSlice.ts
543
+ var setAll = createActionCreator("profiles/setAll");
544
+ var add = createActionCreator("profiles/add");
545
+ var update = createActionCreator("profiles/update");
546
+ var remove = createActionCreator("profiles/remove");
547
+ var profilesActions = { add, remove, setAll, update };
548
+ function profilesReducer(state = [], action) {
549
+ if (setAll.match(action)) return action.payload;
550
+ if (add.match(action)) return [...state, action.payload];
551
+ if (update.match(action)) {
552
+ const { id, patch } = action.payload;
553
+ return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
554
+ }
555
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
556
+ return state;
557
+ }
558
+ function generateProfileId() {
559
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
560
+ }
561
+ function createProfileRecord(input) {
562
+ const now = Date.now();
563
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
564
+ }
565
+
511
566
  // src/sharedProfileStore.ts
512
567
  var native = null;
513
568
  try {
@@ -541,6 +596,55 @@ async function saveSharedProfiles(groupId, profiles) {
541
596
  } catch {
542
597
  }
543
598
  }
599
+
600
+ // src/redux/resolveInitialProfiles.ts
601
+ async function resolveInitialProfiles(groupId, localFallback) {
602
+ if (!isSharedProfileStoreAvailable) return localFallback;
603
+ const shared = await loadSharedProfiles(groupId);
604
+ if (shared.length === 0 && localFallback.length > 0) {
605
+ saveSharedProfiles(groupId, localFallback).catch(() => {
606
+ });
607
+ return localFallback;
608
+ }
609
+ return shared;
610
+ }
611
+
612
+ // src/redux/useSharedProfilesSync.ts
613
+ var import_react3 = require("react");
614
+ var import_react_native4 = require("react-native");
615
+ 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)(() => {
619
+ onRemoteChangeRef.current = onRemoteChange;
620
+ }, [onRemoteChange]);
621
+ (0, import_react3.useEffect)(() => {
622
+ if (!isSharedProfileStoreAvailable) return;
623
+ let cancelled = false;
624
+ const sub = import_react_native4.AppState.addEventListener("change", (next) => {
625
+ if (next !== "active" || pendingWriteRef.current) return;
626
+ loadSharedProfiles(groupId).then((shared) => {
627
+ if (!cancelled) onRemoteChangeRef.current(shared);
628
+ });
629
+ });
630
+ return () => {
631
+ cancelled = true;
632
+ sub.remove();
633
+ };
634
+ }, [groupId]);
635
+ const syncToShared = (0, import_react3.useCallback)(
636
+ (next) => {
637
+ if (!isSharedProfileStoreAvailable) return;
638
+ pendingWriteRef.current = true;
639
+ saveSharedProfiles(groupId, next).catch(() => {
640
+ }).finally(() => {
641
+ pendingWriteRef.current = false;
642
+ });
643
+ },
644
+ [groupId]
645
+ );
646
+ return { syncToShared };
647
+ }
544
648
  // Annotate the CommonJS export names for ESM import in node:
545
649
  0 && (module.exports = {
546
650
  MAX_PROFILE_NAME_LENGTH,
@@ -548,9 +652,15 @@ async function saveSharedProfiles(groupId, profiles) {
548
652
  ProfileChip,
549
653
  ProfilePicker,
550
654
  ProfilesManager,
655
+ createProfileExtensionSlice,
656
+ createProfileRecord,
551
657
  isSharedProfileStoreAvailable,
552
658
  isValidProfile,
553
659
  isValidTag,
554
660
  loadSharedProfiles,
555
- saveSharedProfiles
661
+ profilesActions,
662
+ profilesReducer,
663
+ resolveInitialProfiles,
664
+ saveSharedProfiles,
665
+ useSharedProfilesSync
556
666
  });
package/dist/index.mjs CHANGED
@@ -259,14 +259,14 @@ function deriveTag(name) {
259
259
  const trimmed = name.trim();
260
260
  return trimmed ? trimmed[0].toUpperCase() : "";
261
261
  }
262
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }) {
262
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview }) {
263
263
  const { dark, colors } = useAutoPaperTheme();
264
264
  const insets = useSafeAreaInsets();
265
265
  const { error: showErrorToast } = useToast();
266
266
  const host = usePopoverHost();
267
- const fg = dark ? "#FFFFFF" : "#000000";
268
- const fgMuted = dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)";
269
- const cardBg = dark ? "#111111" : "#F2F2F2";
267
+ const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
268
+ const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
269
+ const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
270
270
  const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
271
271
  const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
272
272
  const cancelFg = dark ? "#FFFFFF" : "#000000";
@@ -335,7 +335,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
335
335
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
336
336
  /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
337
337
  headerLeft,
338
- /* @__PURE__ */ jsx3(Text3, { variant: "displaySmall", style: [styles3.title, { color: fg }], children: "Profiles" })
338
+ /* @__PURE__ */ jsx3(Text3, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
339
339
  ] }),
340
340
  /* @__PURE__ */ jsx3(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ jsx3(ScrollView2, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
341
341
  if (row.kind === "new") {
@@ -345,11 +345,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
345
345
  /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
346
346
  ] }) }, "new");
347
347
  }
348
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
348
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview }, "new");
349
349
  }
350
350
  const { profile } = row;
351
351
  if (editingId === profile.id) {
352
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
352
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview }, profile.id);
353
353
  }
354
354
  return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
355
355
  /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -369,7 +369,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
369
369
  ] }) }) })
370
370
  ] });
371
371
  }
372
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
372
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }) {
373
373
  const register = useFocusChain();
374
374
  const tag = register();
375
375
  const name = register();
@@ -381,7 +381,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
381
381
  if (host.openId === "color") tagFocusRef.current();
382
382
  }, [host.openId]);
383
383
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
384
- /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
384
+ /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
385
385
  /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
386
386
  /* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
387
387
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
@@ -404,6 +404,15 @@ var styles3 = StyleSheet3.create({
404
404
  gap: 4,
405
405
  paddingBottom: 8
406
406
  },
407
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
408
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
409
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
410
+ hiddenTagInput: {
411
+ height: 1,
412
+ opacity: 0,
413
+ position: "absolute",
414
+ width: 1
415
+ },
407
416
  iconButton: {
408
417
  alignItems: "center",
409
418
  borderRadius: 8,
@@ -465,21 +474,61 @@ var styles3 = StyleSheet3.create({
465
474
  scrollView: {
466
475
  flex: 1
467
476
  },
468
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
469
- // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
470
- // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
471
- hiddenTagInput: {
472
- height: 1,
473
- opacity: 0,
474
- position: "absolute",
475
- width: 1
476
- },
477
477
  title: {
478
478
  flexShrink: 1,
479
479
  fontWeight: "bold"
480
480
  }
481
481
  });
482
482
 
483
+ // src/redux/createActionCreator.ts
484
+ function createActionCreator(type) {
485
+ const creator = ((payload) => ({ payload, type }));
486
+ creator.type = type;
487
+ creator.match = (action) => action.type === type;
488
+ return creator;
489
+ }
490
+
491
+ // src/redux/profileExtensionSlice.ts
492
+ function createProfileExtensionSlice(namespace) {
493
+ const set = createActionCreator(`profileExtension/${namespace}/set`);
494
+ const remove2 = createActionCreator(`profileExtension/${namespace}/remove`);
495
+ function reducer(state = {}, action) {
496
+ if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension };
497
+ if (remove2.match(action)) {
498
+ if (!(action.payload in state)) return state;
499
+ const next = { ...state };
500
+ delete next[action.payload];
501
+ return next;
502
+ }
503
+ return state;
504
+ }
505
+ return { actions: { remove: remove2, set }, reducer };
506
+ }
507
+
508
+ // src/redux/profilesSlice.ts
509
+ var setAll = createActionCreator("profiles/setAll");
510
+ var add = createActionCreator("profiles/add");
511
+ var update = createActionCreator("profiles/update");
512
+ var remove = createActionCreator("profiles/remove");
513
+ var profilesActions = { add, remove, setAll, update };
514
+ function profilesReducer(state = [], action) {
515
+ if (setAll.match(action)) return action.payload;
516
+ if (add.match(action)) return [...state, action.payload];
517
+ if (update.match(action)) {
518
+ const { id, patch } = action.payload;
519
+ return state.map((p) => p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p);
520
+ }
521
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload);
522
+ return state;
523
+ }
524
+ function generateProfileId() {
525
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
526
+ }
527
+ function createProfileRecord(input) {
528
+ const now = Date.now();
529
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now };
530
+ }
531
+
483
532
  // src/sharedProfileStore.ts
484
533
  var native = null;
485
534
  try {
@@ -513,15 +562,70 @@ async function saveSharedProfiles(groupId, profiles) {
513
562
  } catch {
514
563
  }
515
564
  }
565
+
566
+ // src/redux/resolveInitialProfiles.ts
567
+ async function resolveInitialProfiles(groupId, localFallback) {
568
+ if (!isSharedProfileStoreAvailable) return localFallback;
569
+ const shared = await loadSharedProfiles(groupId);
570
+ if (shared.length === 0 && localFallback.length > 0) {
571
+ saveSharedProfiles(groupId, localFallback).catch(() => {
572
+ });
573
+ return localFallback;
574
+ }
575
+ return shared;
576
+ }
577
+
578
+ // src/redux/useSharedProfilesSync.ts
579
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
580
+ import { AppState } from "react-native";
581
+ function useSharedProfilesSync({ groupId, onRemoteChange }) {
582
+ const pendingWriteRef = useRef2(false);
583
+ const onRemoteChangeRef = useRef2(onRemoteChange);
584
+ useEffect2(() => {
585
+ onRemoteChangeRef.current = onRemoteChange;
586
+ }, [onRemoteChange]);
587
+ useEffect2(() => {
588
+ if (!isSharedProfileStoreAvailable) return;
589
+ let cancelled = false;
590
+ const sub = AppState.addEventListener("change", (next) => {
591
+ if (next !== "active" || pendingWriteRef.current) return;
592
+ loadSharedProfiles(groupId).then((shared) => {
593
+ if (!cancelled) onRemoteChangeRef.current(shared);
594
+ });
595
+ });
596
+ return () => {
597
+ cancelled = true;
598
+ sub.remove();
599
+ };
600
+ }, [groupId]);
601
+ const syncToShared = useCallback2(
602
+ (next) => {
603
+ if (!isSharedProfileStoreAvailable) return;
604
+ pendingWriteRef.current = true;
605
+ saveSharedProfiles(groupId, next).catch(() => {
606
+ }).finally(() => {
607
+ pendingWriteRef.current = false;
608
+ });
609
+ },
610
+ [groupId]
611
+ );
612
+ return { syncToShared };
613
+ }
516
614
  export {
517
615
  MAX_PROFILE_NAME_LENGTH,
518
616
  MAX_TAG_LENGTH,
519
617
  ProfileChip,
520
618
  ProfilePicker,
521
619
  ProfilesManager,
620
+ createProfileExtensionSlice,
621
+ createProfileRecord,
522
622
  isSharedProfileStoreAvailable,
523
623
  isValidProfile,
524
624
  isValidTag,
525
625
  loadSharedProfiles,
526
- saveSharedProfiles
626
+ profilesActions,
627
+ profilesReducer,
628
+ resolveInitialProfiles,
629
+ saveSharedProfiles,
630
+ useSharedProfilesSync
527
631
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.3.7",
3
+ "version": "0.4.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",
@@ -62,15 +62,16 @@
62
62
  },
63
63
  "prettier": "@infinitetoken/eslint-config/prettier",
64
64
  "devDependencies": {
65
- "@infinitetoken/eslint-config": "^0.1.8",
66
- "@infinitetoken/jest-config": "^0.2.1",
67
- "@infinitetoken/tsconfig": "^0.3.0",
68
- "@rific/auto-paper": "^0.10.3",
69
- "@rific/feedback-press": "^0.10.8",
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",
70
71
  "@rific/focus-chain": "^0.4.7",
71
72
  "@rific/toaster": "^0.6.3",
72
- "@tastic/core": "^0.1.5",
73
- "@tastic/hud": "^0.1.6",
73
+ "@tastic/core": "^0.2.0",
74
+ "@tastic/hud": "^0.8.5",
74
75
  "@testing-library/dom": "^10.4.1",
75
76
  "@testing-library/react": "^16.3.2",
76
77
  "@types/jest": "^30.0.0",
@@ -16,6 +16,11 @@ import { Profile } from './types'
16
16
  // size for every circle on this screen, rather than each picking its own.
17
17
  const CHIP_SIZE = 40
18
18
 
19
+ // react-native-paper's own MD3 Text variant names — mirrored locally rather than importing its
20
+ // internal VariantProp/MD3TypescaleKey (not exported from the package's public entry point), same
21
+ // as @tastic/hud's BaseStatsScreen does for its own identical titleVariant prop.
22
+ type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
23
+
19
24
  interface ProfileEditPatch {
20
25
  name: string
21
26
  color: string
@@ -52,6 +57,25 @@ export interface ProfilesManagerProps {
52
57
  // React 19 accepts `ref` as a plain prop on a function component (no forwardRef needed) — see
53
58
  // ProfilesManagerHandle's own doc for why a host may need this.
54
59
  ref?: Ref<ProfilesManagerHandle>
60
+ // Independently optional overrides for this screen's text/card colors — each defaults to the same
61
+ // literal-black/white-by-appearance formula as before when omitted, so every existing caller
62
+ // (none of which pass these) renders identically to before. Same convention as @tastic/hud's
63
+ // BaseStatsScreen fg/cardBg overrides, for a caller with its own app-wide chrome palette instead
64
+ // of that literal black/white convention. cardBorder/cancelBg/cancelFg have no override, matching
65
+ // BaseStatsScreen's identical reasoning for cardBorder: their low-alpha/neutral values already
66
+ // read fine against any cardBg, and (for Cancel specifically) are deliberately neutral rather than
67
+ // theme-derived so a host's own live gameplay/seat color can't land there by coincidence.
68
+ fg?: string
69
+ fgMuted?: string
70
+ cardBg?: string
71
+ // 'headlineSmall' (this screen's own default, below), matching BaseStatsScreen's own settled
72
+ // default and the reasoning behind it — this title sits next to a small headerLeft back-button
73
+ // glyph the same way, and the previous hardcoded 'displaySmall' read as oversized there too.
74
+ titleVariant?: MD3TextVariant
75
+ // Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
76
+ // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
77
+ // color value untouched; only what's rendered changes.
78
+ colorPreview?: (hex: string) => string
55
79
  }
56
80
 
57
81
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
@@ -74,16 +98,16 @@ function deriveTag(name: string): string {
74
98
  // really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
75
99
  // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
76
100
  // instead of relying on the unmount fallback alone.
77
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref }: ProfilesManagerProps) {
101
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview }: ProfilesManagerProps) {
78
102
  const { dark, colors } = useAutoPaperTheme()
79
103
  const insets = useSafeAreaInsets()
80
104
  const { error: showErrorToast } = useToast()
81
105
  // Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
82
106
  // so every EditRow instance can safely share this one host instead of each needing its own.
83
107
  const host = usePopoverHost()
84
- const fg = dark ? '#FFFFFF' : '#000000'
85
- const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
86
- const cardBg = dark ? '#111111' : '#F2F2F2'
108
+ const fg = fgOverride ?? (dark ? '#FFFFFF' : '#000000')
109
+ const fgMuted = fgMutedOverride ?? (dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)')
110
+ const cardBg = cardBgOverride ?? (dark ? '#111111' : '#F2F2F2')
87
111
  const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
88
112
  // A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
89
113
  // danger fill, rather than looking unfinished/secondary beside it.
@@ -198,7 +222,7 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
198
222
  <>
199
223
  <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
200
224
  {headerLeft}
201
- <Text variant='displaySmall' style={[styles.title, { color: fg }]}>
225
+ <Text variant={titleVariant} style={[styles.title, { color: fg }]}>
202
226
  Profiles
203
227
  </Text>
204
228
  </View>
@@ -226,12 +250,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
226
250
  </TouchableRipple>
227
251
  )
228
252
  }
229
- return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
253
+ return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
230
254
  }
231
255
 
232
256
  const { profile } = row
233
257
  if (editingId === profile.id) {
234
- return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
258
+ return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} />
235
259
  }
236
260
 
237
261
  // No delete affordance at rest — tap the row to start editing, which is the one place
@@ -295,6 +319,7 @@ interface EditRowProps {
295
319
  fgMuted: string
296
320
  host: PopoverHost
297
321
  dark: boolean
322
+ colorPreview?: (hex: string) => string
298
323
  }
299
324
 
300
325
  // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
@@ -309,7 +334,7 @@ interface EditRowProps {
309
334
  // own — submitting the *name* field is what actually commits the row, so its registration's own
310
335
  // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
311
336
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
312
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
337
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview }: EditRowProps) {
313
338
  const register = useFocusChain()
314
339
  const tag = register()
315
340
  const name = register()
@@ -336,7 +361,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
336
361
 
337
362
  return (
338
363
  <View style={styles.editRow}>
339
- <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
364
+ <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />
340
365
  {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
341
366
  doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
342
367
  value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
@@ -380,6 +405,15 @@ const styles = StyleSheet.create({
380
405
  gap: 4,
381
406
  paddingBottom: 8
382
407
  },
408
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
409
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
410
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
411
+ hiddenTagInput: {
412
+ height: 1,
413
+ opacity: 0,
414
+ position: 'absolute',
415
+ width: 1
416
+ },
383
417
  iconButton: {
384
418
  alignItems: 'center',
385
419
  borderRadius: 8,
@@ -441,15 +475,6 @@ const styles = StyleSheet.create({
441
475
  scrollView: {
442
476
  flex: 1
443
477
  },
444
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
445
- // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
446
- // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
447
- hiddenTagInput: {
448
- height: 1,
449
- opacity: 0,
450
- position: 'absolute',
451
- width: 1
452
- },
453
478
  title: {
454
479
  flexShrink: 1,
455
480
  fontWeight: 'bold'
package/src/index.ts CHANGED
@@ -2,5 +2,9 @@ export { ProfileChip } from './ProfileChip'
2
2
  export { ProfilePicker } from './ProfilePicker'
3
3
  export { ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
4
4
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
5
+ export { createProfileExtensionSlice, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, type SetProfileExtensionPayload } from './redux/profileExtensionSlice'
6
+ export { type CreateProfileInput, createProfileRecord, profilesActions, profilesReducer, type UpdateProfilePayload } from './redux/profilesSlice'
7
+ export { resolveInitialProfiles } from './redux/resolveInitialProfiles'
8
+ export { useSharedProfilesSync, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult } from './redux/useSharedProfilesSync'
5
9
  export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
6
10
  export type { Profile } from './types'
@@ -0,0 +1,21 @@
1
+ export interface ReduxAction<P> {
2
+ payload: P
3
+ type: string
4
+ }
5
+
6
+ export type ActionCreator<P> = ((payload: P) => ReduxAction<P>) & {
7
+ type: string
8
+ match: (action: { type: string }) => action is ReduxAction<P>
9
+ }
10
+
11
+ // Same low-level shape @rific/core's createSettingsSlice gives every other slice in the fleet
12
+ // (namespaced type string, .type, .match). Shared internally between profilesSlice.ts and
13
+ // profileExtensionSlice.ts rather than each hand-rolling its own copy — not exported from
14
+ // index.ts, since it's an implementation detail of how this package's own reducers are built, not
15
+ // something a consuming app needs.
16
+ export function createActionCreator<P>(type: string): ActionCreator<P> {
17
+ const creator = ((payload: P) => ({ payload, type })) as ActionCreator<P>
18
+ creator.type = type
19
+ creator.match = (action: { type: string }): action is ReduxAction<P> => action.type === type
20
+ return creator
21
+ }
@@ -0,0 +1,55 @@
1
+ import { ActionCreator, createActionCreator } from './createActionCreator'
2
+
3
+ export interface SetProfileExtensionPayload<TExtension> {
4
+ extension: TExtension
5
+ id: string
6
+ }
7
+
8
+ export interface ProfileExtensionActions<TExtension> {
9
+ remove: ActionCreator<string>
10
+ set: ActionCreator<SetProfileExtensionPayload<TExtension>>
11
+ }
12
+
13
+ export type ProfileExtensionState<TExtension> = Record<string, TExtension>
14
+
15
+ export interface ProfileExtensionSlice<TExtension> {
16
+ actions: ProfileExtensionActions<TExtension>
17
+ reducer: (state: ProfileExtensionState<TExtension> | undefined, action: { type: string }) => ProfileExtensionState<TExtension>
18
+ }
19
+
20
+ // A per-profile extension is any app-specific field that doesn't belong on the shared base Profile
21
+ // type (a control scheme, a key-binding preference, a difficulty setting, ...) but still needs to
22
+ // travel WITH a specific profile id, entirely locally — never synced to the shared App Group
23
+ // roster (a profile created on a sibling @tastic game has no idea this extension exists at all,
24
+ // same as this package's own README describes for any app-specific field). Two real @tastic games
25
+ // (BoxHockey's controlScheme, LightCycles' keyScheme) each hand-rolled an almost-identical
26
+ // Record<profileId, TExtension> reducer before this factory existed; this is that shape,
27
+ // generalized over the extension's own type so a future game's own per-profile preference doesn't
28
+ // need a third copy.
29
+ //
30
+ // `namespace` prefixes every action type (e.g. 'profileExtension/controlScheme/set') so an app with
31
+ // more than one independent per-profile extension never collides between them — same convention
32
+ // @rific/core's createSettingsSlice uses its own namespace argument for.
33
+ //
34
+ // Deliberately NOT auto-cleared when the base `profiles` slice removes an id — this stays a plain,
35
+ // self-contained reducer rather than importing profilesActions.remove's action type to listen for.
36
+ // An app already dispatches multiple independent actions when deleting a profile (profilesActions.
37
+ // remove, and typically its own local selection-slice cleanup); calling this slice's own `remove`
38
+ // alongside those is one more such dispatch, not new cross-slice wiring.
39
+ export function createProfileExtensionSlice<TExtension>(namespace: string): ProfileExtensionSlice<TExtension> {
40
+ const set = createActionCreator<SetProfileExtensionPayload<TExtension>>(`profileExtension/${namespace}/set`)
41
+ const remove = createActionCreator<string>(`profileExtension/${namespace}/remove`)
42
+
43
+ function reducer(state: ProfileExtensionState<TExtension> = {}, action: { type: string }): ProfileExtensionState<TExtension> {
44
+ if (set.match(action)) return { ...state, [action.payload.id]: action.payload.extension }
45
+ if (remove.match(action)) {
46
+ if (!(action.payload in state)) return state
47
+ const next = { ...state }
48
+ delete next[action.payload]
49
+ return next
50
+ }
51
+ return state
52
+ }
53
+
54
+ return { actions: { remove, set }, reducer }
55
+ }
@@ -0,0 +1,47 @@
1
+ import { Profile } from '../types'
2
+ import { createActionCreator } from './createActionCreator'
3
+
4
+ export interface UpdateProfilePayload {
5
+ id: string
6
+ patch: Partial<Pick<Profile, 'name' | 'color' | 'tag'>>
7
+ }
8
+
9
+ // setAll: full-replace, for hydrating from resolveInitialProfiles or a remote refresh
10
+ // (useSharedProfilesSync's onRemoteChange). add/update/remove: the actual roster CRUD every
11
+ // @tastic game already hand-rolled an identical version of in its own useProfiles.tsx.
12
+ const setAll = createActionCreator<Profile[]>('profiles/setAll')
13
+ const add = createActionCreator<Profile>('profiles/add')
14
+ const update = createActionCreator<UpdateProfilePayload>('profiles/update')
15
+ const remove = createActionCreator<string>('profiles/remove')
16
+
17
+ export const profilesActions = { add, remove, setAll, update }
18
+
19
+ export function profilesReducer(state: Profile[] = [], action: { type: string }): Profile[] {
20
+ if (setAll.match(action)) return action.payload
21
+ if (add.match(action)) return [...state, action.payload]
22
+ if (update.match(action)) {
23
+ const { id, patch } = action.payload
24
+ return state.map((p) => (p.id === id ? { ...p, ...patch, updatedAt: Date.now() } : p))
25
+ }
26
+ if (remove.match(action)) return state.filter((p) => p.id !== action.payload)
27
+ return state
28
+ }
29
+
30
+ function generateProfileId(): string {
31
+ return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
32
+ }
33
+
34
+ export interface CreateProfileInput {
35
+ name: string
36
+ color: string
37
+ tag: string
38
+ }
39
+
40
+ // Builds a complete Profile (id + timestamps) ready to dispatch via profilesActions.add. Kept as
41
+ // its own plain function rather than folded into the `add` action creator, so `add` itself stays a
42
+ // simple "append this exact record" primitive (useful for rehydration/tests too, where the record
43
+ // already exists and shouldn't get a second, different id/timestamp minted for it).
44
+ export function createProfileRecord(input: CreateProfileInput): Profile {
45
+ const now = Date.now()
46
+ return { color: input.color, createdAt: now, id: generateProfileId(), name: input.name, tag: input.tag, updatedAt: now }
47
+ }
@@ -0,0 +1,26 @@
1
+ import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
2
+ import { Profile } from '../types'
3
+
4
+ // Resolves the roster an app should start with, given whatever it already had saved in its own
5
+ // local fallback storage (however that app stores it — this never touches AsyncStorage itself,
6
+ // only the shared App Group store via loadSharedProfiles/saveSharedProfiles). Every @tastic game
7
+ // hand-rolled an identical version of this exact decision inline in its own useProfiles.tsx; this
8
+ // is that logic, extracted verbatim.
9
+ //
10
+ // - Shared store unavailable (Android/web, or an iOS build that hasn't run `expo prebuild` since
11
+ // the entitlement was added): `localFallback` IS the roster, verbatim.
12
+ // - Shared store available, and genuinely empty: either a first launch, or the first time THIS
13
+ // app has synced since the App Group entitlement was added — seed it from `localFallback` (so
14
+ // an upgrading user's own profiles don't just vanish), and mirror that seed to the shared store
15
+ // (fire-and-forget; a failed seed write just means the next successful write catches up).
16
+ // - Shared store available and non-empty: shared always wins over a stale local snapshot, never
17
+ // merged with it — avoids resurrecting a profile that was deleted from another app.
18
+ export async function resolveInitialProfiles(groupId: string, localFallback: Profile[]): Promise<Profile[]> {
19
+ if (!isSharedProfileStoreAvailable) return localFallback
20
+ const shared = await loadSharedProfiles(groupId)
21
+ if (shared.length === 0 && localFallback.length > 0) {
22
+ saveSharedProfiles(groupId, localFallback).catch(() => {})
23
+ return localFallback
24
+ }
25
+ return shared
26
+ }
@@ -0,0 +1,74 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+ import { AppState } from 'react-native'
3
+
4
+ import { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from '../sharedProfileStore'
5
+ import { Profile } from '../types'
6
+
7
+ export interface UseSharedProfilesSyncOptions {
8
+ groupId: string
9
+ // Called with a freshly-read shared roster whenever the app returns to the foreground — the
10
+ // shared store has no cross-process change notification, so this is what catches "edited a
11
+ // profile in a different @tastic app, then switched back to this one." Skipped while a
12
+ // syncToShared write is still in flight (see pendingWriteRef below), and never called at all
13
+ // when the shared store isn't available.
14
+ onRemoteChange: (profiles: Profile[]) => void
15
+ }
16
+
17
+ export interface UseSharedProfilesSyncResult {
18
+ // Call right after applying a roster change locally (dispatching profilesActions.setAll/add/
19
+ // update/remove) to mirror it to the shared store. No-ops when the shared store isn't available
20
+ // — the caller is responsible for its own local persistence in that case, same as before this
21
+ // hook existed. Fire-and-forget: never throws, never awaited by this hook itself.
22
+ syncToShared: (next: Profile[]) => void
23
+ }
24
+
25
+ // Bundles the two things every @tastic game needs around the shared App Group roster once
26
+ // `profiles` lives in its own Redux store: mirroring local writes out to the shared store, and
27
+ // picking up remote writes a sibling app made while this one was backgrounded. Deliberately
28
+ // separate from resolveInitialProfiles — that one's for the one-time initial load, this one's for
29
+ // the app's whole lifetime after that.
30
+ export function useSharedProfilesSync({ groupId, onRemoteChange }: UseSharedProfilesSyncOptions): UseSharedProfilesSyncResult {
31
+ // True for the duration of an in-flight syncToShared write — the foreground-refresh effect below
32
+ // skips a refresh while this is true, rather than risk reading back a pre-write snapshot and
33
+ // handing the caller an onRemoteChange that reverts the write still landing. A ref, not state:
34
+ // read from inside the AppState listener's closure, which is only created once per subscription
35
+ // and would otherwise see whatever this was at mount time, forever.
36
+ const pendingWriteRef = useRef(false)
37
+ // Same reasoning, for onRemoteChange itself — an app typically passes an inline callback that
38
+ // closes over its own latest dispatch/state, so the listener must always call the current one,
39
+ // not whichever was in scope when the effect below first subscribed.
40
+ const onRemoteChangeRef = useRef(onRemoteChange)
41
+ useEffect(() => {
42
+ onRemoteChangeRef.current = onRemoteChange
43
+ }, [onRemoteChange])
44
+
45
+ useEffect(() => {
46
+ if (!isSharedProfileStoreAvailable) return
47
+ let cancelled = false
48
+ const sub = AppState.addEventListener('change', (next) => {
49
+ if (next !== 'active' || pendingWriteRef.current) return
50
+ loadSharedProfiles(groupId).then((shared) => {
51
+ if (!cancelled) onRemoteChangeRef.current(shared)
52
+ })
53
+ })
54
+ return () => {
55
+ cancelled = true
56
+ sub.remove()
57
+ }
58
+ }, [groupId])
59
+
60
+ const syncToShared = useCallback(
61
+ (next: Profile[]) => {
62
+ if (!isSharedProfileStoreAvailable) return
63
+ pendingWriteRef.current = true
64
+ saveSharedProfiles(groupId, next)
65
+ .catch(() => {})
66
+ .finally(() => {
67
+ pendingWriteRef.current = false
68
+ })
69
+ },
70
+ [groupId]
71
+ )
72
+
73
+ return { syncToShared }
74
+ }