@tastic/profile 0.16.2 → 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/README.md CHANGED
@@ -57,7 +57,10 @@ see its own doc comment for why that component is generic over your own `Profile
57
57
  (`{ value, onChange, previewValue?, swatches?, label?, caption? }`) puts a Color row at the top of
58
58
  the popover that opens a page of swatches (with a back row), and turns the trigger into one
59
59
  button that leads with a chip in `color` with the selected profile's tag; `triggerStyle` styles
60
- that button. Also exports
60
+ that button. A `flexShrink` in `triggerStyle` lets the button give way in a row too narrow for
61
+ it: its label is cut off with an ellipsis while the chip and chevron keep their size, and it never
62
+ gets narrower than those. With room it lays out as before. `OptionPicker` takes the same
63
+ `triggerStyle`. Also exports
61
64
  `getProfilePickerContentSize(profileCount, hasManageRow?, colorSection?)` - the popover's
62
65
  `{ width, height }`, for sizing your own placement hook.
63
66
  - **`ProfilesManager`**: the roster screen itself: create/rename/recolor/retag/delete, editing
@@ -84,7 +87,10 @@ see its own doc comment for why that component is generic over your own `Profile
84
87
  in that app's own local extension table yet - e.g. it was created on a different app in the
85
88
  group). This split is deliberate, not a limitation to work around: it's this package's own
86
89
  base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
87
- TypeScript type.
90
+ TypeScript type. A read drops any stored entry that this version's `isValidProfile` rejects (a
91
+ profile saved by a newer app under a rule loosened since), and `saveSharedProfiles` writes those
92
+ entries back after your roster as they were, so an older app can't delete a newer app's profile
93
+ just by saving.
88
94
 
89
95
  ```ts
90
96
  // Conceptual sketch of the pattern - see "Redux helpers for the shared roster" below for a
@@ -174,14 +180,16 @@ rect). Pass a placement **hook** as `useColorPickerAlign`:
174
180
  <ProfilesScreen useColorPickerAlign={useFrameAutoAlign} /* ...the usual props */ />
175
181
  ```
176
182
 
177
- It is called as `useColorPickerAlign(open, contentWidth, contentHeight)` - `open` is whether the swatch
178
- popover is showing, and the size is what `InlineColorPicker` itself computes (`@tastic/hud`'s
179
- `getInlineColorPickerContentSize` for the default swatch list at the raw window width) - and must return a
183
+ It is called as `useColorPickerAlign(open, contentWidth, contentHeight, column)` - `open` is whether the swatch
184
+ popover is showing, the size is what `InlineColorPicker` itself computes (`@tastic/hud`'s
185
+ `getInlineColorPickerContentSize` for the default swatch list at the raw window width), and `column` is a ref
186
+ to the roster's scroll view, which clips the popover at its edges: measure it to keep the popover inside a
187
+ roster narrower than the frame (a column capped in a wide window) - and must return a
180
188
  `ProfilePickerAlignResult`, which is handed to `InlineColorPicker` as its `alignOverride` (its `triggerRef`
181
189
  is the ref that gets measured). It is a hook, not a plain function: give it a stable identity for the life of
182
190
  the mount (a module-level function), since switching it at runtime remounts the swatch. Omit it and behaviour is
183
- identical to before. **Needs `@tastic/hud` >= 0.12.0** to have any effect - older hud has no `alignOverride`
184
- on `InlineColorPicker` and ignores it.
191
+ identical to before, and without a hook the popover is kept inside that column for you (`InlineColorPicker`'s
192
+ `bounds`, `@tastic/hud` 0.25.0+).
185
193
 
186
194
  ### Routed screens: `ProfilesScreen`
187
195
 
