@tastic/profile 0.9.0 → 0.11.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
@@ -244,15 +244,40 @@ so a host app plugs the reducer(s) into whatever store it already has:
244
244
  - **`createProfileSelectionSlice<TSeat>(namespace, defaultState)`** — a factory for the *other*
245
245
  per-seat piece of state: which profile id each seat currently has selected (parallel to
246
246
  `createProfileExtensionSlice` above, but keyed by seat instead of by profile id). Returns a
247
- `{ actions: { select, clearProfile }, reducer }` pair over a plain `Record<TSeat, string | null>`.
247
+ `{ actions: { select, clearProfile, pruneToRoster }, reducer }` pair over a plain
248
+ `Record<TSeat, string | null>`.
248
249
  Unlike `createProfileExtensionSlice`, the seat set can't be derived from `TSeat` alone (types
249
250
  don't exist at runtime), so callers pass their own `defaultState` — the same literal object (e.g.
250
251
  `{ 1: null, 2: null }`) every app already declares today. `select({ seat, profileId })` sets one
251
252
  seat's selection directly; `clearProfile(profileId)` walks every seat and nulls out any that
252
253
  currently points at that id — call it when a profile is deleted so no seat is left referencing a
253
- ghost id. Always local, like the guest/CPU/override colors below — never part of the shared
254
+ ghost id. `pruneToRoster(ids)` nulls every seat whose id is no longer in the given roster; dispatch
255
+ it after both `profilesActions.setAll` paths (the initial `resolveInitialProfiles` reconcile and
256
+ `onRemoteChange`), since a sibling app can delete a profile without this app's own delete ever
257
+ running, and it returns the same state object when nothing changes.
258
+ Always local, like the guest/CPU/override colors below, and never part of the shared
254
259
  App Group roster, since who's selected on *this* device is naturally per-app, per-device state.
255
260
 
261
+ ## Muted profile colors (`ProfileColorProvider`)
262
+
263
+ An app that draws profile colors through a transform (Solitaire blends them toward its muted
264
+ card-back palette) wraps its tree once:
265
+
266
+ ```tsx
267
+ import { ProfileColorProvider } from '@tastic/profile'
268
+
269
+ const preview = useCallback((hex: string) => blendTowardCardBack(hex, dark), [dark])
270
+ <ProfileColorProvider preview={preview}>{children}</ProfileColorProvider>
271
+ ```
272
+
273
+ Every `ProfileChip` then draws `preview(profile.color)` (contrast computed against that),
274
+ `ProfilePicker`'s selected row fills with it, and `ProfilesManager`/`ProfilesScreen` use it for
275
+ their swatch previews unless an explicit `colorPreview` prop is passed. Components from other
276
+ packages that render a `ProfileChip` (e.g. `@tastic/hud`'s `AchievementUnlockList`) pick it up
277
+ for free. Only what's drawn changes: saved colors, `onCreate`/`onSave` values and selection are
278
+ untouched. `useProfileColorPreview()` returns the provider's function (or `undefined`), and
279
+ `useProfileDisplayColor(hex)` resolves one color. Without a provider nothing changes.
280
+
256
281
  ## Per-seat color persistence
257
282
 
258
283
  A loadout/lobby screen's color for a given seat is never just "the selected profile's saved
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { RefObject, ReactNode, Ref } from 'react';
2
+ import { ReactNode, RefObject, Ref } from 'react';
3
3
  import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
4
  import { View } from 'react-native';
5
5
 
@@ -19,6 +19,14 @@ interface Props {
19
19
  }
20
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
21
21
 
22
+ interface ProfileColorProviderProps {
23
+ preview: (hex: string) => string;
24
+ children: ReactNode;
25
+ }
26
+ declare function ProfileColorProvider({ preview, children }: ProfileColorProviderProps): react.JSX.Element;
27
+ declare function useProfileColorPreview(): ((hex: string) => string) | undefined;
28
+ declare function useProfileDisplayColor(hex: string): string;
29
+
22
30
  interface ProfilePickerAlignResult {
23
31
  align: 'left' | 'right' | 'center';
24
32
  verticalAlign: 'below' | 'above';
@@ -74,7 +82,7 @@ interface ProfilesManagerProps {
74
82
  colorPreview?: (hex: string) => string;
75
83
  useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
76
84
  }
77
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
85
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
78
86
 
79
87
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
80
88
  interface ProfilesScreenProps {
@@ -133,6 +141,7 @@ interface SelectProfilePayload<TSeat> {
133
141
  }
134
142
  interface ProfileSelectionActions<TSeat> {
135
143
  clearProfile: ActionCreator<string>;
144
+ pruneToRoster: ActionCreator<string[]>;
136
145
  select: ActionCreator<SelectProfilePayload<TSeat>>;
137
146
  }
138
147
  type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
@@ -207,4 +216,4 @@ declare const isSharedProfileStoreAvailable: boolean;
207
216
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
208
217
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
209
218
 
210
- 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 };
219
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { RefObject, ReactNode, Ref } from 'react';
2
+ import { ReactNode, RefObject, Ref } from 'react';
3
3
  import { PopoverHost, PopoverRotation } from '@tastic/hud';
4
4
  import { View } from 'react-native';
5
5
 
@@ -19,6 +19,14 @@ interface Props {
19
19
  }
20
20
  declare function ProfileChip({ profile, size, filled }: Props): react.JSX.Element;
21
21
 
22
+ interface ProfileColorProviderProps {
23
+ preview: (hex: string) => string;
24
+ children: ReactNode;
25
+ }
26
+ declare function ProfileColorProvider({ preview, children }: ProfileColorProviderProps): react.JSX.Element;
27
+ declare function useProfileColorPreview(): ((hex: string) => string) | undefined;
28
+ declare function useProfileDisplayColor(hex: string): string;
29
+
22
30
  interface ProfilePickerAlignResult {
23
31
  align: 'left' | 'right' | 'center';
24
32
  verticalAlign: 'below' | 'above';
@@ -74,7 +82,7 @@ interface ProfilesManagerProps {
74
82
  colorPreview?: (hex: string) => string;
75
83
  useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
76
84
  }
77
- declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
85
+ declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
78
86
 
79
87
  type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall';
80
88
  interface ProfilesScreenProps {
@@ -133,6 +141,7 @@ interface SelectProfilePayload<TSeat> {
133
141
  }
134
142
  interface ProfileSelectionActions<TSeat> {
135
143
  clearProfile: ActionCreator<string>;
144
+ pruneToRoster: ActionCreator<string[]>;
136
145
  select: ActionCreator<SelectProfilePayload<TSeat>>;
137
146
  }
138
147
  type ProfileSelectionState<TSeat extends string | number> = Record<TSeat, string | null>;
@@ -207,4 +216,4 @@ declare const isSharedProfileStoreAvailable: boolean;
207
216
  declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
208
217
  declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
209
218
 
210
- 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 };
219
+ export { type CreateProfileInput, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfileColorProvider, type ProfileEditPatch, type ProfileExtensionActions, type ProfileExtensionSlice, type ProfileExtensionState, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize, type ProfileSelectionActions, type ProfileSelectionSlice, type ProfileSelectionState, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps, ProfilesScreen, type ProfilesScreenProps, type SeatColorsActions, type SeatColorsSlice, type SeatColorsState, type SelectProfilePayload, type SetLastGuestColorPayload, type SetProfileExtensionPayload, type SetProfileOverridePayload, type UpdateProfilePayload, type UseSharedProfilesSyncOptions, type UseSharedProfilesSyncResult, createProfileExtensionSlice, createProfileRecord, createProfileSelectionSlice, createSeatColorsSlice, getProfilePickerContentSize, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, profilesActions, profilesReducer, resolveInitialProfiles, saveSharedProfiles, useProfileColorPreview, useProfileDisplayColor, useSharedProfilesSync };
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  MAX_PROFILE_NAME_LENGTH: () => MAX_PROFILE_NAME_LENGTH,
24
24
  MAX_TAG_LENGTH: () => MAX_TAG_LENGTH,
25
25
  ProfileChip: () => ProfileChip,
26
+ ProfileColorProvider: () => ProfileColorProvider,
26
27
  ProfilePicker: () => ProfilePicker,
27
28
  ProfilesManager: () => ProfilesManager,
28
29
  ProfilesScreen: () => ProfilesScreen,
@@ -39,6 +40,8 @@ __export(index_exports, {
39
40
  profilesReducer: () => profilesReducer,
40
41
  resolveInitialProfiles: () => resolveInitialProfiles,
41
42
  saveSharedProfiles: () => saveSharedProfiles,
43
+ useProfileColorPreview: () => useProfileColorPreview,
44
+ useProfileDisplayColor: () => useProfileDisplayColor,
42
45
  useSharedProfilesSync: () => useSharedProfilesSync
43
46
  });
44
47
  module.exports = __toCommonJS(index_exports);
@@ -47,12 +50,30 @@ module.exports = __toCommonJS(index_exports);
47
50
  var import_auto_paper = require("@rific/auto-paper");
48
51
  var import_react_native = require("react-native");
49
52
  var import_react_native_paper = require("react-native-paper");
53
+
54
+ // src/ProfileColorContext.tsx
55
+ var import_react = require("react");
50
56
  var import_jsx_runtime = require("react/jsx-runtime");
57
+ var ProfileColorContext = (0, import_react.createContext)(void 0);
58
+ function ProfileColorProvider({ preview, children }) {
59
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProfileColorContext.Provider, { value: preview, children });
60
+ }
61
+ function useProfileColorPreview() {
62
+ return (0, import_react.useContext)(ProfileColorContext);
63
+ }
64
+ function useProfileDisplayColor(hex) {
65
+ const preview = useProfileColorPreview();
66
+ return preview ? preview(hex) : hex;
67
+ }
68
+
69
+ // src/ProfileChip.tsx
70
+ var import_jsx_runtime2 = require("react/jsx-runtime");
51
71
  var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
52
72
  function ProfileChip({ profile, size = 22, filled = true }) {
53
- const contrastColor = (0, import_auto_paper.getContrastColor)(profile.color);
73
+ const color = useProfileDisplayColor(profile.color);
74
+ const contrastColor = (0, import_auto_paper.getContrastColor)(color);
54
75
  const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4);
55
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.View, { style: [styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: profile.color }], children: profile.tag ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native_paper.Text, { style: [styles.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: profile.tag }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native_paper.Icon, { source: "account", size: size * 0.6, color: contrastColor }) });
76
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native.View, { style: [styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }], children: profile.tag ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Text, { style: [styles.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: profile.tag }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper.Icon, { source: "account", size: size * 0.6, color: contrastColor }) });
56
77
  }
