@tastic/profile 0.16.2 → 0.18.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;
@@ -31,8 +31,9 @@ interface OptionPickerOption<T extends string> {
31
31
  label: string;
32
32
  icon?: string;
33
33
  color?: string;
34
+ dividerBefore?: boolean;
34
35
  }
35
- declare function getOptionPickerContentSize(optionCount: number, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize;
36
+ declare function getOptionPickerContentSize(options: number | readonly OptionPickerOption<string>[], colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize;
36
37
  interface OptionPickerProps<T extends string> {
37
38
  id: string;
38
39
  host: PopoverHost;
@@ -123,7 +124,7 @@ interface ProfilesManagerProps {
123
124
  fgMuted?: string;
124
125
  titleVariant?: MD3TextVariant$1;
125
126
  colorPreview?: (hex: string) => string;
126
- useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
127
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
127
128
  }
128
129
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
129
130
 
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;
@@ -31,8 +31,9 @@ interface OptionPickerOption<T extends string> {
31
31
  label: string;
32
32
  icon?: string;
33
33
  color?: string;
34
+ dividerBefore?: boolean;
34
35
  }
35
- declare function getOptionPickerContentSize(optionCount: number, colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize;
36
+ declare function getOptionPickerContentSize(options: number | readonly OptionPickerOption<string>[], colorSection?: Pick<ProfilePickerColorSection, 'swatches' | 'caption'>): ProfilePickerContentSize;
36
37
  interface OptionPickerProps<T extends string> {
37
38
  id: string;
38
39
  host: PopoverHost;
@@ -123,7 +124,7 @@ interface ProfilesManagerProps {
123
124
  fgMuted?: string;
124
125
  titleVariant?: MD3TextVariant$1;
125
126
  colorPreview?: (hex: string) => string;
126
- useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number) => ProfilePickerAlignResult;
127
+ useColorPickerAlign?: (open: boolean, contentWidth: number, contentHeight: number, column: RefObject<ViewInstance | null>) => ProfilePickerAlignResult;
127
128
  }
128
129
  declare function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft, ref, fg: fgOverride, fgMuted: fgMutedOverride, titleVariant, colorPreview: colorPreviewProp, useColorPickerAlign }: ProfilesManagerProps): react.JSX.Element;
129
130
 
package/dist/index.js CHANGED
@@ -53,6 +53,9 @@ __export(index_exports, {
53
53
  });
54
54
  module.exports = __toCommonJS(index_exports);
55
55
 
56
+ // src/OptionPicker.tsx
57
+ var import_react3 = require("react");
58
+
56
59
  // src/PickerMenu.tsx
57
60
  var import_auto_paper2 = require("@rific/auto-paper");
58
61
  var import_feedback_press = require("@rific/feedback-press");
@@ -138,6 +141,12 @@ var MENU_BORDER_WIDTH = 2;
138
141
  var ROW_INSET = 12;
139
142
  var CHIP_SIZE = 32;
140
143
  var CHIP_TRIGGER_PADDING = 6;
144
+ var CHIP_TRIGGER_END_PADDING = CHIP_TRIGGER_PADDING + 8;
145
+ var CHIP_TRIGGER_GAP = 8;
146
+ var PLAIN_TRIGGER_GAP = 2;
147
+ var CHEVRON_SIZE = 16;
148
+ var CHIP_TRIGGER_FIXED_WIDTH = CHIP_TRIGGER_PADDING + CHIP_SIZE + 2 * CHIP_TRIGGER_GAP + CHEVRON_SIZE + CHIP_TRIGGER_END_PADDING;
149
+ var PLAIN_TRIGGER_FIXED_WIDTH = PLAIN_TRIGGER_GAP + CHEVRON_SIZE;
141
150
  var SWATCH_SIZE = 28;
142
151
  var SWATCH_MIN_GAP = 6;
143
152
  var SWATCH_RING_WIDTH = 2;
@@ -145,7 +154,7 @@ var COLOR_ROW_SWATCH_SIZE = 22;
145
154
  var COLOR_ROW_CAPTIONED_HEIGHT = 48;
146
155
  var COLOR_LABEL_LINE = 18;
147
156
  var COLOR_CAPTION_LINE = 16;
148
- var COLOR_ROW_DIVIDER = 9;
157
+ var DIVIDER_HEIGHT = 9;
149
158
  var GRID_TOP = 4;
150
159
  var GRID_BOTTOM = 8;
151
160
  var GRID_WIDTH = POPOVER_WIDTH - 2 * MENU_BORDER_WIDTH - 2 * LIST_PADDING - 2 * ROW_INSET;
@@ -158,8 +167,8 @@ function colorPageHeight(swatches) {
158
167
  const rows = Math.ceil(swatches.length / GRID_COLUMNS);
159
168
  return ROW_HEIGHT + GRID_TOP + rows * SWATCH_SIZE + (rows - 1) * GRID_GAP + GRID_BOTTOM;
160
169
  }
161
- function pickerContentSize(rowCount, colorSection) {
162
- const list = rowCount * ROW_HEIGHT + (colorSection ? colorRowHeight(colorSection.caption) + COLOR_ROW_DIVIDER : 0);
170
+ function pickerContentSize(rowCount, colorSection, dividerCount = 0) {
171
+ const list = rowCount * ROW_HEIGHT + dividerCount * DIVIDER_HEIGHT + (colorSection ? colorRowHeight(colorSection.caption) + DIVIDER_HEIGHT : 0);
163
172
  const content = colorSection ? Math.max(list, colorPageHeight(colorSection.swatches ?? import_auto_paper2.defaultColors)) : list;
164
173
  return { width: POPOVER_WIDTH, height: LIST_PADDING * 2 + content };
165
174
  }
@@ -182,11 +191,15 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
182
191
  };
183
192
  const triggerLabel = label.toUpperCase();
184
193
  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: [
194
+ const flatTriggerStyle = import_react_native3.StyleSheet.flatten(triggerStyle) ?? {};
195
+ const { flexShrink, ...pillStyle } = flatTriggerStyle;
196
+ const shrinks = typeof flexShrink === "number" && flexShrink > 0;
197
+ const anchorShrink = shrinks ? { flexShrink, minWidth: chipTrigger ? CHIP_TRIGGER_FIXED_WIDTH : PLAIN_TRIGGER_FIXED_WIDTH } : void 0;
198
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: [styles3.anchor, anchorShrink, open && styles3.anchorOpen], onLayout: handleAnchorLayout, children: [
199
+ /* @__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
200
  chip && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(IdentityChip, { color, tag: chip.tag, icon: chip.icon, size: CHIP_SIZE }),
188
201
  /* @__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 })
202
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "menu-down", size: CHEVRON_SIZE, color: chipTrigger ? fg : color })
190
203
  ] }) }),
191
204
  /* @__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
205
  import_react_native3.View,
@@ -206,7 +219,7 @@ function PickerMenu({ id, host, color, dark, label, chip, colorSection, homeColo
206
219
  children
207
220
  ] }) }, showColorPage ? "color" : "list"),
208
221
  showFooter && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_react_native3.View, { style: styles3.menuFooter, children: [
209
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.View, { style: [styles3.divider, { backgroundColor: color }] }),
222
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MenuDivider, { color }),
210
223
  footer
211
224
  ] })
212
225
  ]
@@ -235,9 +248,12 @@ function ColorRow({ section, fg, dividerColor, onPress }) {
235
248
  ] }),
236
249
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native_paper2.Icon, { source: "chevron-right", size: 18, color: fg })
237
250
  ] }) }),
238
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.View, { style: [styles3.divider, { backgroundColor: dividerColor }] })
251
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MenuDivider, { color: dividerColor })
239
252
  ] });
240
253
  }
254
+ function MenuDivider({ color }) {
255
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(import_react_native3.View, { style: [styles3.divider, { backgroundColor: color }] });
256
+ }
241
257
  function ColorPage({ section, homeColor, fg, onBack }) {
242
258
  const { value, onChange, previewValue, swatches = import_auto_paper2.defaultColors, label = "Color", takenValue, allowSwapTaken } = section;
243
259
  const renderSwatch = useProfileSwatchRenderer();
@@ -304,10 +320,10 @@ var styles3 = import_react_native3.StyleSheet.create({
304
320
  chipTriggerInner: {
305
321
  alignItems: "center",
306
322
  flexDirection: "row",
307
- gap: 8,
323
+ gap: CHIP_TRIGGER_GAP,
308
324
  height: CHIP_SIZE + 2 * CHIP_TRIGGER_PADDING,
309
325
  paddingLeft: CHIP_TRIGGER_PADDING,
310
- paddingRight: CHIP_TRIGGER_PADDING + 8
326
+ paddingRight: CHIP_TRIGGER_END_PADDING
311
327
  },
312
328
  colorRowCaption: {
313
329
  fontSize: 12,
@@ -329,7 +345,8 @@ var styles3 = import_react_native3.StyleSheet.create({
329
345
  flex: 1
330
346
  },
331
347
  // Separates the footer (a navigation action) and the Color row from the selectable rows - a
332
- // different kind of action, not just another row in the same list. Tinted with the seat's own color
348
+ // different kind of action, not just another row in the same list - and an OptionPicker's groups
349
+ // of options from each other (its `dividerBefore`). Tinted with the seat's own color
333
350
  // (passed in as backgroundColor) rather than a flat gray, matching the border/caret's own
334
351
  // accent-tinted look.
335
352
  divider: {
@@ -416,20 +433,33 @@ var styles3 = import_react_native3.StyleSheet.create({
416
433
  triggerInner: {
417
434
  alignItems: "center",
418
435
  flexDirection: "row",
419
- gap: 2,
436
+ gap: PLAIN_TRIGGER_GAP,
420
437
  height: TRIGGER_HEIGHT
421
438
  },
439
+ // Lets the label, alone in its row, give way to a trigger narrower than its name (see `shrinks`).
440
+ // With room it keeps its natural width, so this changes nothing there.
422
441
  triggerLabel: {
442
+ flexShrink: 1,
423
443
  fontSize: 13,
424
444
  fontWeight: "700",
425
- letterSpacing: 1
445
+ letterSpacing: 1,
446
+ minWidth: 0
447
+ },
448
+ // A shrinking trigger (see `shrinks`): no wider than its anchor. Its row is then laid out at that
449
+ // width, which is what cuts the label off.
450
+ triggerShrinks: {
451
+ maxWidth: "100%"
426
452
  }
427
453
  });
428
454
 
429
455
  // src/OptionPicker.tsx
430
456
  var import_jsx_runtime5 = require("react/jsx-runtime");
431
- function getOptionPickerContentSize(optionCount, colorSection) {
432
- return pickerContentSize(optionCount, colorSection);
457
+ function hasDividerBefore(option, index) {
458
+ return index > 0 && !!option.dividerBefore;
459
+ }
460
+ function getOptionPickerContentSize(options, colorSection) {
461
+ if (typeof options === "number") return pickerContentSize(options, colorSection);
462
+ return pickerContentSize(options.length, colorSection, options.filter(hasDividerBefore).length);
433
463
  }
434
464
  function OptionPicker({ id, host, options, value, onChange, color, dark, icon, rowIcon = icon, colorSection, align, alignOverride, rotation = 0, triggerStyle }) {
435
465
  const fg = dark ? "#FFFFFF" : "#000000";
@@ -438,7 +468,10 @@ function OptionPicker({ id, host, options, value, onChange, color, dark, icon, r
438
468
  onChange(option.value);
439
469
  host.close();
440
470
  };
441
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(PickerMenu, { id, host, color, dark, label: selected?.label ?? "", chip: { icon }, colorSection, contentSize: getOptionPickerContentSize(options.length, colorSection), align, alignOverride, rotation, triggerStyle, children: options.map((option) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(IconRow, { icon: option.icon ?? rowIcon, iconColor: option.color, label: option.label, selected: option.value === value, color, fg, onPress: () => handleSelect(option) }, option.value)) });
471
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(PickerMenu, { id, host, color, dark, label: selected?.label ?? "", chip: { icon }, colorSection, contentSize: getOptionPickerContentSize(options, colorSection), align, alignOverride, rotation, triggerStyle, children: options.map((option, index) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_react3.Fragment, { children: [
472
+ hasDividerBefore(option, index) && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(MenuDivider, { color }),
473
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(IconRow, { icon: option.icon ?? rowIcon, iconColor: option.color, label: option.label, selected: option.value === value, color, fg, onPress: () => handleSelect(option) })
474
+ ] }, option.value)) });
442
475
  }
443
476
 
444
477
  // src/ProfileChip.tsx
@@ -451,7 +484,7 @@ function ProfileChip({ profile, size = 22, filled = true }) {
451
484
  // src/ProfilePicker.tsx
452
485
  var import_auto_paper3 = require("@rific/auto-paper");
453
486
  var import_feedback_press2 = require("@rific/feedback-press");
454
- var import_react3 = require("react");
487
+ var import_react4 = require("react");
455
488
  var import_react_native4 = require("react-native");
456
489
  var import_react_native_paper3 = require("react-native-paper");
457
490
  var import_jsx_runtime7 = require("react/jsx-runtime");
@@ -463,7 +496,7 @@ function ProfilePicker({ idPrefix, host, profiles, selectedId, takenId, takenIds
463
496
  const colorPreview = useProfileColorPreview();
464
497
  const fg = dark ? "#FFFFFF" : "#000000";
465
498
  const selectedProfile = profiles.find((p) => p.id === selectedId) ?? null;
466
- const sortedProfiles = (0, import_react3.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
499
+ const sortedProfiles = (0, import_react4.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
467
500
  const contentSize = getProfilePickerContentSize(profiles.length, !!onManage, colorSection);
468
501
  const isTaken = (profileId) => profileId === takenId || profileId !== selectedId && !!takenIds?.includes(profileId);
469
502
  const handleSelectGuest = () => {
@@ -531,7 +564,7 @@ var import_feedback_press3 = require("@rific/feedback-press");
531
564
  var import_focus_chain = require("@rific/focus-chain");
532
565
  var import_toaster = require("@rific/toaster");
533
566
  var import_chrome2 = require("@tastic/hud/chrome");
534
- var import_react4 = require("react");
567
+ var import_react5 = require("react");
535
568
  var import_react_native5 = require("react-native");
536
569
  var import_react_native_paper4 = require("react-native-paper");
537
570
  var import_react_native_safe_area_context = require("react-native-safe-area-context");
@@ -571,6 +604,7 @@ function isValidProfile(value) {
571
604
  // src/ProfilesManager.tsx
572
605
  var import_jsx_runtime8 = require("react/jsx-runtime");
573
606
  var CHIP_SIZE2 = 40;
607
+ var textInputRef = (ref) => ref;
574
608
  function deriveTag(name) {
575
609
  const trimmed = name.trim();
576
610
  return trimmed ? trimmed[0].toUpperCase() : "";
@@ -581,16 +615,18 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
581
615
  const { dark } = (0, import_auto_paper4.useAutoPaperTheme)();
582
616
  const insets = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
583
617
  const { error: showErrorToast } = (0, import_toaster.useToast)();
618
+ const columnRef = (0, import_react5.useRef)(null);
619
+ const renderSwatch = useProfileSwatchRenderer();
584
620
  const host = (0, import_chrome2.usePopoverHost)();
585
621
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
586
622
  const fgMuted = fgMutedOverride ?? (dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.5)");
587
- const [editingId, setEditingId] = (0, import_react4.useState)(null);
588
- const [draftName, setDraftName] = (0, import_react4.useState)("");
589
- const [draftColor, setDraftColor] = (0, import_react4.useState)(defaultColor);
590
- const [draftTag, setDraftTag] = (0, import_react4.useState)("");
591
- const [tagManuallySet, setTagManuallySet] = (0, import_react4.useState)(false);
592
- const [confirmDeleteId, setConfirmDeleteId] = (0, import_react4.useState)(null);
593
- const commitEdit = (0, import_react4.useCallback)(() => {
623
+ const [editingId, setEditingId] = (0, import_react5.useState)(null);
624
+ const [draftName, setDraftName] = (0, import_react5.useState)("");
625
+ const [draftColor, setDraftColor] = (0, import_react5.useState)(defaultColor);
626
+ const [draftTag, setDraftTag] = (0, import_react5.useState)("");
627
+ const [tagManuallySet, setTagManuallySet] = (0, import_react5.useState)(false);
628
+ const [confirmDeleteId, setConfirmDeleteId] = (0, import_react5.useState)(null);
629
+ const commitEdit = (0, import_react5.useCallback)(() => {
594
630
  const name = draftName.trim();
595
631
  if (editingId && name) {
596
632
  const tagValid = isValidTag(draftTag);
@@ -601,13 +637,13 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
601
637
  }
602
638
  setEditingId(null);
603
639
  }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast]);
604
- const commitEditRef = (0, import_react4.useRef)(commitEdit);
605
- (0, import_react4.useEffect)(() => {
640
+ const commitEditRef = (0, import_react5.useRef)(commitEdit);
641
+ (0, import_react5.useEffect)(() => {
606
642
  commitEditRef.current = commitEdit;
607
643
  });
608
- (0, import_react4.useEffect)(() => () => commitEditRef.current(), []);
609
- (0, import_react4.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
610
- const startEdit = (0, import_react4.useCallback)(
644
+ (0, import_react5.useEffect)(() => () => commitEditRef.current(), []);
645
+ (0, import_react5.useImperativeHandle)(ref, () => ({ commitPendingEdit: () => commitEditRef.current() }), []);
646
+ const startEdit = (0, import_react5.useCallback)(
611
647
  (target) => {
612
648
  commitEdit();
613
649
  if (target === "new") {
@@ -625,33 +661,33 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
625
661
  },
626
662
  [commitEdit, defaultColor]
627
663
  );
628
- const handleNameChange = (0, import_react4.useCallback)(
664
+ const handleNameChange = (0, import_react5.useCallback)(
629
665
  (name) => {
630
666
  setDraftName(name);
631
667
  if (!tagManuallySet) setDraftTag(deriveTag(name));
632
668
  },
633
669
  [tagManuallySet]
634
670
  );
635
- const handleTagChange = (0, import_react4.useCallback)((tag) => {
671
+ const handleTagChange = (0, import_react5.useCallback)((tag) => {
636
672
  setDraftTag((previous) => clampTag(previous, tag));
637
673
  setTagManuallySet(true);
638
674
  }, []);
639
675
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
640
- const handleConfirmDelete = (0, import_react4.useCallback)(() => {
676
+ const handleConfirmDelete = (0, import_react5.useCallback)(() => {
641
677
  if (confirmDeleteId) {
642
678
  onDelete(confirmDeleteId);
643
679
  if (editingId === confirmDeleteId) setEditingId(null);
644
680
  }
645
681
  setConfirmDeleteId(null);
646
682
  }, [confirmDeleteId, editingId, onDelete]);
647
- const sortedProfiles = (0, import_react4.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
683
+ const sortedProfiles = (0, import_react5.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
648
684
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
649
685
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
650
686
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native5.View, { style: [styles5.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
651
687
  headerLeft,
652
688
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: titleVariant, style: [styles5.title, { color: fg }], children: "Profiles" })
653
689
  ] }),
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) => {
690
+ /* @__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
691
  if (row.kind === "new") {
656
692
  if (editingId !== "new") {
657
693
  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,45 +695,50 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
659
695
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
660
696
  ] }) }, "new");
661
697
  }
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");
698
+ 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
699
  }
664
700
  const { profile } = row;
665
701
  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);
702
+ 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
703
  }
668
704
  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
705
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ProfileChip, { profile, size: CHIP_SIZE2 }),
670
706
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper4.Text, { variant: "bodyLarge", style: [styles5.rowName, { color: fg }], numberOfLines: 1, children: profile.name })
671
707
  ] }) }, profile.id);
672
- }) }) }),
708
+ }) }) }) }),
673
709
  /* @__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
710
  ] });
675
711
  }
676
- function AlignedColorPicker({ useAlign, ...props }) {
712
+ function AlignedColorPicker({ useAlign, column, ...props }) {
677
713
  const { width: windowWidth } = (0, import_react_native5.useWindowDimensions)();
678
714
  const { width, height } = (0, import_chrome2.getInlineColorPickerContentSize)(import_auto_paper4.defaultColors.length, windowWidth);
679
- const alignOverride = useAlign(props.host.openId === props.id, width, height);
715
+ const alignOverride = useAlign(props.host.openId === props.id, width, height, column);
680
716
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_chrome2.InlineColorPicker, { ...props, alignOverride });
681
717
  }
682
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign }) {
718
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark, colorPreview, useColorPickerAlign, column, renderSwatch }) {
719
+ const pickerSwatch = (0, import_react5.useMemo)(() => renderSwatch ? (color, size, place) => renderSwatch(color, size, place === "trigger" ? "chip" : "colorPage") : void 0, [renderSwatch]);
683
720
  const register = (0, import_focus_chain.useFocusChain)();
684
721
  const tag = register();
685
722
  const name = register();
686
- const tagFocusRef = (0, import_react4.useRef)(tag.props.focus);
687
- (0, import_react4.useEffect)(() => {
723
+ const tagFocusRef = (0, import_react5.useRef)(tag.props.focus);
724
+ (0, import_react5.useEffect)(() => {
688
725
  tagFocusRef.current = tag.props.focus;
689
726
  });
690
- (0, import_react4.useEffect)(() => {
727
+ (0, import_react5.useEffect)(() => {
691
728
  if (host.openId === "color") tagFocusRef.current();
692
729
  }, [host.openId]);
693
730
  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 }),
731
+ 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 }),
732
+ /* @__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" }),
733
+ /* @__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
734
  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
735
  ] });
699
736
  }
700
737
  var styles5 = import_react_native5.StyleSheet.create({
738
+ // The roster's column, around the scroll view (see columnRef).
739
+ column: {
740
+ flex: 1
741
+ },
701
742
  content: {
702
743
  gap: 4,
703
744
  paddingHorizontal: 20
@@ -778,12 +819,12 @@ var styles5 = import_react_native5.StyleSheet.create({
778
819
  // src/ProfilesScreen.tsx
779
820
  var import_auto_paper5 = require("@rific/auto-paper");
780
821
  var import_feedback_press4 = require("@rific/feedback-press");
781
- var import_react5 = require("react");
822
+ var import_react6 = require("react");
782
823
  var import_react_native6 = require("react-native");
783
824
  var import_jsx_runtime9 = require("react/jsx-runtime");
784
825
  function ProfilesScreen({ profiles, defaultColor, onCreate, onSave, onDelete, onBack, fg: fgOverride, fgMuted, bg: bgOverride, titleVariant, colorPreview, useColorPickerAlign }) {
785
826
  const { dark } = (0, import_auto_paper5.useAutoPaperTheme)();
786
- const managerRef = (0, import_react5.useRef)(null);
827
+ const managerRef = (0, import_react6.useRef)(null);
787
828
  const fg = fgOverride ?? (dark ? "#FFFFFF" : "#000000");
788
829
  const bg = bgOverride ?? (dark ? "#000000" : "#FFFFFF");
789
830
  const handleBack = () => {
@@ -971,10 +1012,29 @@ async function readSharedProfiles(groupId) {
971
1012
  async function loadSharedProfiles(groupId) {
972
1013
  return await readSharedProfiles(groupId) ?? [];
973
1014
  }
1015
+ var hasId = (entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string" && entry.id !== "";
1016
+ async function unreadableEntries(groupId, profiles) {
1017
+ let json;
1018
+ try {
1019
+ json = web ? web.storage.getItem(webKey(groupId, PROFILES_KEY)) : await native?.getSharedJSON(groupId, PROFILES_KEY);
1020
+ } catch {
1021
+ return [];
1022
+ }
1023
+ if (!json) return [];
1024
+ try {
1025
+ const parsed = JSON.parse(json);
1026
+ if (!Array.isArray(parsed)) return [];
1027
+ const saved = new Set(profiles.map((profile) => profile.id));
1028
+ return parsed.filter((entry) => !isValidProfile(entry) && hasId(entry) && !saved.has(entry.id));
1029
+ } catch {
1030
+ return [];
1031
+ }
1032
+ }
974
1033
  async function saveSharedProfiles(groupId, profiles) {
975
1034
  if (!native) return;
976
1035
  try {
977
- await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
1036
+ const kept = await unreadableEntries(groupId, profiles);
1037
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify([...profiles, ...kept]));
978
1038
  } catch {
979
1039
  }
980
1040
  }
@@ -1056,15 +1116,15 @@ function createSeatColorsSlice(namespace, defaultState, mountKey = "seatColors")
1056
1116
  }
1057
1117
 
1058
1118
  // src/redux/useSharedProfilesSync.ts
1059
- var import_react6 = require("react");
1119
+ var import_react7 = require("react");
1060
1120
  var import_react_native8 = require("react-native");
1061
1121
  function useSharedProfilesSync({ groupId, onRemoteChange }) {
1062
- const pendingWriteRef = (0, import_react6.useRef)(false);
1063
- const onRemoteChangeRef = (0, import_react6.useRef)(onRemoteChange);
1064
- (0, import_react6.useEffect)(() => {
1122
+ const pendingWriteRef = (0, import_react7.useRef)(false);
1123
+ const onRemoteChangeRef = (0, import_react7.useRef)(onRemoteChange);
1124
+ (0, import_react7.useEffect)(() => {
1065
1125
  onRemoteChangeRef.current = onRemoteChange;
1066
1126
  }, [onRemoteChange]);
1067
- (0, import_react6.useEffect)(() => {
1127
+ (0, import_react7.useEffect)(() => {
1068
1128
  if (!isSharedProfileStoreAvailable) return;
1069
1129
  let cancelled = false;
1070
1130
  const sub = import_react_native8.AppState.addEventListener("change", (next) => {
@@ -1082,7 +1142,7 @@ function useSharedProfilesSync({ groupId, onRemoteChange }) {
1082
1142
  unsubscribe();
1083
1143
  };
1084
1144
  }, [groupId]);
1085
- const syncToShared = (0, import_react6.useCallback)(
1145
+ const syncToShared = (0, import_react7.useCallback)(
1086
1146
  (next) => {
1087
1147
  if (!isSharedProfileStoreAvailable) return;
1088
1148
  pendingWriteRef.current = true;