@@ -327,9 +335,10 @@ const renderSwatch: ProfileSwatchRenderer = (color, size, place) => (place === '
327
335
  ```
328
336
 
329
337
  (`TriadPie` is your own component, e.g. three `react-native-svg` arcs.) Give it a stable identity
330
- (module level, or `useCallback`). Rows that fill with a color (a selected picker row) stay flat, and
331
- so do `@tastic/hud`'s `InlineColorPicker` swatches in `ProfilesManager`'s edit row, which don't read
332
- this provider. `useProfileSwatchRenderer()` returns it (or `undefined`).
338
+ (module level, or `useCallback`). `ProfilesManager`'s edit row hands it to `@tastic/hud`'s
339
+ `InlineColorPicker` too: its trigger (the profile's color with its tag on it) as a `'chip'`, its grid
340
+ as the `'colorPage'`. Rows that fill with a color (a selected picker row) stay flat.
341
+ `useProfileSwatchRenderer()` returns it (or `undefined`).
333
342
 
334
343
  ## Per-seat color persistence
335
344
 
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { RefObject, ReactNode, Ref } from 'react';
3
- import { PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
4
- import { View, StyleProp, ViewStyle } from 'react-native';
3
+ import { ViewInstance, PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
4
+ import { StyleProp, ViewStyle } from 'react-native';
5
5
  import { SeedColor } from '@rific/auto-paper';
6
6
 
7
7
  interface ProfilePickerColorSection {
@@ -19,7 +19,7 @@ interface ProfilePickerAlignResult {
19
19
  verticalAlign: 'below' | 'above';
20
20
  maxHeight: number;
21
21
  measured: boolean;
22
- triggerRef: RefObject<View | null>;
22
+ triggerRef: RefObject<ViewInstance | null>;
23
23
  }
24
24
  interface ProfilePickerContentSize {
25
25
  width: number;
@@ -123,7 +123,7 @@ interface ProfilesManagerProps {
123
123
  fgMuted?: string;
124
124
  titleVariant?: MD3TextVariant$1;
125
125
  colorPreview?: (hex: string) => string;
126
- useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
126
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
127
127
  }
128
128
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
129
129
 
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { RefObject, ReactNode, Ref } from 'react';
3
- import { PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
4
- import { View, StyleProp, ViewStyle } from 'react-native';
3
+ import { ViewInstance, PopoverHost, PopoverRotation } from '@tastic/hud/chrome';
4
+ import { StyleProp, ViewStyle } from 'react-native';
5
5
  import { SeedColor } from '@rific/auto-paper';
6
6
 
7
7
  interface ProfilePickerColorSection {
@@ -19,7 +19,7 @@ interface ProfilePickerAlignResult {
19
19
  verticalAlign: 'below' | 'above';
20
20
  maxHeight: number;
21
21
  measured: boolean;
22
- triggerRef: RefObject<View | null>;
22
+ triggerRef: RefObject<ViewInstance | null>;
23
23
  }
24
24
  interface ProfilePickerContentSize {
25
25
  width: number;
@@ -123,7 +123,7 @@ interface ProfilesManagerProps {
123
123
  fgMuted?: string;
124
124
  titleVariant?: MD3TextVariant$1;
125
125
  colorPreview?: (hex: string) => string;
126
- useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
126
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
127
127
  }
128
128
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
129
129
 
package/dist/index.js CHANGED
@@ -138,6 +138,12 @@ var MENU_BORDER_WIDTH = 2;
138
138
  var ROW_INSET = 12;
139
139
  var CHIP_SIZE = 32;
140
140
  var CHIP_TRIGGER_PADDING = 6;
141
+ var CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8;
142
+ var CHIP_TRIGGER_GAP = 8;
143
+ var PLAIN_TRIGGER_GAP = 2;
144
+ var CHEVRON_SIZE = 16;
145
+ var CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING;
146
+ var PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE;
141
147
  var SWATCH_SIZE = 28;
142
148
  var SWATCH_MIN_GAP = 6;
143
149
  var SWATCH_RING_WIDTH = 2;
@@ -182,11 +188,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
182
188
  };
183
189
  const triggerLabel = label.toUpperCase();
184
190
  const showFooter = !!footer && !showColorPage;
185
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.anchor, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
186
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press.TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
191
+ const flatTriggerStyle = import_react_native3.StyleSheet.flatten(triggerStyle) ?? {};
192
+ const { flexShrink, ...pillStyle } = flatTriggerStyle;
193
+ const shrinks = typeof flexShrink === "number" && flexShrink > 0;
194
+ const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : void 0;
195
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.anchor, anchorShrink, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
196
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_feedback_press.TouchableRipple, { onPress: handleTriggerPress, borderless: chipTrigger, style: [styles3.trigger, chipTrigger && styles3.chipTrigger, shrinks && styles3.triggerShrinks, shrinks ? pillStyle : triggerStyle], accessibilityRole: "button", accessibilityLabel: triggerLabel, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { ref: triggerRef, collapsable: false, style: chipTrigger ? styles3.chipTriggerInner : styles3.triggerInner, children: [
187
197
  chip && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
188
198
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Text, { style: [styles3.triggerLabel, { color: chipTrigger ? fg : color }], numberOfLines: 1, children: triggerLabel }),
189
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: 16, color: chipTrigger ? fg : color })
199
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: CHEVRON_SIZE, color: chipTrigger ? fg : color })
190
200
  ] }) }),
191
201
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_chrome.PopoverBody, { visible: open && measured, align, verticalAlign, caretColor: menuBg, caretBorderColor: color, caretBorderWidth: MENU_BORDER_WIDTH, triggerSize: chipTrigger ? triggerWidth : TRIGGER_HEIGHT, children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
192
202
  import_react_native3.View,
@@ -304,10 +314,10 @@ var styles3 = import_react_native3.StyleSheet.create({
304
314
  chipTriggerInner: {
305
315
  alignItems: "center",
306
316
  flexDirection: "row",
307
- gap: 8,
317
+ gap: CHIP_TRIGGER_GAP,
308
318
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
309
319
  paddingLeft: CHIP_TRIGGER_PADDING,
310
- paddingRight: CHIP_TRIGGER_PADDING + 8
320
+ paddingRight: CHIP_TRIGGER_END_PADDING
311
321
  },
312
322
  colorRowCaption: {
313
323
  fontSize: 12,
@@ -416,13 +426,22 @@ var styles3 = import_react_native3.StyleSheet.create({
416
426
  triggerInner: {
417
427
  alignItems: "center",
418
428
  flexDirection: "row",
419
- gap: 2,
429
+ gap: PLAIN_TRIGGER_GAP,
420
430
  height: TRIGGER_HEIGHT
421
431
  },
432
+ // Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
433
+ // With room it keeps its natural width, so this changes nothing there.
422
434
  triggerLabel: {
435
+ flexShrink: 1,
423
436
  fontSize: 13,
424
437
  fontWeight: "700",
425
- letterSpacing: 1
438
+ letterSpacing: 1,
439
+ minWidth: 0
440
+ },
441
+ // A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
442
+ // width, which is what cuts the label off.
443
+ triggerShrinks: {
444
+ maxWidth: "100%"
426
445
  }
427
446
  });
428
447
 
@@ -571,6 +590,7 @@ function isValidProfile(value) {
571
590
  // src/ProfilesManager.tsx
572
591
  var import_jsx_runtime8 = require("react/jsx-runtime");
573
592
  var CHIP_SIZE2 = 40;
593
+ var textInputRef = (ref) => ref;
574
594
  function deriveTag(name) {
575
595
  const trimmed = name.trim();
576
596
  return trimmed ? trimmed[0].toUpperCase() : "";
@@ -581,6 +601,8 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
581
601
  const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
582
602
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
583
603
  const { error: showErrorToast } = (0, import_toaster.useToast)();
604
+ const columnRef = (0, import_react4.useRef)(null);
605
+ const renderSwatch = useProfileSwatchRenderer();
584
606
  const host = (0, import_chrome2.usePopoverHost)();
585
607
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
586
608
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
@@ -651,7 +673,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
651
673
  headerLeft,
652
674
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
653
675
  ] }),
654
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.KeyboardAvoidingView, { behavior: import_react_native5.Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_auto_paper4.FadingScrollView, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
676
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.KeyboardAvoidingView, { behavior: import_react_native5.Platform.OS === "ios" ? "padding" : void 0, style: styles5.keyboardAvoiding, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native5.View, { ref: columnRef, collapsable: false, style: styles5.column, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_auto_paper4.FadingScrollView, { style: styles5.scrollView, contentContainerStyle: [styles5.content, { paddingBottom: 32 + insets.bottom }], showsVerticalScrollIndicator: false, keyboardShouldPersistTaps: "handled", children: rows.map((row) => {
655
677
  if (row.kind === "new") {
656
678
  if (editingId !== "new") {
657
679
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: () => startEdit("new"), style: styles5.row, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.rowInner, children: [
@@ -659,27 +681,28 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
659
681
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
660
682
  ] }) }, "new");
661
683
  }
662
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign }, "new");
684
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark, colorPreview, useColorPickerAlign, column: columnRef, renderSwatch }, "new");
663
685
  }
664
686
  const { profile } = row;
665
687
  if (editingId === profile.id) {
666
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark, colorPreview, useColorPickerAlign }, profile.id);
688
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(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);
667
689
  }
668
690
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: () => startEdit(profile), style: styles5.row, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.rowInner, children: [
669
691
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ProfileChip, { profile, size: CHIP_SIZE2 }),
670
692
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
671
693
  ] }) }, profile.id);
672
- }) }) }),
694
+ }) }) }) }),
673
695
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.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) })
674
696
  ] });
675
697
  }
676
- function AlignedColorPicker({ useAlign, ...props }) {
698
+ function AlignedColorPicker({ useAlign, column, ...props }) {
677
699
  const { width: windowWidth } = (0, import_react_native5.useWindowDimensions)();
678
700
  const { width, height } = (0, import_chrome2.getInlineColorPickerContentSize)(import_auto_paper4.defaultColors.length, windowWidth);
679
- const alignOverride = useAlign(props.host.openId === props.id, width, height);
701
+ const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
680
702
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { ...props, alignOverride });
681
703
  }
682
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
704
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }) {
705
+ const pickerSwatch = (0, import_react4.useMemo)(() => renderSwatch ? (color, size, place) => renderSwatch(color, size, place === "trigger" ? "chip" : "colorPage") : void 0, [renderSwatch]);
683
706
  const register = (0, import_focus_chain.useFocusChain)();
684
707
  const tag = register();
685
708
  const name = register();
@@ -691,13 +714,17 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
691
714
  if (host.openId === "color") tagFocusRef.current();
692
715
  }, [host.openId]);
693
716
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: styles5.editRow, children: [
694
- useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark }),
695
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
696
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.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 }),
717
+ useColorPickerAlign ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(AlignedColorPicker, { useAlign: useColorPickerAlign, column, id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch }) : /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, previewValue: colorPreview, tag: draftTag, dark, renderSwatch: pickerSwatch, bounds: column }),
718
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.TextInput, { ref: textInputRef(tag.ref), ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles5.hiddenTagInput, pointerEvents: "none" }),
719
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.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 }),
697
720
  onDelete && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_feedback_press3.TouchableRipple, { onPress: onDelete, style: styles5.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
698
721
  ] });
699
722
  }
700
723
  var styles5 = import_react_native5.StyleSheet.create({
724
+ // The roster's column, around the scroll view (see columnRef).
725
+ column: {
726
+ flex: 1
727
+ },
701
728
  content: {
702
729
  gap: 4,
703
730
  paddingHorizontal: 20
@@ -971,10 +998,29 @@ async function readSharedProfiles(groupId) {
971
998
  async function loadSharedProfiles(groupId) {
972
999
  return await readSharedProfiles(groupId) ?? [];
973
1000
  }
1001
+ var hasId = (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.id !== "";
1002
+ async function unreadableEntries(groupId, profiles) {
1003
+ let json;
1004
+ try {
1005
+ json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY);
1006
+ } catch {
1007
+ return [];
1008
+ }
1009
+ if (!json) return [];
1010
+ try {
1011
+ const parsed = JSON.parse(json);
1012
+ if (!Array.isArray(parsed)) return [];
1013
+ const saved = new Set(profiles.map((profile) => profile.id));
1014
+ return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id));
1015
+ } catch {
1016
+ return [];
1017
+ }
1018
+ }
974
1019
  async function saveSharedProfiles(groupId, profiles) {
975
1020
  if (!native) return;
976
1021
  try {
977
- await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
1022
+ const kept = await unreadableEntries(groupId, profiles);
1023
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]));
978
1024
  } catch {
979
1025
  }
980
1026
  }
package/dist/index.mjs CHANGED
@@ -90,6 +90,12 @@ var MENU_BORDER_WIDTH = 2;
90
90
  var ROW_INSET = 12;
91
91
  var CHIP_SIZE = 32;
92
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;
93
99
  var SWATCH_SIZE = 28;
94
100
  var SWATCH_MIN_GAP = 6;
95
101
  var SWATCH_RING_WIDTH = 2;
@@ -134,11 +140,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
134
140
  };
135
141
  const triggerLabel = label.toUpperCase();
136
142
  const showFooter = !!footer && !showColorPage;
137
- return /* @__PURE__ */ jsxs2(View3, { style: [styles3.anchor, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
138
- /* @__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: [
139
149
  chip && /* @__PURE__ */ jsx4(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
140
150
  /* @__PURE__ */ jsx4(Text2, { style: [styles3.triggerLabel, { color: chipTrigger ? fg : color }], numberOfLines: 1, children: triggerLabel }),
141
- /* @__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 })
142
152
  ] }) }),
143
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(
144
154
  View3,
@@ -256,10 +266,10 @@ var styles3 = StyleSheet3.create({
256
266
  chipTriggerInner: {
257
267
  alignItems: "center",
258
268
  flexDirection: "row",
259
- gap: 8,
269
+ gap: CHIP_TRIGGER_GAP,
260
270
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
261
271
  paddingLeft: CHIP_TRIGGER_PADDING,
262
- paddingRight: CHIP_TRIGGER_PADDING + 8
272
+ paddingRight: CHIP_TRIGGER_END_PADDING
263
273
  },
264
274
  colorRowCaption: {
265
275
  fontSize: 12,
@@ -368,13 +378,22 @@ var styles3 = StyleSheet3.create({
368
378
  triggerInner: {
369
379
  alignItems: "center",
370
380
  flexDirection: "row",
371
- gap: 2,
381
+ gap: PLAIN_TRIGGER_GAP,
372
382
  height: TRIGGER_HEIGHT
373
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.
374
386
  triggerLabel: {
387
+ flexShrink: 1,
375
388
  fontSize: 13,
376
389
  fontWeight: "700",
377
- 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%"
378
397
  }
379
398
  });
380
399
 
@@ -523,6 +542,7 @@ function isValidProfile(value) {
523
542
  // src/ProfilesManager.tsx
524
543
  import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
525
544
  var CHIP_SIZE2 = 40;
545
+ var textInputRef = (ref) => ref;
526
546
  function deriveTag(name) {
527
547
  const trimmed = name.trim();
528
548
  return trimmed ? trimmed[0].toUpperCase() : "";
@@ -533,6 +553,8 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
533
553
  const { dark } = useAutoPaperTheme();
534
554
  const insets = useSafeAreaInsets();
535
555
  const { error: showErrorToast } = useToast();
556
+ const columnRef = useRef(null);
557
+ const renderSwatch = useProfileSwatchRenderer();
536
558
  const host = usePopoverHost();
537
559
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
538
560
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
@@ -603,7 +625,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
603
625
  headerLeft,
604
626
  /* @__PURE__ */ jsx8(Text4, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
605
627
  ] }),
606
- /* @__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) => {
607
629
  if (row.kind === "new") {
608
630
  if (editingId !== "new") {
609
631
  return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit("new"), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
@@ -611,27 +633,28 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
611
633
  /* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
612
634
  ] }) }, "new");
613
635
  }
614
- 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");
615
637
  }
616
638
  const { profile } = row;
617
639
  if (editingId === profile.id) {
618
- 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);
619
641
  }
620
642
  return /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: () => startEdit(profile), style: styles5.row, children: /* @__PURE__ */ jsxs4(View5, { style: styles5.rowInner, children: [
621
643
  /* @__PURE__ */ jsx8(ProfileChip, { profile, size: CHIP_SIZE2 }),
622
644
  /* @__PURE__ */ jsx8(Text4, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
623
645
  ] }) }, profile.id);
624
- }) }) }),
646
+ }) }) }) }),
625
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) })
626
648
  ] });
627
649
  }
628
- function AlignedColorPicker({ useAlign, ...props }) {
650
+ function AlignedColorPicker({ useAlign, column, ...props }) {
629
651
  const { width: windowWidth } = useWindowDimensions();
630
652
  const { width, height } = getInlineColorPickerContentSize(defaultColors2.length, windowWidth);
631
- const alignOverride = useAlign(props.host.openId === props.id, width, height);
653
+ const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
632
654
  return /* @__PURE__ */ jsx8(InlineColorPicker, { ...props, alignOverride });
633
655
  }
634
- 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]);
635
658
  const register = useFocusChain();
636
659
  const tag = register();
637
660
  const name = register();
@@ -643,13 +666,17 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
643
666
  if (host.openId === "color") tagFocusRef.current();
644
667
  }, [host.openId]);
645
668
  return /* @__PURE__ */ jsxs4(View5, { style: styles5.editRow, children: [
646
- 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 }),
647
- /* @__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" }),
648
- /* @__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 }),
649
672
  onDelete && /* @__PURE__ */ jsx8(TouchableRipple3, { onPress: onDelete, style: styles5.iconButton, children: /* @__PURE__ */ jsx8(Icon4, { source: "trash-can-outline", size: 18, color: fgMuted }) })
650
673
  ] });
651
674
  }
652
675
  var styles5 = StyleSheet5.create({
676
+ // The roster's column, around the scroll view (see columnRef).
677
+ column: {
678
+ flex: 1
679
+ },
653
680
  content: {
654
681
  gap: 4,
655
682
  paddingHorizontal: 20
@@ -923,10 +950,29 @@ async function readSharedProfiles(groupId) {
923
950
  async function loadSharedProfiles(groupId) {
924
951
  return await readSharedProfiles(groupId) ?? [];
925
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
+ }
926
971
  async function saveSharedProfiles(groupId, profiles) {
927
972
  if (!native) return;
928
973
  try {
929
- 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]));
930
976
  } catch {
931
977
  }
932
978
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.16.2",
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",
@@ -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'
@@ -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
 
@@ -395,10 +418,10 @@ const styles = StyleSheet.create({
395
418
  chipTriggerInner: {
396
419
  alignItems: 'center',
397
420
  flexDirection: 'row',
398
- gap: 8,
421
+ gap: CHIP_TRIGGER_GAP,
399
422
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
400
423
  paddingLeft: CHIP_TRIGGER_PADDING,
401
- paddingRight: CHIP_TRIGGER_PADDING + 8
424
+ paddingRight: CHIP_TRIGGER_END_PADDING
402
425
  },
403
426
  colorRowCaption: {
404
427
  fontSize: 12,
@@ -507,12 +530,21 @@ const styles = StyleSheet.create({
507
530
  triggerInner: {
508
531
  alignItems: 'center',
509
532
  flexDirection: 'row',
510
- gap: 2,
533
+ gap: PLAIN_TRIGGER_GAP,
511
534
  height: TRIGGER_HEIGHT
512
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.
513
538
  triggerLabel: {
539
+ flexShrink: 1,
514
540
  fontSize: 13,
515
541
  fontWeight: '700',
516
- 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%'
517
549
  }
518
550
  })
@@ -29,8 +29,8 @@ interface ProfileColorProviderProps {
29
29
  // where it is (ProfileSwatchPlace) and kept flat where it returns null. Hangman draws a pie of each
30
30
  // color's triad on the Color row and page only. The renderer is drawn behind the swatch's mark,
31
31
  // clipped to the circle.
32
- // Rows that fill with a color (a selected picker row) stay flat, and so does hud's InlineColorPicker
33
- // (ProfilesManager's color trigger and swatches), which doesn't read this context.
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.
34
34
  renderSwatch?: ProfileSwatchRenderer
35
35
  children: ReactNode
36
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
 
@@ -2,15 +2,15 @@ import { defaultColors, FadingScrollView, useAutoPaperTheme } from '@rific/auto-
2
2
  import { TouchableRipple } from '@rific/feedback-press'
3
3
  import { useFocusChain } from '@rific/focus-chain'
4
4
  import { useToast } from '@rific/toaster'
5
- import type { PopoverHost } from '@tastic/hud/chrome'
5
+ import type { InlineColorPickerSwatchRenderer, PopoverHost, ViewInstance } from '@tastic/hud/chrome'
6
6
  import { ConfirmDialog, getInlineColorPickerContentSize, InlineColorPicker, usePopoverHost } from '@tastic/hud/chrome'
7
- import { ComponentProps, ReactNode, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
7
+ import { ComponentProps, ReactNode, Ref, RefObject, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
8
8
  import { KeyboardAvoidingView, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
9
9
  import { Icon, Text, TextInput } from 'react-native-paper'
10
10
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
11
11
 
12
12
  import { ProfileChip } from './ProfileChip'
13
- import { useProfileColorPreview } from './ProfileColorContext'
13
+ import { type ProfileSwatchRenderer, useProfileColorPreview, useProfileSwatchRenderer } from './ProfileColorContext'
14
14
  import { type ProfilePickerAlignResult } from './ProfilePicker'
15
15
  import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
16
16
  import { Profile } from './types'
@@ -88,12 +88,21 @@ export interface ProfilesManagerProps {
88
88
  // measured). It is a hook, not a plain function: give it a STABLE identity for the life of the mount (a
89
89
  // module-level function) - switching it at runtime remounts the swatch. Omit for the ordinary case;
90
90
  // behaviour is then identical to before. Needs @tastic/hud >= 0.12.0 to have any effect (older hud has no
91
- // `alignOverride` and ignores it).
92
- useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult
91
+ // `alignOverride` and ignores it). Its fourth argument, `column`, is the roster's scroll view: the popover
92
+ // opens inside it and is clipped at its edges, so where it's narrower than the frame (a column capped in a
93
+ // wide window) the placement should keep the popover inside it. A hook that measures it never has to be
94
+ // told the column's width. A hook that ignores it behaves as before.
95
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult
93
96
  }
94
97
 
95
98
  type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
96
99
 
100
+ // A focus-chain ref as a Paper TextInput takes it. Paper types its input's handle as
101
+ // Pick<TextInput, 'focus' | ...>, which React Native 0.88's strict types read as `focus: unknown`
102
+ // (TextInput is a function there, not a class), so focus-chain's ref, which wants `focus(): void`,
103
+ // no longer fits the prop. The handle does have focus() at run time.
104
+ const textInputRef = (ref: (el: { focus: () => void } | null) => void) => ref as (el: unknown) => void
105
+
97
106
  // First letter of the name, uppercased - the tag's own default for as long as it hasn't been typed
98
107
  // into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
99
108
  // nothing into the tag field yourself would leave it.
@@ -120,6 +129,11 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
120
129
  const { dark } = useAutoPaperTheme()
121
130
  const insets = useSafeAreaInsets()
122
131
  const { error: showErrorToast } = useToast()
132
+ // The roster's scroll view, which clips the color popover: the color picker keeps its popover
133
+ // inside it (InlineColorPicker's `bounds`), and a host's useColorPickerAlign is handed it.
134
+ const columnRef = useRef<ViewInstance>(null)
135
+ // ProfileColorProvider's swatch renderer, for the color picker's trigger and swatches.
136
+ const renderSwatch = useProfileSwatchRenderer()
123
137
  // Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
124
138
  // so every EditRow instance can safely share this one host instead of each needing its own.
125
139
  const host = usePopoverHost()
@@ -247,46 +261,50 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
247
261
  fade out at an edge while there is more past it, the same cue as the Settings sheet and the
248
262
  Achievements screen (Jay, 2026-10-16: every scrolling screen alike). */}
249
263
  <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
250
- <FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
251
- {rows.map((row) => {
252
- if (row.kind === 'new') {
253
- if (editingId !== 'new') {
254
- return (
255
- <TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
256
- <View style={styles.rowInner}>
257
- <View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
258
- <Icon source='plus' size={20} color={fg} />
264
+ {/* A plain View around the scroll view, the same size, for the column the color popover
265
+ is kept inside (see columnRef): the scroll view takes no ref. */}
266
+ <View ref={columnRef} collapsable={false} style={styles.column}>
267
+ <FadingScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
268
+ {rows.map((row) => {
269
+ if (row.kind === 'new') {
270
+ if (editingId !== 'new') {
271
+ return (
272
+ <TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
273
+ <View style={styles.rowInner}>
274
+ <View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
275
+ <Icon source='plus' size={20} color={fg} />
276
+ </View>
277
+ <Text variant='bodyLarge' style={{ color: fg }}>
278
+ New Profile
279
+ </Text>
259
280
  </View>
260
- <Text variant='bodyLarge' style={{ color: fg }}>
261
- New Profile
262
- </Text>
263
- </View>
264
- </TouchableRipple>
265
- )
281
+ </TouchableRipple>
282
+ )
283
+ }
284
+ return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} column={columnRef} renderSwatch={renderSwatch} />
266
285
  }
267
- return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
268
- }
269
286
 
270
- const { profile } = row
271
- if (editingId === profile.id) {
272
- return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} />
273
- }
287
+ const { profile } = row
288
+ if (editingId === profile.id) {
289
+ return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} colorPreview={colorPreview} useColorPickerAlign={useColorPickerAlign} column={columnRef} renderSwatch={renderSwatch} />
290
+ }
274
291
 
275
- // No delete affordance at rest - tap the row to start editing, which is the one place
276
- // delete shows (see EditRow): a deliberate extra step before a destructive action is
277
- // even reachable, not just before it's confirmed.
278
- return (
279
- <TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
280
- <View style={styles.rowInner}>
281
- <ProfileChip profile={profile} size={CHIP_SIZE} />
282
- <Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
283
- {profile.name}
284
- </Text>
285
- </View>
286
- </TouchableRipple>
287
- )
288
- })}
289
- </FadingScrollView>
292
+ // No delete affordance at rest - tap the row to start editing, which is the one place
293
+ // delete shows (see EditRow): a deliberate extra step before a destructive action is
294
+ // even reachable, not just before it's confirmed.
295
+ return (
296
+ <TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
297
+ <View style={styles.rowInner}>
298
+ <ProfileChip profile={profile} size={CHIP_SIZE} />
299
+ <Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
300
+ {profile.name}
301
+ </Text>
302
+ </View>
303
+ </TouchableRipple>
304
+ )
305
+ })}
306
+ </FadingScrollView>
307
+ </View>
290
308
  </KeyboardAvoidingView>
291
309
 
292
310
  {/* hud's ConfirmDialog, the one confirm card every app in the fleet uses, so this reads like the
@@ -314,6 +332,8 @@ interface EditRowProps {
314
332
  dark: boolean
315
333
  colorPreview?: (hex: string) => string
316
334
  useColorPickerAlign?: ProfilesManagerProps['useColorPickerAlign']
335
+ column: RefObject<ViewInstance | null>
336
+ renderSwatch?: ProfileSwatchRenderer
317
337
  }
318
338
 
319
339
  // InlineColorPicker with its placement supplied by a host hook (see ProfilesManagerProps.useColorPickerAlign).
@@ -321,10 +341,10 @@ interface EditRowProps {
321
341
  // unconditionally here, and the plain path below stays byte-identical to what it was. Sized exactly as
322
342
  // InlineColorPicker sizes itself (the default swatch list at the raw window width), so the host's placement and
323
343
  // the popover it positions agree on its footprint.
324
- function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']> }) {
344
+ function AlignedColorPicker({ useAlign, column, ...props }: ComponentProps<typeof InlineColorPicker> & { useAlign: NonNullable<ProfilesManagerProps['useColorPickerAlign']>; column: EditRowProps['column'] }) {
325
345
  const { width: windowWidth } = useWindowDimensions()
326
346
  const { width, height } = getInlineColorPickerContentSize(defaultColors.length, windowWidth)
327
- const alignOverride = useAlign(props.host.openId === props.id, width, height)
347
+ const alignOverride = useAlign(props.host.openId === props.id, width, height, column)
328
348
  return <InlineColorPicker {...props} alignOverride={alignOverride} />
329
349
  }
330
350
 
@@ -340,7 +360,11 @@ function AlignedColorPicker({ useAlign, ...props }: ComponentProps<typeof Inline
340
360
  // own - submitting the *name* field is what actually commits the row, so its registration's own
341
361
  // onSubmitEditing (a no-op - it's last in the chain) is overridden with onSubmit below, matching
342
362
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
343
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }: EditRowProps) {
363
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }: EditRowProps) {
364
+ // ProfileColorProvider's renderer in the color picker: its trigger is the profile's color with its
365
+ // tag on it, an identity like a chip, and its grid is where a color is chosen, like a picker's color
366
+ // page (Hangman draws its triad pies on the grid only).
367
+ const pickerSwatch = useMemo<InlineColorPickerSwatchRenderer | undefined>(() => (renderSwatch ? (color, size, place) => renderSwatch(color, size, place === 'trigger' ? 'chip' : 'colorPage') : undefined), [renderSwatch])
344
368
  const register = useFocusChain()
345
369
  const tag = register()
346
370
  const name = register()
@@ -367,7 +391,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
367
391
 
368
392
  return (
369
393
  <View style={styles.editRow}>
370
- {useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} />}
394
+ {useColorPickerAlign ? <AlignedColorPicker useAlign={useColorPickerAlign} column={column} id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} /> : <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} previewValue={colorPreview} tag={draftTag} dark={dark} renderSwatch={pickerSwatch} bounds={column} />}
371
395
  {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
372
396
  doesn't reserve space in the row's own flex layout) - the swatch above is the only place this
373
397
  value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
@@ -379,12 +403,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
379
403
  find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
380
404
  so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
381
405
  inserting mid-string. */}
382
- {/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
383
- register(), called during render by design (see the hook's own doc); the actual DOM/native
384
- focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
385
- <TextInput ref={tag.ref} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
386
- {/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
387
- <TextInput ref={name.ref} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
406
+ {}
407
+ <TextInput ref={textInputRef(tag.ref)} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
408
+ {}
409
+ <TextInput ref={textInputRef(name.ref)} {...name.props} onSubmitEditing={onSubmit} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} />
388
410
  {onDelete && (
389
411
  <TouchableRipple onPress={onDelete} style={styles.iconButton}>
390
412
  <Icon source='trash-can-outline' size={18} color={fgMuted} />
@@ -395,6 +417,10 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
395
417
  }
396
418
 
397
419
  const styles = StyleSheet.create({
420
+ // The roster's column, around the scroll view (see columnRef).
421
+ column: {
422
+ flex: 1
423
+ },
398
424
  content: {
399
425
  gap: 4,
400
426
  paddingHorizontal: 20
@@ -122,15 +122,45 @@ export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
122
122
  return (await readSharedProfiles(groupId)) ?? []
123
123
  }
124
124
 
125
+ const hasId = (entry: unknown): entry is { id: string } => typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string' && (entry as { id: string }).id !== ''
126
+
127
+ // The stored roster's entries this version can't read (they fail isValidProfile: a newer app's
128
+ // profile under a rule loosened since, say) and that aren't in `profiles`, so saveSharedProfiles can
129
+ // write them back as they are. This app never had them in its roster, so a save that left them out
130
+ // would delete them for every app in the group. Only entries with an id are kept (anything else is
131
+ // unusable to every app). [] when the stored roster can't be read at all. The web store is read
132
+ // directly, past its failed-write latch: that latch is there so a stale roster can't replace a
133
+ // local one, and nothing read here replaces anything.
134
+ async function unreadableEntries(groupId: string, profiles: Profile[]): Promise<unknown[]> {
135
+ let json: string | null | undefined
136
+ try {
137
+ json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY)
138
+ } catch {
139
+ return []
140
+ }
141
+ if (!json) return []
142
+ try {
143
+ const parsed: unknown = JSON.parse(json)
144
+ if (!Array.isArray(parsed)) return []
145
+ const saved = new Set(profiles.map((profile) => profile.id))
146
+ return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id))
147
+ } catch {
148
+ return []
149
+ }
150
+ }
151
+
125
152
  // Overwrites the entire shared roster - same whole-blob-write discipline a host app's own
126
- // AsyncStorage-backed profile store already uses, not an incremental per-profile update. A write
127
- // failure (store unavailable, or some other native error) is swallowed rather than thrown: the
128
- // caller's own local state is already the source of truth for what just changed, and this is a
129
- // best-effort mirror of it, not the other way around.
153
+ // AsyncStorage-backed profile store already uses, not an incremental per-profile update - except
154
+ // for the stored entries this version can't read, which it keeps (see unreadableEntries), after
155
+ // `profiles`. A write failure (store unavailable, or some other native error) is swallowed rather
156
+ // than thrown: the caller's own local state is already the source of truth for what just changed,
157
+ // and this is a best-effort mirror of it, not the other way around. A failed read before it writes
158
+ // `profiles` alone, as every save did before entries were kept.
130
159
  export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
131
160
  if (!native) return
132
161
  try {
133
- await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles))
162
+ const kept = await unreadableEntries(groupId, profiles)
163
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]))
134
164
  } catch {
135
165
  // Best-effort - see doc comment above.
136
166
  }