57
78
  var styles = import_react_native.StyleSheet.create({
58
79
  chip: {
@@ -69,10 +90,10 @@ var styles = import_react_native.StyleSheet.create({
69
90
  var import_auto_paper2 = require("@rific/auto-paper");
70
91
  var import_feedback_press = require("@rific/feedback-press");
71
92
  var import_hud = require("@tastic/hud");
72
- var import_react = require("react");
93
+ var import_react2 = require("react");
73
94
  var import_react_native2 = require("react-native");
74
95
  var import_react_native_paper2 = require("react-native-paper");
75
- var import_jsx_runtime2 = require("react/jsx-runtime");
96
+ var import_jsx_runtime3 = require("react/jsx-runtime");
76
97
  var TRIGGER_HEIGHT = 28;
77
98
  var POPOVER_WIDTH = 220;
78
99
  var ROW_HEIGHT = 36;
@@ -83,11 +104,12 @@ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
83
104
  }
84
105
  function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
85
106
  const id = `${idPrefix}-profile`;
107
+ const colorPreview = useProfileColorPreview();
86
108
  const menuBg = dark ? "#000000" : "#FFFFFF";
87
109
  const fg = dark ? "#FFFFFF" : "#000000";
88
110
  const open = host.openId === id;
89
111
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
90
- const sortedProfiles = (0, import_react.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
112
+ const sortedProfiles = (0, import_react2.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
91
113
  const { width: contentWidth, height: contentHeight } = getProfilePickerContentSize(profiles.length, !!onManage);
92
114
  const auto = (0, import_hud.useAutoAlign)(open, contentWidth, contentHeight, rotation);
93
115
  const placement = alignOverride ?? auto;
@@ -106,12 +128,12 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
106
128
  host.close();
107
129
  onManage?.();
108
130
  };
109
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
110
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), style: styles2.trigger, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { ref: triggerRef, collapsable: false, style: styles2.triggerInner, children: [
111
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Text, { style: [styles2.triggerLabel, { color }], numberOfLines: 1, children: (selectedProfile?.name ?? guestLabel).toUpperCase() }),
112
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: 16, color })
131
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
132
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press.TouchableRipple, { onPress: () => host.toggle(id), style: styles2.trigger, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { ref: triggerRef, collapsable: false, style: styles2.triggerInner, children: [
133
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Text, { style: [styles2.triggerLabel, { color }], numberOfLines: 1, children: (selectedProfile?.name ?? guestLabel).toUpperCase() }),
134
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: 16, color })
113
135
  ] }) }),
114
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_hud.PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: 2, triggerSize: TRIGGER_HEIGHT, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
136
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud.PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: 2, triggerSize: TRIGGER_HEIGHT, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
115
137
  import_react_native2.ScrollView,
116
138
  {
117
139
  style: [
@@ -126,22 +148,23 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
126
148
  contentContainerStyle: styles2.menuContent,
127
149
  showsVerticalScrollIndicator: false,
128
150
  children: [
129
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: handleSelectGuest, style: [styles2.row, selectedId === null && { backgroundColor: color }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: styles2.rowInner, children: [
130
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: styles2.guestChip, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Icon, { source: nullIcon, size: 13, color: selectedId === null ? (0, import_auto_paper2.getContrastColor)(color) : fg }) }),
131
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Text, { style: [styles2.rowLabel, { color: selectedId === null ? (0, import_auto_paper2.getContrastColor)(color) : fg }], children: nullLabel })
151
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press.TouchableRipple, { onPress: handleSelectGuest, style: [styles2.row, selectedId === null && { backgroundColor: color }], children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { style: styles2.rowInner, children: [
152
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native2.View, { style: styles2.guestChip, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Icon, { source: nullIcon, size: 13, color: selectedId === null ? (0, import_auto_paper2.getContrastColor)(color) : fg }) }),
153
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Text, { style: [styles2.rowLabel, { color: selectedId === null ? (0, import_auto_paper2.getContrastColor)(color) : fg }], children: nullLabel })
132
154
  ] }) }),
