@tastic/profile 0.16.1 → 0.17.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.mjs CHANGED
@@ -40,8 +40,11 @@ function useProfileDisplayColor(hex) {
40
40
  // src/SwatchLayer.tsx
41
41
  import { StyleSheet, View } from "react-native";
42
42
  import { jsx as jsx2 } from "react/jsx-runtime";
43
- function SwatchLayer({ render, color, size }) {
44
- return /* @__PURE__ */ jsx2(View, { style: [styles.layer, { borderRadius: size / 2, height: size, width: size }], children: render(color, size) });
43
+ function drawSwatch(render, color, size, place) {
44
+ return render ? render(color, size, place) ?? null : null;
45
+ }
46
+ function SwatchLayer({ size, children }) {
47
+ return /* @__PURE__ */ jsx2(View, { style: [styles.layer, { borderRadius: size / 2, height: size, width: size }], children });
45
48
  }
46
49
  var styles = StyleSheet.create({
47
50
  layer: {
@@ -58,10 +61,11 @@ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
58
61
  var EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
59
62
  function IdentityChip({ color, tag, icon, size, filled = true }) {
60
63
  const renderSwatch = useProfileSwatchRenderer();
64
+ const drawing = filled ? drawSwatch(renderSwatch, color, size, "chip") : null;
61
65
  const contrastColor = getContrastColor(color);
62
66
  const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : tag && Array.from(tag).length >= 3 ? 0.32 : 0.4);
63
- return /* @__PURE__ */ jsxs(View2, { style: [styles2.chip, { borderRadius: size / 2, height: size, width: size }, filled && !renderSwatch && { backgroundColor: color }], children: [
64
- filled && renderSwatch && /* @__PURE__ */ jsx3(SwatchLayer, { render: renderSwatch, color, size }),
67
+ return /* @__PURE__ */ jsxs(View2, { style: [styles2.chip, { borderRadius: size / 2, height: size, width: size }, filled && drawing === null && { backgroundColor: color }], children: [
68
+ drawing !== null && /* @__PURE__ */ jsx3(SwatchLayer, { size, children: drawing }),
65
69
  tag ? /* @__PURE__ */ jsx3(Text, { style: [styles2.tag, { color: contrastColor, fontSize: tagFontSize }], numberOfLines: 1, adjustsFontSizeToFit: true, children: tag }) : /* @__PURE__ */ jsx3(Icon, { source: icon, size: size * 0.6, color: contrastColor })
66
70
  ] });
67
71
  }
@@ -86,6 +90,12 @@ var MENU_BORDER_WIDTH = 2;
86
90
  var ROW_INSET = 12;
87
91
  var CHIP_SIZE = 32;
88
92
  var CHIP_TRIGGER_PADDING = 6;
93
+ var CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8;
94
+ var CHIP_TRIGGER_GAP = 8;
95
+ var PLAIN_TRIGGER_GAP = 2;
96
+ var CHEVRON_SIZE = 16;
97
+ var CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING;
98
+ var PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE;
89
99
  var SWATCH_SIZE = 28;
90
100
  var SWATCH_MIN_GAP = 6;
91
101
  var SWATCH_RING_WIDTH = 2;
@@ -130,11 +140,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
130
140
  };
131
141
  const triggerLabel = label.toUpperCase();
132
142
  const showFooter = !!footer && !showColorPage;
133
- return /* @__PURE__ */ jsxs2(View3, { style: [styles3.anchor, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
134
- /* @__PURE__ */ jsx4(TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ jsxs2(View3, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
143
+ const flatTriggerStyle = StyleSheet3.flatten(triggerStyle) ?? {};
144
+ const { flexShrink, ...pillStyle } = flatTriggerStyle;
145
+ const shrinks = typeof flexShrink === "number" && flexShrink > 0;
146
+ const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : void 0;
147
+ return /* @__PURE__ */ jsxs2(View3, { style: [styles3.anchor, anchorShrink, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
148
+ /* @__PURE__ */ jsx4(TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, shrinks && styles3.triggerShrinks, shrinks ? pillStyle : triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ jsxs2(View3, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
135
149
  chip && /* @__PURE__ */ jsx4(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
136
150
  /* @__PURE__ */ jsx4(Text2, { style: [styles3.triggerLabel, { color: chipTrigger ? fg : color }], numberOfLines: 1, children: triggerLabel }),
137
- /* @__PURE__ */ jsx4(Icon2, { source: "menu-down", size: 16, color: chipTrigger ? fg : color })
151
+ /* @__PURE__ */ jsx4(Icon2, { source: "menu-down", size: CHEVRON_SIZE, color: chipTrigger ? fg : color })
138
152
  ] }) }),
139
153
  /* @__PURE__ */ jsx4(PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: MENU_BORDER_WIDTH, triggerSize: chipTrigger ? triggerWidth : TRIGGER_HEIGHT, children: /* @__PURE__ */ jsxs2(
140
154
  View3,
@@ -173,9 +187,10 @@ function ColorRow({ section, fg, dividerColor, onPress }) {
173
187
  const { value, previewValue, label = "Color", caption } = section;
174
188
  const renderSwatch = useProfileSwatchRenderer();
175
189
  const drawn = previewValue ? previewValue(value) : value;
190
+ const drawing = drawSwatch(renderSwatch, drawn, COLOR_ROW_SWATCH_SIZE, "colorRow");
176
191
  return /* @__PURE__ */ jsxs2(Fragment, { children: [
177
192
  /* @__PURE__ */ jsx4(TouchableRipple, { onPress, style: [pickerMenuStyles.row, { height: colorRowHeight(caption) }], accessibilityRole: "button", accessibilityLabel: caption ? `${label}, ${caption}` : label, children: /* @__PURE__ */ jsxs2(View3, { style: pickerMenuStyles.rowInner, children: [
178
- /* @__PURE__ */ jsx4(View3, { style: [styles3.colorRowSwatch, !renderSwatch && { backgroundColor: drawn }], children: renderSwatch && /* @__PURE__ */ jsx4(SwatchLayer, { render: renderSwatch, color: drawn, size: COLOR_ROW_SWATCH_SIZE }) }),
193
+ /* @__PURE__ */ jsx4(View3, { style: [styles3.colorRowSwatch, drawing === null && { backgroundColor: drawn }], children: drawing !== null && /* @__PURE__ */ jsx4(SwatchLayer, { size: COLOR_ROW_SWATCH_SIZE, children: drawing }) }),
179
194
  /* @__PURE__ */ jsxs2(View3, { style: styles3.colorRowText, children: [
180
195
  /* @__PURE__ */ jsx4(Text2, { style: [styles3.colorRowLabel, { color: fg }], numberOfLines: 1, children: label }),
181
196
  caption ? /* @__PURE__ */ jsx4(Text2, { style: [styles3.colorRowCaption, { color: fg }], numberOfLines: 1, children: caption }) : null
@@ -202,9 +217,11 @@ function ColorPage({ section, homeColor, fg, onBack }) {
202
217
  const swatchDisplay = displayFor(swatch.value);
203
218
  const contrast = getContrastColor2(swatchDisplay);
204
219
  const accessibilityLabel = swappable ? `${swatch.label}, swap` : taken ? `${swatch.label}, taken` : isHomeColor ? `${swatch.label}, profile color` : swatch.label;
220
+ const drawnSize = selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE;
221
+ const drawing = drawSwatch(renderSwatch, swatchDisplay, drawnSize, "colorPage");
205
222
  const mark = selected ? /* @__PURE__ */ jsx4(Icon2, { source: "check", size: 16, color: contrast }) : swappable ? /* @__PURE__ */ jsx4(Icon2, { source: "swap-horizontal", size: 16, color: contrast }) : taken ? /* @__PURE__ */ jsx4(Icon2, { source: "close", size: 16, color: contrast }) : isHomeColor ? /* @__PURE__ */ jsx4(View3, { style: [styles3.homeDot, { backgroundColor: contrast }] }) : /* @__PURE__ */ jsx4(View3, {});
206
- return /* @__PURE__ */ jsx4(TouchableRipple, { disabled: taken && !swappable, onPress: () => onChange(swatch.value), borderless: true, style: [styles3.swatch, !renderSwatch && { backgroundColor: swatchDisplay }, selected && styles3.swatchSelected, taken && !swappable && styles3.swatchTaken], accessibilityRole: "button", accessibilityLabel, accessibilityState: { selected, disabled: taken && !swappable }, children: renderSwatch ? /* @__PURE__ */ jsxs2(View3, { style: styles3.swatchFace, children: [
207
- /* @__PURE__ */ jsx4(SwatchLayer, { render: renderSwatch, color: swatchDisplay, size: selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE }),
223
+ return /* @__PURE__ */ jsx4(TouchableRipple, { disabled: taken && !swappable, onPress: () => onChange(swatch.value), borderless: true, style: [styles3.swatch, drawing === null && { backgroundColor: swatchDisplay }, selected && styles3.swatchSelected, taken && !swappable && styles3.swatchTaken], accessibilityRole: "button", accessibilityLabel, accessibilityState: { selected, disabled: taken && !swappable }, children: drawing !== null ? /* @__PURE__ */ jsxs2(View3, { style: styles3.swatchFace, children: [
224
+ /* @__PURE__ */ jsx4(SwatchLayer, { size: drawnSize, children: drawing }),
208
225
  mark
209
226
  ] }) : mark }, swatch.value);
210
227
  }) })
@@ -249,10 +266,10 @@ var styles3 = StyleSheet3.create({
249
266
  chipTriggerInner: {
250
267
  alignItems: "center",
251
268
  flexDirection: "row",
252
- gap: 8,
269
+ gap: CHIP_TRIGGER_GAP,
253
270
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
254
271
  paddingLeft: CHIP_TRIGGER_PADDING,
255
- paddingRight: CHIP_TRIGGER_PADDING + 8
272
+ paddingRight: CHIP_TRIGGER_END_PADDING
256
273
  },
257
274
  colorRowCaption: {
258
275
  fontSize: 12,
@@ -361,13 +378,22 @@ var styles3 = StyleSheet3.create({
361
378
  triggerInner: {
362
379
  alignItems: "center",
363
380
  flexDirection: "row",
364
- gap: 2,
381
+ gap: PLAIN_TRIGGER_GAP,
365
382
  height: TRIGGER_HEIGHT
366
383
  },
384
+ // Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
385
+ // With room it keeps its natural width, so this changes nothing there.
367
386
  triggerLabel: {
387
+ flexShrink: 1,
368
388
  fontSize: 13,
369
389
  fontWeight: "700",
370
- letterSpacing: 1
390
+ letterSpacing: 1,
391
+ minWidth: 0
392
+ },
393
+ // A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
394
+ // width, which is what cuts the label off.
395
+ triggerShrinks: {
396
+ maxWidth: "100%"
371
397
  }
372
398
  });
373
399
 
@@ -516,6 +542,7 @@ function isValidProfile(value) {
516
542
  // src/ProfilesManager.tsx
517
543
  import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
518
544
  var CHIP_SIZE2 = 40;
545
+ var textInputRef = (ref) => ref;
519
546
  function deriveTag(name) {
520
547
  const trimmed = name.trim();
521
548
  return trimmed ? trimmed[0].toUpperCase() : "";
@@ -526,6 +553,8 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
526
553
  const { dark } = useAutoPaperTheme();
527
554
  const insets = useSafeAreaInsets();
528
555
  const { error: showErrorToast } = useToast();
556
+ const columnRef = useRef(null);
557
+ const renderSwatch = useProfileSwatchRenderer();
529
558
  const host = usePopoverHost();
530
559
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
531
560
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
@@ -596,7 +625,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
596
625
  headerLeft,
597
626
  /* @__PURE__ */ jsx8(Text4, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
598
627
  ] }),
599
- /* @__PURE__ */ jsx8(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ jsx8(FadingScrollView2, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
628
+ /* @__PURE__ */ jsx8(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ jsx8(View5, { ref: columnRef, collapsable: false, style: styles5.column, children: /* @__PURE__ */ jsx8(FadingScrollView2, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
600
629
  if (row.kind === "new") {
601
630
  if (editingId !== "new") {
602
631
  return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit("new"), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
@@ -604,27 +633,28 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
604
633
  /* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
605
634
  ] }) }, "new");
606
635
  }
607
- return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
636
+ return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, "new");
608
637
  }
609
638
  const { profile } = row;
610
639
  if (editingId === profile.id) {
611
- return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
640
+ return /* @__PURE__ */ jsx8(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, profile.id);
612
641
  }
613
642
  return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit(profile), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
614
643
  /* @__PURE__ */ jsx8(ProfileChip, { profile, size: CHIP_SIZE2 }),
615
644
  /* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
616
645
  ] }) }, profile.id);
617
- }) }) }),
646
+ }) }) }) }),
618
647
  /* @__PURE__ */ jsx8(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) })
619
648
  ] });
620
649
  }
621
- function AlignedColorPicker({ useAlign, ...props }) {
650
+ function AlignedColorPicker({ useAlign, column, ...props }) {
622
651
  const { width: windowWidth } = useWindowDimensions();
623
652
  const { width, height } = getInlineColorPickerContentSize(defaultColors2.length, windowWidth);
624
- const alignOverride = useAlign(props.host.openId === props.id, width, height);
653
+ const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
625
654
  return /* @__PURE__ */ jsx8(InlineColorPicker, { ...props, alignOverride });
626
655
  }
627
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
656
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }) {
657
+ const pickerSwatch = useMemo3(() => renderSwatch ? (color, size, place) => renderSwatch(color, size, place === "trigger" ? "chip" : "colorPage") : void 0, [renderSwatch]);
628
658
  const register = useFocusChain();
629
659
  const tag = register();
630
660
  const name = register();
@@ -636,13 +666,17 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
636
666
  if (host.openId === "color") tagFocusRef.current();
637
667
  }, [host.openId]);
638
668
  return /* @__PURE__ */ jsxs4(View5, { style: styles5.editRow, children: [
639
- useColorPickerAlign ? /* @__PURE__ */ jsx8(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ jsx8(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
640
- /* @__PURE__ */ jsx8(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
641
- /* @__PURE__ */ jsx8(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: styles5.nameInput }),
669
+ useColorPickerAlign ? /* @__PURE__ */ jsx8(AlignedColorPicker, { useAlign: useColorPickerAlign, column, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch }) : /* @__PURE__ */ jsx8(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch, bounds: column }),
670
+ /* @__PURE__ */ jsx8(TextInput, { ref: textInputRef(tag.ref), ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
671
+ /* @__PURE__ */ jsx8(TextInput, { ref: textInputRef(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: styles5.nameInput }),
642
672
  onDelete && /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: onDelete, style: styles5.iconButton, children: /* @__PURE__ */ jsx8(Icon4, { source: "trash-can-outline", size: 18, color: fgMuted }) })
643
673
  ] });
644
674
  }
645
675
  var styles5 = StyleSheet5.create({
676
+ // The roster's column, around the scroll view (see columnRef).
677
+ column: {
678
+ flex: 1
679
+ },
646
680
  content: {
647
681
  gap: 4,
648
682
  paddingHorizontal: 20
@@ -916,10 +950,29 @@ async function readSharedProfiles(groupId) {
916
950
  async function loadSharedProfiles(groupId) {
917
951
  return await readSharedProfiles(groupId) ?? [];
918
952
  }
953
+ var hasId = (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.id !== "";
954
+ async function unreadableEntries(groupId, profiles) {
955
+ let json;
956
+ try {
957
+ json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY);
958
+ } catch {
959
+ return [];
960
+ }
961
+ if (!json) return [];
962
+ try {
963
+ const parsed = JSON.parse(json);
964
+ if (!Array.isArray(parsed)) return [];
965
+ const saved = new Set(profiles.map((profile) => profile.id));
966
+ return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id));
967
+ } catch {
968
+ return [];
969
+ }
970
+ }
919
971
  async function saveSharedProfiles(groupId, profiles) {
920
972
  if (!native) return;
921
973
  try {
922
- await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
974
+ const kept = await unreadableEntries(groupId, profiles);
975
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]));
923
976
  } catch {
924
977
  }
925
978
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Player profile identity for local-multiplayer React Native games: a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
5
  "keywords": [
6
6
  "react-native",
@@ -62,16 +62,17 @@
62
62
  },
63
63
  "prettier": "@infinitetoken/eslint-config/prettier",
64
64
  "devDependencies": {
65
- "@infinitetoken/eslint-config": "0.2.0",
65
+ "@infinitetoken/eslint-config": "0.2.1",
66
66
  "@infinitetoken/jest-config": "^0.5.2",
67
67
  "@infinitetoken/tsconfig": "^0.6.0",
68
- "@rific/auto-paper": "^0.13.3",
69
- "@rific/core": "^0.4.0",
70
- "@rific/feedback-press": "^0.13.0",
68
+ "@rific/auto-paper": "^0.13.4",
69
+ "@rific/core": "^0.5.0",
70
+ "@rific/feedback-press": "^0.13.1",
71
71
  "@rific/focus-chain": "^0.4.8",
72
- "@rific/toaster": "^0.8.3",
72
+ "@rific/gestures": "^0.1.3",
73
+ "@rific/toaster": "^0.10.1",
73
74
  "@tastic/core": "^0.14.0",
74
- "@tastic/hud": "^0.24.0",
75
+ "@tastic/hud": "^0.25.1",
75
76
  "@testing-library/dom": "^10.4.1",
76
77
  "@testing-library/react": "^16.3.2",
77
78
  "@types/jest": "^30.0.0",
@@ -97,7 +98,7 @@
97
98
  "@rific/focus-chain": ">=0.4.0",
98
99
  "@rific/toaster": ">=0.4.0",
99
100
  "@tastic/core": ">=0.1.0",
100
- "@tastic/hud": ">=0.23.0",
101
+ "@tastic/hud": ">=0.25.1",
101
102
  "expo-modules-core": ">=1.0.0",
102
103
  "react": ">=19.0.0",
103
104
  "react-native": ">=0.76.0",
@@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'
3
3
  import { Icon, Text } from 'react-native-paper'
4
4
 
5
5
  import { useProfileSwatchRenderer } from './ProfileColorContext'
6
- import { SwatchLayer } from './SwatchLayer'
6
+ import { drawSwatch, SwatchLayer } from './SwatchLayer'
7
7
 
8
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,
@@ -28,6 +28,7 @@ interface Props {
28
28
  // place of the flat color (a filled chip only); the tag's contrast still comes from `color`.
29
29
  export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
30
30
  const renderSwatch = useProfileSwatchRenderer()
31
+ const drawing = filled ? drawSwatch(renderSwatch, color, size, 'chip') : null
31
32
  const contrastColor = getContrastColor(color)
32
33
  // An emoji glyph reads visually smaller than a bold letter at the same fontSize (the system emoji
33
34
  // font leaves more of its own em-box empty), so a plain-text ratio that looks right for "J" still
@@ -39,8 +40,8 @@ export function IdentityChip({ color, tag, icon, size, filled = true }: Props) {
39
40
  // them off there ("D..."); 0.32 fits them, with adjustsFontSizeToFit still the backstop on native.
40
41
  const tagFontSize = size * (tag && EMOJI_PATTERN.test(tag) ? 0.6 : tag && Array.from(tag).length >= 3 ? 0.32 : 0.4)
41
42
  return (
42
- <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && !renderSwatch && { backgroundColor: color }]}>
43
- {filled && renderSwatch && <SwatchLayer render={renderSwatch} color={color} size={size} />}
43
+ <View style={[styles.chip, { borderRadius: size / 2, height: size, width: size }, filled && drawing === null && { backgroundColor: color }]}>
44
+ {drawing !== null && <SwatchLayer size={size}>{drawing}</SwatchLayer>}
44
45
  {tag ? (
45
46
  <Text style={[styles.tag, { color: contrastColor, fontSize: tagFontSize }]} numberOfLines={1} adjustsFontSizeToFit>
46
47
  {tag}
@@ -41,6 +41,8 @@ interface OptionPickerProps<T extends string> {
41
41
  align?: 'left' | 'right' | 'center'
42
42
  alignOverride?: ProfilePickerAlignResult
43
43
  rotation?: PopoverRotation
44
+ // The pill's own style (a background), as ProfilePicker's; a `flexShrink` lets it give way in a
45
+ // narrow row, cutting its label off.
44
46
  triggerStyle?: StyleProp<ViewStyle>
45
47
  }
46
48
 
@@ -1,6 +1,6 @@
1
1
  import { defaultColors, FadingScrollView, getContrastColor, type SeedColor } from '@rific/auto-paper'
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
- import type { PopoverHost, PopoverRotation } from '@tastic/hud/chrome'
3
+ import type { PopoverHost, PopoverRotation, ViewInstance } from '@tastic/hud/chrome'
4
4
  import { PopoverBody, useAutoAlign } from '@tastic/hud/chrome'
5
5
  import { ReactNode, RefObject, useState } from 'react'
6
6
  import { type LayoutChangeEvent, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
@@ -8,7 +8,7 @@ import { Icon, Text } from 'react-native-paper'
8
8
 
9
9
  import { IdentityChip } from './IdentityChip'
10
10
  import { useProfileSwatchRenderer } from './ProfileColorContext'
11
- import { SwatchLayer } from './SwatchLayer'
11
+ import { drawSwatch, SwatchLayer } from './SwatchLayer'
12
12
 
13
13
  // The trigger and popover ProfilePicker and OptionPicker share: a name trigger (or the chip pill),
14
14
  // a bordered menu of rows, and with a `colorSection` a Color row that swaps the menu for a page of
@@ -25,6 +25,15 @@ const ROW_INSET = 12
25
25
  // The chip trigger (see `colorSection`): a CHIP_SIZE circle inside a pill padded by CHIP_TRIGGER_PADDING.
26
26
  const CHIP_SIZE = 32
27
27
  const CHIP_TRIGGER_PADDING = 6
28
+ const CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8
29
+ const CHIP_TRIGGER_GAP = 8
30
+ const PLAIN_TRIGGER_GAP = 2
31
+ const CHEVRON_SIZE = 16
32
+ // Everything in a trigger but its label: the chip, the chevron, the gaps and the padding (84pt for the
33
+ // chip pill, 18 for the plain trigger). A shrinking trigger (see `triggerStyle`) stops here, so its
34
+ // chip and chevron always fit inside it.
35
+ const CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING
36
+ const PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE
28
37
 
29
38
  // The color row and page (see `colorSection`). Every height here is fixed (explicit line heights),
30
39
  // so pickerContentSize can size them exactly. Swatches are the same 28pt circles as
@@ -74,16 +83,16 @@ export interface ProfilePickerColorSection {
74
83
 
75
84
  // A complete placement decision for the popover: which way it opens, how tall it may grow, whether
76
85
  // this open's measurement has landed yet, and the ref attached to the measured trigger.
77
- // Structurally identical to @tastic/hud's own `AlignResult` (exported from hud >= 0.12.0, and the
78
- // same shape `useAutoAlign` itself returns) - declared here rather than imported so this package's
79
- // public types don't start requiring a newer @tastic/hud than its peer range already admits. A
80
- // hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
86
+ // Structurally identical to @tastic/hud's own `AlignResult` (the same shape `useAutoAlign` itself
87
+ // returns), and a hud `AlignResult` (e.g. from `useZoneClampedAlign`) is assignable to this as-is.
88
+ // The ref holds hud's `ViewInstance`, a View's instance under React Native's legacy types and its
89
+ // strict ones alike (a bare `RefObject<View>` is a ref to the View function under the strict ones).
81
90
  export interface ProfilePickerAlignResult {
82
91
  align: 'left' | 'right' | 'center'
83
92
  verticalAlign: 'below' | 'above'
84
93
  maxHeight: number
85
94
  measured: boolean
86
- triggerRef: RefObject<View | null>
95
+ triggerRef: RefObject<ViewInstance | null>
87
96
  }
88
97
 
89
98
  export interface ProfilePickerContentSize {
@@ -134,6 +143,8 @@ interface PickerMenuProps {
134
143
  align?: 'left' | 'right' | 'center'
135
144
  alignOverride?: ProfilePickerAlignResult
136
145
  rotation: PopoverRotation
146
+ // The pressable trigger's own style (a host's background behind the pill). A `flexShrink` in it lets
147
+ // the trigger give way in the host's row: see `shrinks` below.
137
148
  triggerStyle?: StyleProp<ViewStyle>
138
149
  // Pinned under the list page's rows, behind a divider (ProfilePicker's Manage row).
139
150
  footer?: ReactNode
@@ -172,9 +183,21 @@ export function PickerMenu({ id, host, color, dark, label, chip, colorSection, h
172
183
  // Shown on the list page only (see the footer below).
173
184
  const showFooter = !!footer && !showColorPage
174
185
 
186
+ // A host's `flexShrink` is how far the trigger gives way in the host's row, so it goes on the anchor,
187
+ // which is the host's flex item (on the pressable it would only squeeze the pill's height), down to
188
+ // the trigger's fixed parts. The pressable is then capped at the anchor's width, so its label is cut
189
+ // off with an ellipsis and the chip and chevron keep their size. With room, the anchor and the pill
190
+ // are their natural width, as before. The pill's edges, and so the measured trigger, the caret and
191
+ // the popover's alignment, follow the shrunk width. Without a flexShrink nothing here changes.
192
+ const flatTriggerStyle: ViewStyle = StyleSheet.flatten(triggerStyle) ?? {}
193
+ const { flexShrink, ...pillStyle } = flatTriggerStyle
194
+ const shrinks = typeof flexShrink === 'number' && flexShrink > 0
195
+ const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : undefined
196
+
175
197
  return (
176
- <View style={[styles.anchor, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
177
- <TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
198
+ <View style={[styles.anchor, anchorShrink, open && styles.anchorOpen]} onLayout={handleAnchorLayout}>
199
+ {/* The cap goes before the host's style, so a host's own maxWidth still wins. */}
200
+ <TouchableRipple onPress={handleTriggerPress} borderless={chipTrigger} style={[styles.trigger, chipTrigger && styles.chipTrigger, shrinks && styles.triggerShrinks, shrinks ? pillStyle : triggerStyle]} accessibilityRole='button' accessibilityLabel={triggerLabel}>
178
201
  <View ref={triggerRef} collapsable={false} style={chipTrigger ? styles.chipTriggerInner : styles.triggerInner}>
179
202
  {chip && <IdentityChip color={color} tag={chip.tag} icon={chip.icon} size={CHIP_SIZE} />}
180
203
  {/* The chip trigger's name and chevron are plain fg: the chip carries the color, and a muted
@@ -183,7 +206,7 @@ export function PickerMenu({ id, host, color, dark, label, chip, colorSection, h
183
206
  <Text style={[styles.triggerLabel, { color: chipTrigger ? fg : color }]} numberOfLines={1}>
184
207
  {triggerLabel}
185
208
  </Text>
186
- <Icon source='menu-down' size={16} color={chipTrigger ? fg : color} />
209
+ <Icon source='menu-down' size={CHEVRON_SIZE} color={chipTrigger ? fg : color} />
187
210
  </View>
188
211
  </TouchableRipple>
189
212
 
@@ -272,12 +295,13 @@ function ColorRow({ section, fg, dividerColor, onPress }: { section: ProfilePick
272
295
  const { value, previewValue, label = 'Color', caption } = section
273
296
  const renderSwatch = useProfileSwatchRenderer()
274
297
  const drawn = previewValue ? previewValue(value) : value
298
+ const drawing = drawSwatch(renderSwatch, drawn, COLOR_ROW_SWATCH_SIZE, 'colorRow')
275
299
  return (
276
300
  <>
277
301
  <TouchableRipple onPress={onPress} style={[pickerMenuStyles.row, { height: colorRowHeight(caption) }]} accessibilityRole='button' accessibilityLabel={caption ? `${label}, ${caption}` : label}>
278
302
  <View style={pickerMenuStyles.rowInner}>
279
303
  {/* A ProfileColorProvider renderSwatch draws it in place of the flat fill. */}
280
- <View style={[styles.colorRowSwatch, !renderSwatch && { backgroundColor: drawn }]}>{renderSwatch && <SwatchLayer render={renderSwatch} color={drawn} size={COLOR_ROW_SWATCH_SIZE} />}</View>
304
+ <View style={[styles.colorRowSwatch, drawing === null && { backgroundColor: drawn }]}>{drawing !== null && <SwatchLayer size={COLOR_ROW_SWATCH_SIZE}>{drawing}</SwatchLayer>}</View>
281
305
  <View style={styles.colorRowText}>
282
306
  <Text style={[styles.colorRowLabel, { color: fg }]} numberOfLines={1}>
283
307
  {label}
@@ -329,15 +353,17 @@ function ColorPage({ section, homeColor, fg, onBack }: { section: ProfilePickerC
329
353
  const swatchDisplay = displayFor(swatch.value)
330
354
  const contrast = getContrastColor(swatchDisplay)
331
355
  const accessibilityLabel = swappable ? `${swatch.label}, swap` : taken ? `${swatch.label}, taken` : isHomeColor ? `${swatch.label}, profile color` : swatch.label
356
+ const drawnSize = selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE
357
+ const drawing = drawSwatch(renderSwatch, swatchDisplay, drawnSize, 'colorPage')
332
358
  const mark = selected ? <Icon source='check' size={16} color={contrast} /> : swappable ? <Icon source='swap-horizontal' size={16} color={contrast} /> : taken ? <Icon source='close' size={16} color={contrast} /> : isHomeColor ? <View style={[styles.homeDot, { backgroundColor: contrast }]} /> : <View />
333
359
  // The same swatch as @tastic/hud's InlineColorPicker: a filled circle, the selected one
334
360
  // ringed and checked, a taken one faded with an X. TouchableRipple takes one child, so a
335
361
  // drawn swatch and its mark share a face that fills the circle inside the ring.
336
362
  return (
337
- <TouchableRipple key={swatch.value} disabled={taken && !swappable} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, !renderSwatch && { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]} accessibilityRole='button' accessibilityLabel={accessibilityLabel} accessibilityState={{ selected, disabled: taken && !swappable }}>
338
- {renderSwatch ? (
363
+ <TouchableRipple key={swatch.value} disabled={taken && !swappable} onPress={() => onChange(swatch.value)} borderless style={[styles.swatch, drawing === null && { backgroundColor: swatchDisplay }, selected && styles.swatchSelected, taken && !swappable && styles.swatchTaken]} accessibilityRole='button' accessibilityLabel={accessibilityLabel} accessibilityState={{ selected, disabled: taken && !swappable }}>
364
+ {drawing !== null ? (
339
365
  <View style={styles.swatchFace}>
340
- <SwatchLayer render={renderSwatch} color={swatchDisplay} size={selected ? SWATCH_SIZE - 2 * SWATCH_RING_WIDTH : SWATCH_SIZE} />
366
+ <SwatchLayer size={drawnSize}>{drawing}</SwatchLayer>
341
367
  {mark}
342
368
  </View>
343
369
  ) : (
@@ -392,10 +418,10 @@ const styles = StyleSheet.create({
392
418
  chipTriggerInner: {
393
419
  alignItems: 'center',
394
420
  flexDirection: 'row',
395
- gap: 8,
421
+ gap: CHIP_TRIGGER_GAP,
396
422
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
397
423
  paddingLeft: CHIP_TRIGGER_PADDING,
398
- paddingRight: CHIP_TRIGGER_PADDING + 8
424
+ paddingRight: CHIP_TRIGGER_END_PADDING
399
425
  },
400
426
  colorRowCaption: {
401
427
  fontSize: 12,
@@ -504,12 +530,21 @@ const styles = StyleSheet.create({
504
530
  triggerInner: {
505
531
  alignItems: 'center',
506
532
  flexDirection: 'row',
507
- gap: 2,
533
+ gap: PLAIN_TRIGGER_GAP,
508
534
  height: TRIGGER_HEIGHT
509
535
  },
536
+ // Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
537
+ // With room it keeps its natural width, so this changes nothing there.
510
538
  triggerLabel: {
539
+ flexShrink: 1,
511
540
  fontSize: 13,
512
541
  fontWeight: '700',
513
- letterSpacing: 1
542
+ letterSpacing: 1,
543
+ minWidth: 0
544
+ },
545
+ // A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
546
+ // width, which is what cuts the label off.
547
+ triggerShrinks: {
548
+ maxWidth: '100%'
514
549
  }
515
550
  })
@@ -1,9 +1,16 @@
1
1
  import { createContext, type ReactNode, useContext, useMemo } from 'react'
2
2
 
3
- // Draws one color's swatch fill: a circle `size` across, standing in for the flat fill. `color` is the
4
- // drawn color (after `preview`), and it stays the primary one: the tag, icon, check or dot on top of
5
- // the swatch takes its contrast from it.
6
- export type ProfileSwatchRenderer = (color: string, size: number) => ReactNode
3
+ // Where a swatch is drawn: an IdentityChip (ProfileChip, the pickers' chip trigger), a picker's Color
4
+ // row, or a swatch on its color page. An app can tell an identity (a chip, which wants a legible mark
5
+ // on one color) from a choice of color (the Color row and page, where previewing more of what the
6
+ // color gives is the point): Hangman draws its triad pie only on the latter.
7
+ export type ProfileSwatchPlace = 'chip' | 'colorRow' | 'colorPage'
8
+
9
+ // Draws one color's swatch fill: a circle `size` across, standing in for the flat fill, at `place`.
10
+ // `color` is the drawn color (after `preview`), and it stays the primary one: the tag, icon, check or
11
+ // dot on top of the swatch takes its contrast from it. Returning null or undefined keeps that swatch's
12
+ // flat fill.
13
+ export type ProfileSwatchRenderer = (color: string, size: number, place: ProfileSwatchPlace) => ReactNode
7
14
 
8
15
  interface ProfileColorContextValue {
9
16
  preview?: (hex: string) => string
@@ -17,11 +24,13 @@ interface ProfileColorProviderProps {
17
24
  // toward its card-back palette so they read muted, not as the raw swatch; a context rather than a
18
25
  // module-level setting because that blend depends on light/dark mode.
19
26
  preview?: (hex: string) => string
20
- // Replaces the flat fill of every round swatch this package draws: IdentityChip (ProfileChip and the
21
- // pickers' chip trigger), the pickers' Color row swatch and their color page swatches. Hangman draws
22
- // a pie of each color's triad. The renderer is drawn behind the swatch's mark, clipped to the circle.
23
- // Rows that fill with a color (a selected picker row) stay flat, and so does hud's InlineColorPicker
24
- // (ProfilesManager's color trigger and swatches), which doesn't read this context.
27
+ // Replaces the flat fill of the round swatches this package draws: IdentityChip (ProfileChip and the
28
+ // pickers' chip trigger), the pickers' Color row swatch and their color page swatches, each told
29
+ // where it is (ProfileSwatchPlace) and kept flat where it returns null. Hangman draws a pie of each
30
+ // color's triad on the Color row and page only. The renderer is drawn behind the swatch's mark,
31
+ // clipped to the circle.
32
+ // ProfilesManager hands it to hud's InlineColorPicker too (its trigger as a 'chip', its grid as the
33
+ // 'colorPage'). Rows that fill with a color (a selected picker row) stay flat.
25
34
  renderSwatch?: ProfileSwatchRenderer
26
35
  children: ReactNode
27
36
  }
@@ -100,7 +100,8 @@ interface ProfilePickerProps<P extends Profile> {
100
100
  // exactly as before.
101
101
  colorSection?: ProfilePickerColorSection
102
102
  // Extra style for the trigger (its pressable box) - e.g. a background behind the chip trigger's
103
- // pill. The chip trigger already pads itself and rounds into a pill.
103
+ // pill. The chip trigger already pads itself and rounds into a pill. A `flexShrink` here lets the
104
+ // trigger give way in a row too narrow for it, cutting its label off (see PickerMenu).
104
105
  triggerStyle?: StyleProp<ViewStyle>
105
106
  }
106
107