@tastic/profile 0.11.0 → 0.13.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/dist/index.js CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  ProfilePicker: () => ProfilePicker,
28
28
  ProfilesManager: () => ProfilesManager,
29
29
  ProfilesScreen: () => ProfilesScreen,
30
+ avoidSeatColorClash: () => avoidSeatColorClash,
30
31
  createProfileExtensionSlice: () => createProfileExtensionSlice,
31
32
  createProfileRecord: () => createProfileRecord,
32
33
  createProfileSelectionSlice: () => createProfileSelectionSlice,
@@ -38,6 +39,7 @@ __export(index_exports, {
38
39
  loadSharedProfiles: () => loadSharedProfiles,
39
40
  profilesActions: () => profilesActions,
40
41
  profilesReducer: () => profilesReducer,
42
+ readSharedProfiles: () => readSharedProfiles,
41
43
  resolveInitialProfiles: () => resolveInitialProfiles,
42
44
  saveSharedProfiles: () => saveSharedProfiles,
43
45
  useProfileColorPreview: () => useProfileColorPreview,
@@ -189,12 +191,12 @@ var styles2 = import_react_native2.StyleSheet.create({
189
191
  anchor: {
190
192
  position: "relative"
191
193
  },
192
- // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
194
+ // See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
193
195
  // view its own stacking context, so the elevation has to live on the anchor itself.
194
196
  anchorOpen: {
195
197
  zIndex: 100
196
198
  },
197
- // Separates Manage (a navigation action) from the selectable rows above it — different kind of
199
+ // Separates Manage (a navigation action) from the selectable rows above it - different kind of
198
200
  // action, not just another row in the same list. Tinted with the seat's own color (passed in as
199
201
  // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
200
202
  divider: {
@@ -203,7 +205,7 @@ var styles2 = import_react_native2.StyleSheet.create({
203
205
  opacity: 0.25
204
206
  },
205
207
  // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
206
- // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
208
+ // - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
207
209
  // own to fill it with, and the render site now colors the icon directly instead.
208
210
  guestChip: {
209
211
  alignItems: "center",
@@ -217,7 +219,7 @@ var styles2 = import_react_native2.StyleSheet.create({
217
219
  fontWeight: "700",
218
220
  letterSpacing: 0.5
219
221
  },
220
- // Centered rather than left-aligned like a normal row — see this row's own render-site comment
222
+ // Centered rather than left-aligned like a normal row - see this row's own render-site comment
221
223
  // for why (a footer action, not another selectable identity).
222
224
  manageRowInner: {
223
225
  alignItems: "center",
@@ -235,7 +237,7 @@ var styles2 = import_react_native2.StyleSheet.create({
235
237
  shadowOpacity: 0.3,
236
238
  shadowRadius: 8
237
239
  },
238
- // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
240
+ // Padded on every side (not just top/bottom) - a selected row's own full-color background (see
239
241
  // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
240
242
  // pill instead of looking clipped against them.
241
243
  menuContent: {
@@ -325,19 +327,15 @@ function deriveTag(name) {
325
327
  const trimmed = name.trim();
326
328
  return trimmed ? trimmed[0].toUpperCase() : "";
327
329
  }
328
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
330
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
329
331
  const contextColorPreview = useProfileColorPreview();
330
332
  const colorPreview = colorPreviewProp ?? contextColorPreview;
331
- const { dark, colors } = (0, import_auto_paper3.useAutoPaperTheme)();
333
+ const { dark } = (0, import_auto_paper3.useAutoPaperTheme)();
332
334
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
333
335
  const { error: showErrorToast } = (0, import_toaster.useToast)();
334
336
  const host = (0, import_hud2.usePopoverHost)();
335
337
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
336
338
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
337
- const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
338
- const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
339
- const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
340
- const cancelFg = dark ? "#FFFFFF" : "#000000";
341
339
  const [editingId, setEditingId] = (0, import_react3.useState)(null);
342
340
  const [draftName, setDraftName] = (0, import_react3.useState)("");
343
341
  const [draftColor, setDraftColor] = (0, import_react3.useState)(defaultColor);
@@ -424,17 +422,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
424
422
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
425
423
  ] }) }, profile.id);
426
424
  }) }) }),
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: [
431
- "This permanently deletes ",
432
- confirmDeleteProfile.name,
433
- "\u2019s profile. This cannot be undone."
434
- ] }),
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" })
437
- ] }) }) })
425
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_hud2.ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
438
426
  ] });