133
155
  sortedProfiles.map((profile) => {
134
156
  const taken = profile.id === takenId;
135
157
  const selected = selectedId === profile.id;
136
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { disabled: taken, onPress: () => handleSelectExisting(profile), style: [styles2.row, selected && { backgroundColor: profile.color }], children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: [styles2.rowInner, taken && styles2.rowDisabled], children: [
137
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ProfileChip, { profile, filled: !selected }),
138
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
158
+ const rowColor = colorPreview ? colorPreview(profile.color) : profile.color;
159
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press.TouchableRipple, { disabled: taken, onPress: () => handleSelectExisting(profile), style: [styles2.row, selected && { backgroundColor: rowColor }], children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { style: [styles2.rowInner, taken && styles2.rowDisabled], children: [
160
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, filled: !selected }),
161
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
139
162
  import_react_native_paper2.Text,
140
163
  {
141
164
  style: [
142
165
  styles2.rowLabel,
143
166
  {
144
- color: selected ? (0, import_auto_paper2.getContrastColor)(profile.color) : fg
167
+ color: selected ? (0, import_auto_paper2.getContrastColor)(rowColor) : fg
145
168
  }
146
169
  ],
147
170
  numberOfLines: 1,
@@ -150,11 +173,11 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
150
173
  )
151
174
  ] }) }, profile.id);
152
175
  }),
153
- onManage && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
154
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.View, { style: [styles2.divider, { backgroundColor: color }] }),
155
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_feedback_press.TouchableRipple, { onPress: handleManage, style: styles2.row, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_react_native2.View, { style: styles2.manageRowInner, children: [
156
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Icon, { source: "account-cog-outline", size: 16, color }),
157
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native_paper2.Text, { style: [styles2.manageLabel, { color }], children: "Manage" })
176
+ onManage && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
177
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native2.View, { style: [styles2.divider, { backgroundColor: color }] }),
178
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press.TouchableRipple, { onPress: handleManage, style: styles2.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { style: styles2.manageRowInner, children: [
179
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Icon, { source: "account-cog-outline", size: 16, color }),
180
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper2.Text, { style: [styles2.manageLabel, { color }], children: "Manage" })
158
181
  ] }) })
159
182
  ] })
160
183
  ]
@@ -258,7 +281,7 @@ var import_feedback_press2 = require("@rific/feedback-press");
258
281
  var import_focus_chain = require("@rific/focus-chain");
259
282
  var import_toaster = require("@rific/toaster");
260
283
  var import_hud2 = require("@tastic/hud");
261
- var import_react2 = require("react");
284
+ var import_react3 = require("react");
262
285
  var import_react_native3 = require("react-native");
263
286
  var import_react_native_paper3 = require("react-native-paper");
264
287
  var import_react_native_safe_area_context = require("react-native-safe-area-context");
@@ -296,13 +319,15 @@ function isValidProfile(value) {
296
319
  }
297
320
 
298
321
  // src/ProfilesManager.tsx
299
- var import_jsx_runtime3 = require("react/jsx-runtime");
322
+ var import_jsx_runtime4 = require("react/jsx-runtime");
300
323
  var CHIP_SIZE = 40;
301
324
  function deriveTag(name) {
302
325
  const trimmed = name.trim();
303
326
  return trimmed ? trimmed[0].toUpperCase() : "";
304
327
  }
305
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview, useColorPickerAlign }) {
328
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
329
+ const contextColorPreview = useProfileColorPreview();
330
+ const colorPreview = colorPreviewProp ?? contextColorPreview;
306
331
  const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
307
332
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
308
333
  const { error: showErrorToast } = (0, import_toaster.useToast)();
@@ -313,13 +338,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
313
338
  const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
314
339
  const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
315
340
  const cancelFg = dark ? "#FFFFFF" : "#000000";
316
- const [editingId, setEditingId] = (0, import_react2.useState)(null);
317
- const [draftName, setDraftName] = (0, import_react2.useState)("");
318
- const [draftColor, setDraftColor] = (0, import_react2.useState)(defaultColor);
319
- const [draftTag, setDraftTag] = (0, import_react2.useState)("");
320
- const [tagManuallySet, setTagManuallySet] = (0, import_react2.useState)(false);
321
- const [confirmDeleteId, setConfirmDeleteId] = (0, import_react2.useState)(null);
322
- const commitEdit = (0, import_react2.useCallback)(() => {
341
+ const [editingId, setEditingId] = (0, import_react3.useState)(null);
342
+ const [draftName, setDraftName] = (0, import_react3.useState)("");
343
+ const [draftColor, setDraftColor] = (0, import_react3.useState)(defaultColor);
344
+ const [draftTag, setDraftTag] = (0, import_react3.useState)("");
345
+ const [tagManuallySet, setTagManuallySet] = (0, import_react3.useState)(false);
346
+ const [confirmDeleteId, setConfirmDeleteId] = (0, import_react3.useState)(null);
347
+ const commitEdit = (0, import_react3.useCallback)(() => {
323
348
  const name = draftName.trim();
324
349
  if (editingId && name) {
325
350
  const tagValid = isValidTag(draftTag);
@@ -330,13 +355,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
330
355
  }
331
356
  setEditingId(null);
332
357
  }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast]);
333
- const commitEditRef = (0, import_react2.useRef)(commitEdit);
334
- (0, import_react2.useEffect)(() => {
358
+ const commitEditRef = (0, import_react3.useRef)(commitEdit);
359
+ (0, import_react3.useEffect)(() => {
335
360
  commitEditRef.current = commitEdit;
336
361
  });
337
- (0, import_react2.useEffect)(() => () => commitEditRef.current(), []);
338
- (0, import_react2.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
339
- const startEdit = (0, import_react2.useCallback)(
362
+ (0, import_react3.useEffect)(() => () => commitEditRef.current(), []);
363
+ (0, import_react3.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
364
+ const startEdit = (0, import_react3.useCallback)(
340
365
  (target) => {
341
366
  commitEdit();
342
367
  if (target === "new") {
@@ -354,61 +379,61 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
354
379
  },
355
380
  [commitEdit, defaultColor]
356
381
  );
357
- const handleNameChange = (0, import_react2.useCallback)(
382
+ const handleNameChange = (0, import_react3.useCallback)(
358
383
  (name) => {
359
384
  setDraftName(name);
360
385
  if (!tagManuallySet) setDraftTag(deriveTag(name));
361
386
  },
362
387
  [tagManuallySet]
363
388
  );
364
- const handleTagChange = (0, import_react2.useCallback)((tag) => {
389
+ const handleTagChange = (0, import_react3.useCallback)((tag) => {
365
390
  setDraftTag((previous) => clampTag(previous, tag));
366
391
  setTagManuallySet(true);
367
392
  }, []);
368
393
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
369
- const handleConfirmDelete = (0, import_react2.useCallback)(() => {
394
+ const handleConfirmDelete = (0, import_react3.useCallback)(() => {
370
395
  if (confirmDeleteId) {
371
396
  onDelete(confirmDeleteId);
372
397
  if (editingId === confirmDeleteId) setEditingId(null);
373
398
  }
374
399
  setConfirmDeleteId(null);
375
400
  }, [confirmDeleteId, editingId, onDelete]);
376
- const sortedProfiles = (0, import_react2.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
401
+ const sortedProfiles = (0, import_react3.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
377
402
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
378
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
379
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
403
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
404
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
380
405
  headerLeft,
381
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
406
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
382
407
  ] }),
383
- /* @__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) => {
408
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.KeyboardAvoidingView, { behavior: import_react_native3.Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.ScrollView, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
384
409
  if (row.kind === "new") {
385
410
  if (editingId !== "new") {
386
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit("new"), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
387
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.View, { style: [styles3.rowChip, styles3.newChip, { borderColor: fg }], children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "plus", size: 20, color: fg }) }),
388
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
411
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit("new"), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
412
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.View, { style: [styles3.rowChip, styles3.newChip, { borderColor: fg }], children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Icon, { source: "plus", size: 20, color: fg }) }),
413
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
389
414
  ] }) }, "new");
390
415
  }
391
- 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, useColorPickerAlign }, "new");
416
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
392
417
  }
393
418
  const { profile } = row;
394
419
  if (editingId === profile.id) {
395
- 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, useColorPickerAlign }, profile.id);
420
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
396
421
  }
397
- 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: [
398
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
399
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
422
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
423
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
424
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
400
425
  ] }) }, profile.id);
401
426
  }) }) }),
402
- confirmDeleteProfile && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.View, { style: styles3.overlay, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }], children: [
403
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "alert-outline", size: 64, color: colors.danger }),
404
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
405
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
427
+ confirmDeleteProfile && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.View, { style: styles3.overlay, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }], children: [
428
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Icon, { source: "alert-outline", size: 64, color: colors.danger }),
429
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
430
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
406
431
  "This permanently deletes ",
407
432
  confirmDeleteProfile.name,
408
433
  "\u2019s profile. This cannot be undone."
409
434
  ] }),
410
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
411
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
435
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
436
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
412
437
  ] }) }) })
413
438
  ] });
414
439
  }
@@ -416,24 +441,24 @@ function AlignedColorPicker({ useAlign, ...props }) {
416
441
  const { width: windowWidth } = (0, import_react_native3.useWindowDimensions)();
417
442
  const { width, height } = (0, import_hud2.getInlineColorPickerContentSize)(import_auto_paper3.defaultColors.length, windowWidth);
418
443
  const alignOverride = useAlign(props.host.openId === props.id, width, height);
419
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { ...props, alignOverride });
444
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_hud2.InlineColorPicker, { ...props, alignOverride });
420
445
  }
421
446
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
422
447
  const register = (0, import_focus_chain.useFocusChain)();
423
448
  const tag = register();
424
449
  const name = register();
425
- const tagFocusRef = (0, import_react2.useRef)(tag.props.focus);
426
- (0, import_react2.useEffect)(() => {
450
+ const tagFocusRef = (0, import_react3.useRef)(tag.props.focus);
451
+ (0, import_react3.useEffect)(() => {
427
452
  tagFocusRef.current = tag.props.focus;
428
453
  });
429
- (0, import_react2.useEffect)(() => {
454
+ (0, import_react3.useEffect)(() => {
430
455
  if (host.openId === "color") tagFocusRef.current();
431
456
  }, [host.openId]);
432
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
433
- useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
434
- /* @__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" }),
435
- /* @__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 }),
436
- 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 }) })
457
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
458
+ useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
459
+ /* @__PURE__ */ (0, import_jsx_runtime4.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" }),
460
+ /* @__PURE__ */ (0, import_jsx_runtime4.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 }),
461
+ onDelete && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
437
462
  ] });
438
463
  }
439
464
  var styles3 = import_react_native3.StyleSheet.create({
@@ -532,19 +557,19 @@ var styles3 = import_react_native3.StyleSheet.create({
532
557
  // src/ProfilesScreen.tsx
533
558
  var import_auto_paper4 = require("@rific/auto-paper");
534
559
  var import_feedback_press3 = require("@rific/feedback-press");
535
- var import_react3 = require("react");
560
+ var import_react4 = require("react");
536
561
  var import_react_native4 = require("react-native");
537
- var import_jsx_runtime4 = require("react/jsx-runtime");
562
+ var import_jsx_runtime5 = require("react/jsx-runtime");
538
563
  function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
539
564
  const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
540
- const managerRef = (0, import_react3.useRef)(null);
565
+ const managerRef = (0, import_react4.useRef)(null);
541
566
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
542
567
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
543
568
  const handleBack = () => {
544
569
  managerRef.current?.commitPendingEdit();
545
570
  onBack();
546
571
  };
547
- 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, useColorPickerAlign, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
572
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_feedback_press3.IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
548
573
  }
549
574
  var styles4 = import_react_native4.StyleSheet.create({
550
575
  container: {
@@ -582,6 +607,7 @@ var REHYDRATE = "persist/REHYDRATE";
582
607
  function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
583
608
  const select = createActionCreator(`profileSelection/${namespace}/select`);
584
609
  const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
610
+ const pruneToRoster = createActionCreator(`profileSelection/${namespace}/pruneToRoster`);
585
611
  function reducer(state = defaultState, action) {
586
612
  if (action.type === REHYDRATE) {
587
613
  const persisted = action.payload?.[mountKey];
@@ -595,9 +621,22 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
595
621
  }
596
622
  return next;
597
623
  }
624
+ if (pruneToRoster.match(action)) {
625
+ const roster = new Set(action.payload);
626
+ const next = { ...state };
627
+ let changed = false;
628
+ for (const seat of Object.keys(state)) {
629
+ const profileId = state[seat];
630
+ if (profileId !== null && !roster.has(profileId)) {
631
+ next[seat] = null;
632
+ changed = true;
633
+ }
634
+ }
635
+ return changed ? next : state;
636
+ }
598
637
  return state;
599
638
  }
600
- return { actions: { clearProfile, select }, reducer };
639
+ return { actions: { clearProfile, pruneToRoster, select }, reducer };
601
640
  }
602
641
 
603
642
  // src/redux/profilesSlice.ts
@@ -690,15 +729,15 @@ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors")
690
729
  }
691
730
 
692
731
  // src/redux/useSharedProfilesSync.ts
693
- var import_react4 = require("react");
732
+ var import_react5 = require("react");
694
733
  var import_react_native5 = require("react-native");
695
734
  function useSharedProfilesSync({ groupId, onRemoteChange }) {
696
- const pendingWriteRef = (0, import_react4.useRef)(false);
697
- const onRemoteChangeRef = (0, import_react4.useRef)(onRemoteChange);
698
- (0, import_react4.useEffect)(() => {
735
+ const pendingWriteRef = (0, import_react5.useRef)(false);
736
+ const onRemoteChangeRef = (0, import_react5.useRef)(onRemoteChange);
737
+ (0, import_react5.useEffect)(() => {
699
738
  onRemoteChangeRef.current = onRemoteChange;
700
739
  }, [onRemoteChange]);
701
- (0, import_react4.useEffect)(() => {
740
+ (0, import_react5.useEffect)(() => {
702
741
  if (!isSharedProfileStoreAvailable) return;
703
742
  let cancelled = false;
704
743
  const sub = import_react_native5.AppState.addEventListener("change", (next) => {
@@ -712,7 +751,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
712
751
  sub.remove();
713
752
  };
714
753
  }, [groupId]);
715
- const syncToShared = (0, import_react4.useCallback)(
754
+ const syncToShared = (0, import_react5.useCallback)(
716
755
  (next) => {
717
756
  if (!isSharedProfileStoreAvailable) return;
718
757
  pendingWriteRef.current = true;
@@ -730,6 +769,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
730
769
  MAX_PROFILE_NAME_LENGTH,
731
770
  MAX_TAG_LENGTH,
732
771
  ProfileChip,
772
+ ProfileColorProvider,
733
773
  ProfilePicker,
734
774
  ProfilesManager,
735
775
  ProfilesScreen,
@@ -746,5 +786,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
746
786
  profilesReducer,
747
787
  resolveInitialProfiles,
748
788
  saveSharedProfiles,
789
+ useProfileColorPreview,
790
+ useProfileDisplayColor,
749
791
  useSharedProfilesSync
750
792
  });
package/dist/index.mjs CHANGED
@@ -9,12 +9,30 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
9
9
  import { getContrastColor } from "@rific/auto-paper";
10
10
  import { StyleSheet, View } from "react-native";
11
11
  import { Icon, Text } from "react-native-paper";
12
+
13
+ // src/ProfileColorContext.tsx
14
+ import { createContext, useContext } from "react";
12
15
  import { jsx } from "react/jsx-runtime";
16
+ var ProfileColorContext = createContext(void 0);
17
+ function ProfileColorProvider({ preview, children }) {
18
+ return /* @__PURE__ */ jsx(ProfileColorContext.Provider, { value: preview, children });
19
+ }
20
+ function useProfileColorPreview() {
21
+ return useContext(ProfileColorContext);
22
+ }
23
+ function useProfileDisplayColor(hex) {
24
+ const preview = useProfileColorPreview();
25
+ return preview ? preview(hex) : hex;
26
+ }
27
+
28
+ // src/ProfileChip.tsx
29
+ import { jsx as jsx2 } from "react/jsx-runtime";
13
30
  var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
14
31
  function ProfileChip({ profile, size = 22, filled = true }) {
15
- const contrastColor = getContrastColor(profile.color);
32
+ const color = useProfileDisplayColor(profile.color);
33
+ const contrastColor = getContrastColor(color);
16
34
  const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4);
17
- return /* @__PURE__ */ jsx(View, { style: [styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: profile.color }], children: profile.tag ? /* @__PURE__ */ jsx(Text, { style: [styles.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: profile.tag }) : /* @__PURE__ */ jsx(Icon, { source: "account", size: size * 0.6, color: contrastColor }) });
35
+ return /* @__PURE__ */ jsx2(View, { style: [styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }], children: profile.tag ? /* @__PURE__ */ jsx2(Text, { style: [styles.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: profile.tag }) : /* @__PURE__ */ jsx2(Icon, { source: "account", size: size * 0.6, color: contrastColor }) });
18
36
  }
19
37
  var styles = StyleSheet.create({
20
38
  chip: {
@@ -34,7 +52,7 @@ import { PopoverBody, useAutoAlign } from "@tastic/hud";
34
52
  import { useMemo } from "react";
35
53
  import { ScrollView, StyleSheet as StyleSheet2, View as View2 } from "react-native";
36
54
  import { Icon as Icon2, Text as Text2 } from "react-native-paper";
37
- import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
55
+ import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
38
56
  var TRIGGER_HEIGHT = 28;
39
57
  var POPOVER_WIDTH = 220;
40
58
  var ROW_HEIGHT = 36;
@@ -45,6 +63,7 @@ function getProfilePickerContentSize(profileCount, hasManageRow = false) {
45
63
  }
46
64
  function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, dark, align: forcedAlign, alignOverride, rotation = 0, guestLabel, nullLabel = "Player", nullIcon = "account-off-outline", onSelect, onManage }) {
47
65
  const id = `${idPrefix}-profile`;
66
+ const colorPreview = useProfileColorPreview();
48
67
  const menuBg = dark ? "#000000" : "#FFFFFF";
49
68
  const fg = dark ? "#FFFFFF" : "#000000";
50
69
  const open = host.openId === id;
@@ -69,11 +88,11 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
69
88
  onManage?.();
70
89
  };
71
90
  return /* @__PURE__ */ jsxs(View2, { style: [styles2.anchor, open && styles2.anchorOpen], children: [
72
- /* @__PURE__ */ jsx2(TouchableRipple, { onPress: () => host.toggle(id), style: styles2.trigger, children: /* @__PURE__ */ jsxs(View2, { ref: triggerRef, collapsable: false, style: styles2.triggerInner, children: [
73
- /* @__PURE__ */ jsx2(Text2, { style: [styles2.triggerLabel, { color }], numberOfLines: 1, children: (selectedProfile?.name ?? guestLabel).toUpperCase() }),
74
- /* @__PURE__ */ jsx2(Icon2, { source: "menu-down", size: 16, color })
91
+ /* @__PURE__ */ jsx3(TouchableRipple, { onPress: () => host.toggle(id), style: styles2.trigger, children: /* @__PURE__ */ jsxs(View2, { ref: triggerRef, collapsable: false, style: styles2.triggerInner, children: [
92
+ /* @__PURE__ */ jsx3(Text2, { style: [styles2.triggerLabel, { color }], numberOfLines: 1, children: (selectedProfile?.name ?? guestLabel).toUpperCase() }),
93
+ /* @__PURE__ */ jsx3(Icon2, { source: "menu-down", size: 16, color })
75
94
  ] }) }),
76
- /* @__PURE__ */ jsx2(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: 2, triggerSize: TRIGGER_HEIGHT, children: /* @__PURE__ */ jsxs(
95
+ /* @__PURE__ */ jsx3(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: 2, triggerSize: TRIGGER_HEIGHT, children: /* @__PURE__ */ jsxs(
77
96
  ScrollView,
78
97
  {
79
98
  style: [
@@ -88,22 +107,23 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
88
107
  contentContainerStyle: styles2.menuContent,
89
108
  showsVerticalScrollIndicator: false,
90
109
  children: [
91
- /* @__PURE__ */ jsx2(TouchableRipple, { onPress: handleSelectGuest, style: [styles2.row, selectedId === null && { backgroundColor: color }], children: /* @__PURE__ */ jsxs(View2, { style: styles2.rowInner, children: [
92
- /* @__PURE__ */ jsx2(View2, { style: styles2.guestChip, children: /* @__PURE__ */ jsx2(Icon2, { source: nullIcon, size: 13, color: selectedId === null ? getContrastColor2(color) : fg }) }),
93
- /* @__PURE__ */ jsx2(Text2, { style: [styles2.rowLabel, { color: selectedId === null ? getContrastColor2(color) : fg }], children: nullLabel })
110
+ /* @__PURE__ */ jsx3(TouchableRipple, { onPress: handleSelectGuest, style: [styles2.row, selectedId === null && { backgroundColor: color }], children: /* @__PURE__ */ jsxs(View2, { style: styles2.rowInner, children: [
111
+ /* @__PURE__ */ jsx3(View2, { style: styles2.guestChip, children: /* @__PURE__ */ jsx3(Icon2, { source: nullIcon, size: 13, color: selectedId === null ? getContrastColor2(color) : fg }) }),
112
+ /* @__PURE__ */ jsx3(Text2, { style: [styles2.rowLabel, { color: selectedId === null ? getContrastColor2(color) : fg }], children: nullLabel })
94
113
  ] }) }),
95
114
  sortedProfiles.map((profile) => {
96
115
  const taken = profile.id === takenId;
97
116
  const selected = selectedId === profile.id;
98
- return /* @__PURE__ */ jsx2(TouchableRipple, { disabled: taken, onPress: () => handleSelectExisting(profile), style: [styles2.row, selected && { backgroundColor: profile.color }], children: /* @__PURE__ */ jsxs(View2, { style: [styles2.rowInner, taken && styles2.rowDisabled], children: [
99
- /* @__PURE__ */ jsx2(ProfileChip, { profile, filled: !selected }),
100
- /* @__PURE__ */ jsx2(
117
+ const rowColor = colorPreview ? colorPreview(profile.color) : profile.color;
118
+ return /* @__PURE__ */ jsx3(TouchableRipple, { disabled: taken, onPress: () => handleSelectExisting(profile), style: [styles2.row, selected && { backgroundColor: rowColor }], children: /* @__PURE__ */ jsxs(View2, { style: [styles2.rowInner, taken && styles2.rowDisabled], children: [
119
+ /* @__PURE__ */ jsx3(ProfileChip, { profile, filled: !selected }),
120
+ /* @__PURE__ */ jsx3(
101
121
  Text2,
102
122
  {
103
123
  style: [
104
124
  styles2.rowLabel,
105
125
  {
106
- color: selected ? getContrastColor2(profile.color) : fg
126
+ color: selected ? getContrastColor2(rowColor) : fg
107
127
  }
108
128
  ],
109
129
  numberOfLines: 1,
@@ -113,10 +133,10 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, color, d
113
133
  ] }) }, profile.id);
114
134
  }),
115
135
  onManage && /* @__PURE__ */ jsxs(Fragment, { children: [
116
- /* @__PURE__ */ jsx2(View2, { style: [styles2.divider, { backgroundColor: color }] }),
117
- /* @__PURE__ */ jsx2(TouchableRipple, { onPress: handleManage, style: styles2.row, children: /* @__PURE__ */ jsxs(View2, { style: styles2.manageRowInner, children: [
118
- /* @__PURE__ */ jsx2(Icon2, { source: "account-cog-outline", size: 16, color }),
119
- /* @__PURE__ */ jsx2(Text2, { style: [styles2.manageLabel, { color }], children: "Manage" })
136
+ /* @__PURE__ */ jsx3(View2, { style: [styles2.divider, { backgroundColor: color }] }),
137
+ /* @__PURE__ */ jsx3(TouchableRipple, { onPress: handleManage, style: styles2.row, children: /* @__PURE__ */ jsxs(View2, { style: styles2.manageRowInner, children: [
138
+ /* @__PURE__ */ jsx3(Icon2, { source: "account-cog-outline", size: 16, color }),
139
+ /* @__PURE__ */ jsx3(Text2, { style: [styles2.manageLabel, { color }], children: "Manage" })
120
140
  ] }) })
121
141
  ] })
122
142
  ]
@@ -258,13 +278,15 @@ function isValidProfile(value) {
258
278
  }
259
279
 
260
280
  // src/ProfilesManager.tsx
261
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
281
+ import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
262
282
  var CHIP_SIZE = 40;
263
283
  function deriveTag(name) {
264
284
  const trimmed = name.trim();
265
285
  return trimmed ? trimmed[0].toUpperCase() : "";
266
286
  }
267
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview, useColorPickerAlign }) {
287
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
288
+ const contextColorPreview = useProfileColorPreview();
289
+ const colorPreview = colorPreviewProp ?? contextColorPreview;
268
290
  const { dark, colors } = useAutoPaperTheme();
269
291
  const insets = useSafeAreaInsets();
270
292
  const { error: showErrorToast } = useToast();
@@ -340,37 +362,37 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
340
362
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
341
363
  /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
342
364
  headerLeft,
343
- /* @__PURE__ */ jsx3(Text3, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
365
+ /* @__PURE__ */ jsx4(Text3, { variant: titleVariant, style: [styles3.title, { color: fg }], children: "Profiles" })
344
366
  ] }),
345
- /* @__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) => {
367
+ /* @__PURE__ */ jsx4(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles3.keyboardAvoiding, children: /* @__PURE__ */ jsx4(ScrollView2, { style: styles3.scrollView, contentContainerStyle: [styles3.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
346
368
  if (row.kind === "new") {
347
369
  if (editingId !== "new") {
348
- return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit("new"), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
349
- /* @__PURE__ */ jsx3(View3, { style: [styles3.rowChip, styles3.newChip, { borderColor: fg }], children: /* @__PURE__ */ jsx3(Icon3, { source: "plus", size: 20, color: fg }) }),
350
- /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
370
+ return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: () => startEdit("new"), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
371
+ /* @__PURE__ */ jsx4(View3, { style: [styles3.rowChip, styles3.newChip, { borderColor: fg }], children: /* @__PURE__ */ jsx4(Icon3, { source: "plus", size: 20, color: fg }) }),
372
+ /* @__PURE__ */ jsx4(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
351
373
  ] }) }, "new");
352
374
  }
353
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
375
+ return /* @__PURE__ */ jsx4(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
354
376
  }
355
377
  const { profile } = row;
356
378
  if (editingId === profile.id) {
357
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
379
+ return /* @__PURE__ */ jsx4(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
358
380
  }
359
- return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
360
- /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
361
- /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
381
+ return /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
382
+ /* @__PURE__ */ jsx4(ProfileChip, { profile, size: CHIP_SIZE }),
383
+ /* @__PURE__ */ jsx4(Text3, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
362
384
  ] }) }, profile.id);
363
385
  }) }) }),
364
- confirmDeleteProfile && /* @__PURE__ */ jsx3(Portal, { children: /* @__PURE__ */ jsx3(View3, { style: styles3.overlay, children: /* @__PURE__ */ jsxs2(View3, { style: [styles3.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }], children: [
365
- /* @__PURE__ */ jsx3(Icon3, { source: "alert-outline", size: 64, color: colors.danger }),
366
- /* @__PURE__ */ jsx3(Text3, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
386
+ confirmDeleteProfile && /* @__PURE__ */ jsx4(Portal, { children: /* @__PURE__ */ jsx4(View3, { style: styles3.overlay, children: /* @__PURE__ */ jsxs2(View3, { style: [styles3.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }], children: [
387
+ /* @__PURE__ */ jsx4(Icon3, { source: "alert-outline", size: 64, color: colors.danger }),
388
+ /* @__PURE__ */ jsx4(Text3, { variant: "headlineLarge", style: [styles3.overlayTitle, { color: colors.danger }], children: "Delete Profile?" }),
367
389
  /* @__PURE__ */ jsxs2(Text3, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
368
390
  "This permanently deletes ",
369
391
  confirmDeleteProfile.name,
370
392
  "\u2019s profile. This cannot be undone."
371
393
  ] }),
372
- /* @__PURE__ */ jsx3(Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
373
- /* @__PURE__ */ jsx3(Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
394
+ /* @__PURE__ */ jsx4(Button, { mode: "contained", onPress: () => setConfirmDeleteId(null), style: styles3.overlayButton, buttonColor: cancelBg, textColor: cancelFg, children: "Cancel" }),
395
+ /* @__PURE__ */ jsx4(Button, { mode: "contained", onPress: handleConfirmDelete, style: styles3.overlayButton, buttonColor: colors.danger, textColor: colors.onDanger, children: "Delete" })
374
396
  ] }) }) })
375
397
  ] });
376
398
  }
@@ -378,7 +400,7 @@ function AlignedColorPicker({ useAlign, ...props }) {
378
400
  const { width: windowWidth } = useWindowDimensions();
379
401
  const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth);
380
402
  const alignOverride = useAlign(props.host.openId === props.id, width, height);
381
- return /* @__PURE__ */ jsx3(InlineColorPicker, { ...props, alignOverride });
403
+ return /* @__PURE__ */ jsx4(InlineColorPicker, { ...props, alignOverride });
382
404
  }
383
405
  function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
384
406
  const register = useFocusChain();
@@ -392,10 +414,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
392
414
  if (host.openId === "color") tagFocusRef.current();
393
415
  }, [host.openId]);
394
416
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
395
- useColorPickerAlign ? /* @__PURE__ */ jsx3(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
396
- /* @__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" }),
397
- /* @__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 }),
398
- onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
417
+ useColorPickerAlign ? /* @__PURE__ */ jsx4(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ jsx4(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
418
+ /* @__PURE__ */ jsx4(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
419
+ /* @__PURE__ */ jsx4(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 }),
420
+ onDelete && /* @__PURE__ */ jsx4(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx4(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
399
421
  ] });
400
422
  }
401
423
  var styles3 = StyleSheet3.create({
@@ -496,7 +518,7 @@ import { useAutoPaperTheme as useAutoPaperTheme2 } from "@rific/auto-paper";
496
518
  import { IconButton } from "@rific/feedback-press";
497
519
  import { useRef as useRef2 } from "react";
498
520
  import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
499
- import { jsx as jsx4 } from "react/jsx-runtime";
521
+ import { jsx as jsx5 } from "react/jsx-runtime";
500
522
  function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
501
523
  const { dark } = useAutoPaperTheme2();
502
524
  const managerRef = useRef2(null);
@@ -506,7 +528,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
506
528
  managerRef.current?.commitPendingEdit();
507
529
  onBack();
508
530
  };
509
- 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, useColorPickerAlign, headerLeft: /* @__PURE__ */ jsx4(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
531
+ return /* @__PURE__ */ jsx5(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx5(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, cardBg, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ jsx5(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
510
532
  }
511
533
  var styles4 = StyleSheet4.create({
512
534
  container: {
@@ -544,6 +566,7 @@ var REHYDRATE = "persist/REHYDRATE";
544
566
  function createProfileSelectionSlice(namespace, defaultState, mountKey = "profileSelection") {
545
567
  const select = createActionCreator(`profileSelection/${namespace}/select`);
546
568
  const clearProfile = createActionCreator(`profileSelection/${namespace}/clearProfile`);
569
+ const pruneToRoster = createActionCreator(`profileSelection/${namespace}/pruneToRoster`);
547
570
  function reducer(state = defaultState, action) {
548
571
  if (action.type === REHYDRATE) {
549
572
  const persisted = action.payload?.[mountKey];
@@ -557,9 +580,22 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
557
580
  }
558
581
  return next;
559
582
  }
583
+ if (pruneToRoster.match(action)) {
584
+ const roster = new Set(action.payload);
585
+ const next = { ...state };
586
+ let changed = false;
587
+ for (const seat of Object.keys(state)) {
588
+ const profileId = state[seat];
589
+ if (profileId !== null && !roster.has(profileId)) {
590
+ next[seat] = null;
591
+ changed = true;
592
+ }
593
+ }
594
+ return changed ? next : state;
595
+ }
560
596
  return state;
561
597
  }
562
- return { actions: { clearProfile, select }, reducer };
598
+ return { actions: { clearProfile, pruneToRoster, select }, reducer };
563
599
  }
564
600
 
565
601
  // src/redux/profilesSlice.ts
@@ -691,6 +727,7 @@ export {
691
727
  MAX_PROFILE_NAME_LENGTH,
692
728
  MAX_TAG_LENGTH,
693
729
  ProfileChip,
730
+ ProfileColorProvider,
694
731
  ProfilePicker,
695
732
  ProfilesManager,
696
733
  ProfilesScreen,
@@ -707,5 +744,7 @@ export {
707
744
  profilesReducer,
708
745
  resolveInitialProfiles,
709
746
  saveSharedProfiles,
747
+ useProfileColorPreview,
748
+ useProfileDisplayColor,
710
749
  useSharedProfilesSync
711
750
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -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.8.0",
100
+ "@tastic/hud": ">=0.12.0",
101
101
  "expo-modules-core": ">=1.0.0",
102
102
  "react": ">=19.0.0",
103
103
  "react-native": ">=0.76.0",
@@ -2,6 +2,7 @@ import { getContrastColor } from '@rific/auto-paper'
2
2
  import { StyleSheet, View } from 'react-native'
3
3
  import { Icon, Text } from 'react-native-paper'
4
4
 
5
+ import { useProfileDisplayColor } from './ProfileColorContext'
5
6
  import { Profile } from './types'
6
7
 
7
8
  // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) — this
@@ -29,14 +30,16 @@ interface Props {
29
30
  // badge for a list row (ProfilePicker's dropdown, ProfilesManager's roster) rather than an
30
31
  // interactive picker trigger.
31
32
  export function ProfileChip({ profile, size = 22, filled = true }: Props) {
32
- const contrastColor = getContrastColor(profile.color)
33
+ // The drawn color, not necessarily the saved one — see ProfileColorProvider.
34
+ const color = useProfileDisplayColor(profile.color)
35
+ const contrastColor = getContrastColor(color)
33
36
  // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
34
37
  // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
35
38
  // looks small for "🎮" at the identical size — this bumps emoji up to the icon's own ratio instead,
36
39
  // since a single emoji is closer in visual weight to an icon glyph than to a line of text.
37
40
  const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
38
41
  return (
39
- <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: profile.color }]}>
42
+ <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && { backgroundColor: color }]}>
40
43
  {profile.tag ? (
41
44
  <Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
42
45
  {profile.tag}
@@ -0,0 +1,31 @@
1
+ import { createContext, type ReactNode, useContext } from 'react'
2
+
3
+ const ProfileColorContext = createContext<((hex: string) => string) | undefined>(undefined)
4
+
5
+ interface ProfileColorProviderProps {
6
+ // Maps a profile's saved color to the color actually drawn for it. Solitaire blends profile colors
7
+ // toward its card-back palette so they read muted, not as the raw swatch; a context rather than a
8
+ // module-level setting because that blend depends on light/dark mode.
9
+ preview: (hex: string) => string
10
+ children: ReactNode
11
+ }
12
+
13
+ // App-wide display transform for profile colors. Every ProfileChip, ProfilePicker's selected-row
14
+ // fill and ProfilesManager's color picker previews read it, and so does anything outside this
15
+ // package that draws a ProfileChip (@tastic/hud's AchievementUnlockList). Without a provider,
16
+ // profile colors draw as saved.
17
+ export function ProfileColorProvider({ preview, children }: ProfileColorProviderProps) {
18
+ return <ProfileColorContext.Provider value={preview}>{children}</ProfileColorContext.Provider>
19
+ }
20
+
21
+ // The provider's preview, or undefined when there's none (so a caller can tell "no transform"
22
+ // apart from an identity one, e.g. to fall back to its own colorPreview prop).
23
+ export function useProfileColorPreview(): ((hex: string) => string) | undefined {
24
+ return useContext(ProfileColorContext)
25
+ }
26
+
27
+ // Resolves one saved color to its drawn color.
28
+ export function useProfileDisplayColor(hex: string): string {
29
+ const preview = useProfileColorPreview()
30
+ return preview ? preview(hex) : hex
31
+ }
@@ -6,6 +6,7 @@ import { ScrollView, StyleSheet, View } from 'react-native'
6
6
  import { Icon, Text } from 'react-native-paper'
7
7
 
8
8
  import { ProfileChip } from './ProfileChip'
9
+ import { useProfileColorPreview } from './ProfileColorContext'
9
10
  import { Profile } from './types'
10
11
 
11
12
  const TRIGGER_HEIGHT = 28
@@ -121,6 +122,8 @@ interface ProfilePickerProps<P extends Profile> {
121
122
  // its own persistence hook.
122
123
  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>) {
123
124
  const id = `${idPrefix}-profile`
125
+ // A selected row fills with the profile's drawn color, the same one its ProfileChip would show.
126
+ const colorPreview = useProfileColorPreview()
124
127
  const menuBg = dark ? '#000000' : '#FFFFFF'
125
128
  const fg = dark ? '#FFFFFF' : '#000000'
126
129
 
@@ -201,8 +204,9 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
201
204
  {sortedProfiles.map((profile) => {
202
205
  const taken = profile.id === takenId
203
206
  const selected = selectedId === profile.id
207
+ const rowColor = colorPreview ? colorPreview(profile.color) : profile.color
204
208
  return (
205
- <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: profile.color }]}>
209
+ <TouchableRipple key={profile.id} disabled={taken} onPress={() => handleSelectExisting(profile)} style={[styles.row, selected && { backgroundColor: rowColor }]}>
206
210
  <View style={[styles.rowInner, taken && styles.rowDisabled]}>
207
211
  {/* Unfilled on a selected row — its own background already fills with the
208
212
  profile's color, so a second circle behind the tag would just be a redundant badge
@@ -212,7 +216,7 @@ export function ProfilePicker<P extends Profile>({ idPrefix, host, profiles, sel
212
216
  style={[
213
217
  styles.rowLabel,
214
218
  {
215
- color: selected ? getContrastColor(profile.color) : fg
219
+ color: selected ? getContrastColor(rowColor) : fg
216
220
  }
217
221
  ]}
218
222
  numberOfLines={1}
@@ -9,6 +9,7 @@ import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
10
 
11
11
  import { ProfileChip } from './ProfileChip'
12
+ import { useProfileColorPreview } from './ProfileColorContext'
12
13
  import { type ProfilePickerAlignResult } from './ProfilePicker'
13
14
  import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
14
15
  import { Profile } from './types'
@@ -78,7 +79,7 @@ export interface ProfilesManagerProps {
78
79
  titleVariant?: MD3TextVariant
79
80
  // Transforms the color swatch grid's own preview per-swatch — forwarded straight to the internal
80
81
  // InlineColorPicker's identical prop (see its own doc). Leaves draftColor/onCreate/onSave's actual
81
- // color value untouched; only what's rendered changes.
82
+ // color value untouched; only what's rendered changes. Defaults to ProfileColorProvider's preview.
82
83
  colorPreview?: (hex: string) => string
83
84
  // Substitutes the color swatch popover's own placement, for a host that renders this screen inside a
84
85
  // FAKE-rotated frame (a View turned +-90deg while the OS stays portrait-locked). @tastic/hud's
@@ -114,7 +115,11 @@ function deriveTag(name: string): string {
114
115
  // really unmounts it on a given host's router — see ProfilesManagerHandle's own doc for the
115
116
  // react-navigation-web case where it doesn't, and why a host there needs the `ref` escape hatch
116
117
  // instead of relying on the unmount fallback alone.
117
- export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview, useColorPickerAlign }: ProfilesManagerProps) {
118
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = 'headlineSmall', colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps) {
119
+ // An explicit prop wins; otherwise the app-wide ProfileColorProvider's, so the swatch previews
120
+ // match the ProfileChips drawn in the list.
121
+ const contextColorPreview = useProfileColorPreview()
122
+ const colorPreview = colorPreviewProp ?? contextColorPreview
118
123
  const { dark, colors } = useAutoPaperTheme()
119
124
  const insets = useSafeAreaInsets()
120
125
  const { error: showErrorToast } = useToast()
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ProfileChip } from './ProfileChip'
2
+ export { ProfileColorProvider, useProfileColorPreview, useProfileDisplayColor } from './ProfileColorContext'
2
3
  export { getProfilePickerContentSize, ProfilePicker, type ProfilePickerAlignResult, type ProfilePickerContentSize } from './ProfilePicker'
3
4
  export { type ProfileEditPatch, ProfilesManager, type ProfilesManagerHandle, type ProfilesManagerProps } from './ProfilesManager'
4
5
  export { ProfilesScreen, type ProfilesScreenProps } from './ProfilesScreen'
@@ -14,6 +14,7 @@ export interface SelectProfilePayload<TSeat> {
14
14
 
15
15
  export interface ProfileSelectionActions<TSeat> {
16
16
  clearProfile: ActionCreator<string>
17
+ pruneToRoster: ActionCreator<string[]>
17
18
  select: ActionCreator<SelectProfilePayload<TSeat>>
18
19
  }
19
20
 
@@ -50,9 +51,15 @@ export interface ProfileSelectionSlice<TSeat extends string | number> {
50
51
  // from inside its own reducer function — only worth overriding if some future consumer ever mounts
51
52
  // this under a different key. Note `namespace` above is a different thing: it's the app's own name
52
53
  // (e.g. 'airhockey', 'boxhockey'), used only to prefix action types, not the mount key.
54
+ //
55
+ // `pruneToRoster(ids)` nulls every seat whose profile id is no longer in the given roster. Call it
56
+ // after any profilesActions.setAll (initial reconcile, onRemoteChange), since a sibling app can
57
+ // delete a profile without this app's own deleteProfile ever running. It returns the same state
58
+ // object when nothing changes, so dispatching it on every foreground resume costs no re-render.
53
59
  export function createProfileSelectionSlice<TSeat extends string | number>(namespace: string, defaultState: ProfileSelectionState<TSeat>, mountKey = 'profileSelection'): ProfileSelectionSlice<TSeat> {
54
60
  const select = createActionCreator<SelectProfilePayload<TSeat>>(`profileSelection/${namespace}/select`)
55
61
  const clearProfile = createActionCreator<string>(`profileSelection/${namespace}/clearProfile`)
62
+ const pruneToRoster = createActionCreator<string[]>(`profileSelection/${namespace}/pruneToRoster`)
56
63
 
57
64
  function reducer(state: ProfileSelectionState<TSeat> = defaultState, action: { type: string; payload?: unknown }): ProfileSelectionState<TSeat> {
58
65
  // redux-persist's own default stateReconciler (autoMergeLevel1) HARD-REPLACES this slice's
@@ -74,8 +81,21 @@ export function createProfileSelectionSlice<TSeat extends string | number>(names
74
81
  }
75
82
  return next
76
83
  }
84
+ if (pruneToRoster.match(action)) {
85
+ const roster = new Set(action.payload)
86
+ const next = { ...state }
87
+ let changed = false
88
+ for (const seat of Object.keys(state) as TSeat[]) {
89
+ const profileId = state[seat]
90
+ if (profileId !== null && !roster.has(profileId)) {
91
+ next[seat] = null
92
+ changed = true
93
+ }
94
+ }
95
+ return changed ? next : state
96
+ }
77
97
  return state
78
98
  }
79
99
 
80
- return { actions: { clearProfile, select }, reducer }
100
+ return { actions: { clearProfile, pruneToRoster, select }, reducer }
81
101
  }