439
427
  }
440
428
  function AlignedColorPicker({ useAlign, ...props }) {
@@ -478,7 +466,7 @@ var styles3 = import_react_native3.StyleSheet.create({
478
466
  gap: 4,
479
467
  paddingBottom: 8
480
468
  },
481
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
469
+ // 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
482
470
  // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
483
471
  // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
484
472
  hiddenTagInput: {
@@ -505,27 +493,6 @@ var styles3 = import_react_native3.StyleSheet.create({
505
493
  borderStyle: "dashed",
506
494
  borderWidth: 2
507
495
  },
508
- overlay: {
509
- alignItems: "center",
510
- backgroundColor: "rgba(0,0,0,0.72)",
511
- bottom: 0,
512
- justifyContent: "center",
513
- left: 0,
514
- position: "absolute",
515
- right: 0,
516
- top: 0
517
- },
518
- overlayBody: { textAlign: "center" },
519
- overlayButton: { width: 160 },
520
- overlayCard: {
521
- alignItems: "center",
522
- borderRadius: 20,
523
- borderWidth: 1,
524
- gap: 16,
525
- maxWidth: 360,
526
- padding: 32
527
- },
528
- overlayTitle: { fontWeight: "bold", marginBottom: -8 },
529
496
  row: {
530
497
  borderRadius: 8,
531
498
  paddingVertical: 8
@@ -560,7 +527,7 @@ var import_feedback_press3 = require("@rific/feedback-press");
560
527
  var import_react4 = require("react");
561
528
  var import_react_native4 = require("react-native");
562
529
  var import_jsx_runtime5 = require("react/jsx-runtime");
563
- function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
530
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }) {
564
531
  const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
565
532
  const managerRef = (0, import_react4.useRef)(null);
566
533
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
@@ -569,7 +536,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
569
536
  managerRef.current?.commitPendingEdit();
570
537
  onBack();
571
538
  };
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" }) }) });
539
+ 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, 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" }) }) });
573
540
  }
574
541
  var styles4 = import_react_native4.StyleSheet.create({
575
542
  container: {
@@ -611,9 +578,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
611
578
  function reducer(state = defaultState, action) {
612
579
  if (action.type === REHYDRATE) {
613
580
  const persisted = action.payload?.[mountKey];
614
- return { ...defaultState, ...state, ...persisted };
581
+ const merged = { ...defaultState, ...state, ...persisted };
582
+ const seen = /* @__PURE__ */ new Set();
583
+ for (const seat of Object.keys(merged)) {
584
+ const profileId = merged[seat];
585
+ if (profileId === null) continue;
586
+ if (seen.has(profileId)) merged[seat] = null;
587
+ else seen.add(profileId);
588
+ }
589
+ return merged;
590
+ }
591
+ if (select.match(action)) {
592
+ const { profileId, seat } = action.payload;
593
+ const previous = state[seat] ?? null;
594
+ const next = { ...state, [seat]: profileId };
595
+ if (profileId !== null) {
596
+ for (const other of Object.keys(state)) {
597
+ if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
598
+ }
599
+ }
600
+ return next;
615
601
  }
616
- if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
617
602
  if (clearProfile.match(action)) {
618
603
  const next = { ...state };
619
604
  for (const seat of Object.keys(state)) {
@@ -673,22 +658,27 @@ try {
673
658
  }
674
659
  var PROFILES_KEY = "sharedProfiles";
675
660
  var isSharedProfileStoreAvailable = native !== null;
676
- async function loadSharedProfiles(groupId) {
661
+ async function readSharedProfiles(groupId) {
677
662
  if (!native) return [];
678
663
  let json = null;
679
664
  try {
680
665
  json = await native.getSharedJSON(groupId, PROFILES_KEY);
681
666
  } catch {
682
- return [];
667
+ return null;
683
668
  }
684
669
  if (!json) return [];
685
670
  try {
686
671
  const parsed = JSON.parse(json);
687
- return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
672
+ if (!Array.isArray(parsed)) return null;
673
+ const profiles = parsed.filter(isValidProfile);
674
+ return parsed.length > 0 && profiles.length === 0 ? null : profiles;
688
675
  } catch {
689
- return [];
676
+ return null;
690
677
  }
691
678
  }
679
+ async function loadSharedProfiles(groupId) {
680
+ return await readSharedProfiles(groupId) ?? [];
681
+ }
692
682
  async function saveSharedProfiles(groupId, profiles) {
693
683
  if (!native) return;
694
684
  try {
@@ -700,7 +690,8 @@ async function saveSharedProfiles(groupId, profiles) {
700
690
  // src/redux/resolveInitialProfiles.ts
701
691
  async function resolveInitialProfiles(groupId, localFallback) {
702
692
  if (!isSharedProfileStoreAvailable) return localFallback;
703
- const shared = await loadSharedProfiles(groupId);
693
+ const shared = await readSharedProfiles(groupId);
694
+ if (shared === null) return localFallback;
704
695
  if (shared.length === 0 && localFallback.length > 0) {
705
696
  saveSharedProfiles(groupId, localFallback).catch(() => {
706
697
  });
@@ -742,8 +733,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
742
733
  let cancelled = false;
743
734
  const sub = import_react_native5.AppState.addEventListener("change", (next) => {
744
735
  if (next !== "active" || pendingWriteRef.current) return;
745
- loadSharedProfiles(groupId).then((shared) => {
746
- if (!cancelled) onRemoteChangeRef.current(shared);
736
+ readSharedProfiles(groupId).then((shared) => {
737
+ if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
747
738
  });
748
739
  });
749
740
  return () => {
@@ -764,6 +755,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
764
755
  );
765
756
  return { syncToShared };
766
757
  }
758
+
759
+ // src/seatColorClash.ts
760
+ function avoidSeatColorClash(p1Color, p2Color, defaults) {
761
+ if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
762
+ const [p1Default, p2Default] = defaults;
763
+ return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
764
+ }
767
765
  // Annotate the CommonJS export names for ESM import in node:
768
766
  0 && (module.exports = {
769
767
  MAX_PROFILE_NAME_LENGTH,
@@ -773,6 +771,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
773
771
  ProfilePicker,
774
772
  ProfilesManager,
775
773
  ProfilesScreen,
774
+ avoidSeatColorClash,
776
775
  createProfileExtensionSlice,
777
776
  createProfileRecord,
778
777
  createProfileSelectionSlice,
@@ -784,6 +783,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
784
783
  loadSharedProfiles,
785
784
  profilesActions,
786
785
  profilesReducer,
786
+ readSharedProfiles,
787
787
  resolveInitialProfiles,
788
788
  saveSharedProfiles,
789
789
  useProfileColorPreview,
package/dist/index.mjs CHANGED
@@ -148,12 +148,12 @@ var styles2 = StyleSheet2.create({
148
148
  anchor: {
149
149
  position: "relative"
150
150
  },
151
- // See InlineColorPicker's identical comment — React Native Web gives every position:'relative'
151
+ // See InlineColorPicker's identical comment - React Native Web gives every position:'relative'
152
152
  // view its own stacking context, so the elevation has to live on the anchor itself.
153
153
  anchorOpen: {
154
154
  zIndex: 100
155
155
  },
156
- // Separates Manage (a navigation action) from the selectable rows above it — different kind of
156
+ // Separates Manage (a navigation action) from the selectable rows above it - different kind of
157
157
  // action, not just another row in the same list. Tinted with the seat's own color (passed in as
158
158
  // backgroundColor) rather than a flat gray, matching the border/caret's own accent-tinted look.
159
159
  divider: {
@@ -162,7 +162,7 @@ var styles2 = StyleSheet2.create({
162
162
  opacity: 0.25
163
163
  },
164
164
  // Matches ProfileChip's own default size/shape exactly (see this row's own render-site comment)
165
- // — no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
165
+ // - no backgroundColor, unlike ProfileChip's own default: this row has no identity color of its
166
166
  // own to fill it with, and the render site now colors the icon directly instead.
167
167
  guestChip: {
168
168
  alignItems: "center",
@@ -176,7 +176,7 @@ var styles2 = StyleSheet2.create({
176
176
  fontWeight: "700",
177
177
  letterSpacing: 0.5
178
178
  },
179
- // Centered rather than left-aligned like a normal row — see this row's own render-site comment
179
+ // Centered rather than left-aligned like a normal row - see this row's own render-site comment
180
180
  // for why (a footer action, not another selectable identity).
181
181
  manageRowInner: {
182
182
  alignItems: "center",
@@ -194,7 +194,7 @@ var styles2 = StyleSheet2.create({
194
194
  shadowOpacity: 0.3,
195
195
  shadowRadius: 8
196
196
  },
197
- // Padded on every side (not just top/bottom) — a selected row's own full-color background (see
197
+ // Padded on every side (not just top/bottom) - a selected row's own full-color background (see
198
198
  // `row`'s borderRadius) needs real margin from the menu's own straight edges to read as a rounded
199
199
  // pill instead of looking clipped against them.
200
200
  menuContent: {
@@ -239,10 +239,10 @@ import { defaultColors, useAutoPaperTheme } from "@rific/auto-paper";
239
239
  import { TouchableRipple as TouchableRipple2 } from "@rific/feedback-press";
240
240
  import { useFocusChain } from "@rific/focus-chain";
241
241
  import { useToast } from "@rific/toaster";
242
- import { getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from "@tastic/hud";
242
+ import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from "@tastic/hud";
243
243
  import { useCallback, useEffect, useImperativeHandle, useMemo as useMemo2, useRef, useState } from "react";
244
244
  import { KeyboardAvoidingView, Platform, ScrollView as ScrollView2, StyleSheet as StyleSheet3, useWindowDimensions, View as View3 } from "react-native";
245
- import { Button, Icon as Icon3, Portal, Text as Text3, TextInput } from "react-native-paper";
245
+ import { Icon as Icon3, Text as Text3, TextInput } from "react-native-paper";
246
246
  import { useSafeAreaInsets } from "react-native-safe-area-context";
247
247
 
248
248
  // src/profilesValidation.ts
@@ -284,19 +284,15 @@ function deriveTag(name) {
284
284
  const trimmed = name.trim();
285
285
  return trimmed ? trimmed[0].toUpperCase() : "";
286
286
  }
287
- function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, cardBg: cardBgOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
287
+ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant = "headlineSmall", colorPreview: colorPreviewProp, useColorPickerAlign }) {
288
288
  const contextColorPreview = useProfileColorPreview();
289
289
  const colorPreview = colorPreviewProp ?? contextColorPreview;
290
- const { dark, colors } = useAutoPaperTheme();
290
+ const { dark } = useAutoPaperTheme();
291
291
  const insets = useSafeAreaInsets();
292
292
  const { error: showErrorToast } = useToast();
293
293
  const host = usePopoverHost();
294
294
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
295
295
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
296
- const cardBg = cardBgOverride ?? (dark ? "#111111" : "#F2F2F2");
297
- const cardBorder = dark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)";
298
- const cancelBg = dark ? "#2A2A2A" : "#E0E0E0";
299
- const cancelFg = dark ? "#FFFFFF" : "#000000";
300
296
  const [editingId, setEditingId] = useState(null);
301
297
  const [draftName, setDraftName] = useState("");
302
298
  const [draftColor, setDraftColor] = useState(defaultColor);
@@ -383,17 +379,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
383
379
  /* @__PURE__ */ jsx4(Text3, { variant: "bodyLarge", style: [styles3.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
384
380
  ] }) }, profile.id);
385
381
  }) }) }),
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?" }),
389
- /* @__PURE__ */ jsxs2(Text3, { variant: "bodyLarge", style: [styles3.overlayBody, { color: fg }], children: [
390
- "This permanently deletes ",
391
- confirmDeleteProfile.name,
392
- "\u2019s profile. This cannot be undone."
393
- ] }),
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" })
396
- ] }) }) })
382
+ /* @__PURE__ */ jsx4(ConfirmDialog, { visible: confirmDeleteProfile !== null, title: "Delete Profile?", message: confirmDeleteProfile ? `This permanently deletes ${confirmDeleteProfile.name}\u2019s profile. This cannot be undone.` : "", confirmLabel: "Delete", cancelLabel: "Cancel", icon: "alert-outline", destructive: true, onConfirm: handleConfirmDelete, onCancel: () => setConfirmDeleteId(null) })
397
383
  ] });
398
384
  }
399
385
  function AlignedColorPicker({ useAlign, ...props }) {
@@ -437,7 +423,7 @@ var styles3 = StyleSheet3.create({
437
423
  gap: 4,
438
424
  paddingBottom: 8
439
425
  },
440
- // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
426
+ // 1x1 and fully transparent - there's nothing to see here on purpose (see EditRow's own comment
441
427
  // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
442
428
  // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
443
429
  hiddenTagInput: {
@@ -464,27 +450,6 @@ var styles3 = StyleSheet3.create({
464
450
  borderStyle: "dashed",
465
451
  borderWidth: 2
466
452
  },
467
- overlay: {
468
- alignItems: "center",
469
- backgroundColor: "rgba(0,0,0,0.72)",
470
- bottom: 0,
471
- justifyContent: "center",
472
- left: 0,
473
- position: "absolute",
474
- right: 0,
475
- top: 0
476
- },
477
- overlayBody: { textAlign: "center" },
478
- overlayButton: { width: 160 },
479
- overlayCard: {
480
- alignItems: "center",
481
- borderRadius: 20,
482
- borderWidth: 1,
483
- gap: 16,
484
- maxWidth: 360,
485
- padding: 32
486
- },
487
- overlayTitle: { fontWeight: "bold", marginBottom: -8 },
488
453
  row: {
489
454
  borderRadius: 8,
490
455
  paddingVertical: 8
@@ -519,7 +484,7 @@ import { IconButton } from "@rific/feedback-press";
519
484
  import { useRef as useRef2 } from "react";
520
485
  import { StyleSheet as StyleSheet4, View as View4 } from "react-native";
521
486
  import { jsx as jsx5 } from "react/jsx-runtime";
522
- function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, cardBg, titleVariant, colorPreview, useColorPickerAlign }) {
487
+ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }) {
523
488
  const { dark } = useAutoPaperTheme2();
524
489
  const managerRef = useRef2(null);
525
490
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
@@ -528,7 +493,7 @@ function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, on
528
493
  managerRef.current?.commitPendingEdit();
529
494
  onBack();
530
495
  };
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" }) }) });
496
+ return /* @__PURE__ */ jsx5(View4, { style: [styles4.container, { backgroundColor: bg }], children: /* @__PURE__ */ jsx5(ProfilesManager, { ref: managerRef, profiles, defaultColor, onCreate, onSave, onDelete, fg, fgMuted, titleVariant, colorPreview, useColorPickerAlign, headerLeft: /* @__PURE__ */ jsx5(IconButton, { icon: "arrow-left", iconColor: fg, size: 24, onPress: handleBack, accessibilityLabel: "Back" }) }) });
532
497
  }
533
498
  var styles4 = StyleSheet4.create({
534
499
  container: {
@@ -570,9 +535,27 @@ function createProfileSelectionSlice(namespace, defaultState, mountKey = "profil
570
535
  function reducer(state = defaultState, action) {
571
536
  if (action.type === REHYDRATE) {
572
537
  const persisted = action.payload?.[mountKey];
573
- return { ...defaultState, ...state, ...persisted };
538
+ const merged = { ...defaultState, ...state, ...persisted };
539
+ const seen = /* @__PURE__ */ new Set();
540
+ for (const seat of Object.keys(merged)) {
541
+ const profileId = merged[seat];
542
+ if (profileId === null) continue;
543
+ if (seen.has(profileId)) merged[seat] = null;
544
+ else seen.add(profileId);
545
+ }
546
+ return merged;
547
+ }
548
+ if (select.match(action)) {
549
+ const { profileId, seat } = action.payload;
550
+ const previous = state[seat] ?? null;
551
+ const next = { ...state, [seat]: profileId };
552
+ if (profileId !== null) {
553
+ for (const other of Object.keys(state)) {
554
+ if (String(other) !== String(seat) && state[other] === profileId) next[other] = previous === profileId ? null : previous;
555
+ }
556
+ }
557
+ return next;
574
558
  }
575
- if (select.match(action)) return { ...state, [action.payload.seat]: action.payload.profileId };
576
559
  if (clearProfile.match(action)) {
577
560
  const next = { ...state };
578
561
  for (const seat of Object.keys(state)) {
@@ -632,22 +615,27 @@ try {
632
615
  }
633
616
  var PROFILES_KEY = "sharedProfiles";
634
617
  var isSharedProfileStoreAvailable = native !== null;
635
- async function loadSharedProfiles(groupId) {
618
+ async function readSharedProfiles(groupId) {
636
619
  if (!native) return [];
637
620
  let json = null;
638
621
  try {
639
622
  json = await native.getSharedJSON(groupId, PROFILES_KEY);
640
623
  } catch {
641
- return [];
624
+ return null;
642
625
  }
643
626
  if (!json) return [];
644
627
  try {
645
628
  const parsed = JSON.parse(json);
646
- return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
629
+ if (!Array.isArray(parsed)) return null;
630
+ const profiles = parsed.filter(isValidProfile);
631
+ return parsed.length > 0 && profiles.length === 0 ? null : profiles;
647
632
  } catch {
648
- return [];
633
+ return null;
649
634
  }
650
635
  }
636
+ async function loadSharedProfiles(groupId) {
637
+ return await readSharedProfiles(groupId) ?? [];
638
+ }
651
639
  async function saveSharedProfiles(groupId, profiles) {
652
640
  if (!native) return;
653
641
  try {
@@ -659,7 +647,8 @@ async function saveSharedProfiles(groupId, profiles) {
659
647
  // src/redux/resolveInitialProfiles.ts
660
648
  async function resolveInitialProfiles(groupId, localFallback) {
661
649
  if (!isSharedProfileStoreAvailable) return localFallback;
662
- const shared = await loadSharedProfiles(groupId);
650
+ const shared = await readSharedProfiles(groupId);
651
+ if (shared === null) return localFallback;
663
652
  if (shared.length === 0 && localFallback.length > 0) {
664
653
  saveSharedProfiles(groupId, localFallback).catch(() => {
665
654
  });
@@ -701,8 +690,8 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
701
690
  let cancelled = false;
702
691
  const sub = AppState.addEventListener("change", (next) => {
703
692
  if (next !== "active" || pendingWriteRef.current) return;
704
- loadSharedProfiles(groupId).then((shared) => {
705
- if (!cancelled) onRemoteChangeRef.current(shared);
693
+ readSharedProfiles(groupId).then((shared) => {
694
+ if (!cancelled && shared !== null) onRemoteChangeRef.current(shared);
706
695
  });
707
696
  });
708
697
  return () => {
@@ -723,6 +712,13 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
723
712
  );
724
713
  return { syncToShared };
725
714
  }
715
+
716
+ // src/seatColorClash.ts
717
+ function avoidSeatColorClash(p1Color, p2Color, defaults) {
718
+ if (p2Color.toLowerCase() !== p1Color.toLowerCase()) return p2Color;
719
+ const [p1Default, p2Default] = defaults;
720
+ return p2Color.toLowerCase() === p1Default.toLowerCase() ? p2Default : p1Default;
721
+ }
726
722
  export {
727
723
  MAX_PROFILE_NAME_LENGTH,
728
724
  MAX_TAG_LENGTH,
@@ -731,6 +727,7 @@ export {
731
727
  ProfilePicker,
732
728
  ProfilesManager,
733
729
  ProfilesScreen,
730
+ avoidSeatColorClash,
734
731
  createProfileExtensionSlice,
735
732
  createProfileRecord,
736
733
  createProfileSelectionSlice,
@@ -742,6 +739,7 @@ export {
742
739
  loadSharedProfiles,
743
740
  profilesActions,
744
741
  profilesReducer,
742
+ readSharedProfiles,
745
743
  resolveInitialProfiles,
746
744
  saveSharedProfiles,
747
745
  useProfileColorPreview,
@@ -1,9 +1,9 @@
1
1
  import ExpoModulesCore
2
2
 
3
3
  // Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
4
- // — the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
4
+ // - the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
5
5
  // the same "com.apple.security.application-groups" entitlement) read and write one common blob of
6
- // data. Nothing here is @tastic/profile-specific — see src/sharedProfileStore.ts, the one caller,
6
+ // data. Nothing here is @tastic/profile-specific - see src/sharedProfileStore.ts, the one caller,
7
7
  // for the profile-roster-shaped API built on top of this. Android/web never register this module
8
8
  // at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
9
9
  // treat "module not present" as a normal, expected case rather than an error.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.11.0",
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",
3
+ "version": "0.13.0",
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",
7
7
  "expo",
@@ -65,13 +65,13 @@
65
65
  "@infinitetoken/eslint-config": "0.2.0",
66
66
  "@infinitetoken/jest-config": "^0.4.0",
67
67
  "@infinitetoken/tsconfig": "^0.5.0",
68
- "@rific/auto-paper": "^0.12.0",
69
- "@rific/core": "^0.2.0",
70
- "@rific/feedback-press": "^0.12.0",
71
- "@rific/focus-chain": "^0.4.7",
72
- "@rific/toaster": "^0.7.0",
73
- "@tastic/core": "^0.9.0",
74
- "@tastic/hud": "^0.12.0",
68
+ "@rific/auto-paper": "^0.13.2",
69
+ "@rific/core": "^0.3.1",
70
+ "@rific/feedback-press": "^0.13.0",
71
+ "@rific/focus-chain": "^0.4.8",
72
+ "@rific/toaster": "^0.8.0",
73
+ "@tastic/core": "^0.14.0",
74
+ "@tastic/hud": "^0.19.0",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
77
77
  "@types/jest": "^30.0.0",
@@ -85,7 +85,7 @@
85
85
  "prettier": "^3.8.3",
86
86
  "react": "^19.0.0",
87
87
  "react-dom": "^19.0.0",
88
- "react-native": "^0.85.3",
88
+ "react-native": "^0.86.3",
89
89
  "react-native-paper": "^5.15.2",
90
90
  "react-native-safe-area-context": "^5.0.0",
91
91
  "tsup": "^8.0.0",
@@ -5,7 +5,7 @@ import { Icon, Text } from 'react-native-paper'
5
5
  import { useProfileDisplayColor } from './ProfileColorContext'
6
6
  import { Profile } from './types'
7
7
 
8
- // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) — this
8
+ // Presence check, not isValidTag's own full-string emoji validation (profilesValidation.ts) - this
9
9
  // only decides a font-size ratio, so it doesn't need that check's ZWJ/variation-selector precision,
10
10
  // just "does this tag contain a pictograph at all."
11
11
  const EMOJI_PATTERN = /\p{Extended_Pictographic}/u
@@ -14,7 +14,7 @@ interface Props {
14
14
  profile: Profile
15
15
  size?: number
16
16
  // False on a selected row, whose own background already fills with the profile's color (see
17
- // ProfilePicker's own selected-row handling) — a second circle behind the tag there would either
17
+ // ProfilePicker's own selected-row handling) - a second circle behind the tag there would either
18
18
  // double up decoratively or, filled some other flat color, look like a stray badge next to the
19
19
  // plain icon+text every other selected row in the app uses. Contrast is still computed against
20
20
  // the profile's own color either way (that's what's actually behind the glyph whether this chip
@@ -24,18 +24,18 @@ interface Props {
24
24
  filled?: boolean
25
25
  }
26
26
 
27
- // A profile's identity at a glance — its own color as a filled circle, with its tag (or a generic
27
+ // A profile's identity at a glance - its own color as a filled circle, with its tag (or a generic
28
28
  // fallback icon, for a profile with none set) in a contrast-safe color on top. The same combination
29
29
  // @tastic/hud's InlineColorPicker trigger shows for live color selection, just as a small static
30
30
  // badge for a list row (ProfilePicker's dropdown, ProfilesManager's roster) rather than an
31
31
  // interactive picker trigger.
32
32
  export function ProfileChip({ profile, size = 22, filled = true }: Props) {
33
- // The drawn color, not necessarily the saved one — see ProfileColorProvider.
33
+ // The drawn color, not necessarily the saved one - see ProfileColorProvider.
34
34
  const color = useProfileDisplayColor(profile.color)
35
35
  const contrastColor = getContrastColor(color)
36
36
  // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
37
37
  // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
38
- // looks small for "🎮" at the identical size — this bumps emoji up to the icon's own ratio instead,
38
+ // looks small for "🎮" at the identical size - this bumps emoji up to the icon's own ratio instead,
39
39
  // since a single emoji is closer in visual weight to an icon glyph than to a line of text.
40
40
  const tagFontSize = size * (profile.tag && EMOJI_PATTERN.test(profile.tag) ? 0.6 : 0.4)
41
41
  return (