@tastic/profile 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -257,11 +257,22 @@ function isValidHexColor(value) {
257
257
  var VARIATION_SELECTOR = String.fromCodePoint(65039);
258
258
  var ZERO_WIDTH_JOINER = String.fromCodePoint(8205);
259
259
  var EMOJI_PATTERN2 = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, "u");
260
+ var EMOJI_PRESENT = /\p{Extended_Pictographic}/u;
260
261
  function isValidTag(value) {
261
262
  if (typeof value !== "string") return false;
262
263
  if (value.length === 0) return true;
263
264
  if (EMOJI_PATTERN2.test(value)) return true;
264
- return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
265
+ return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
266
+ }
267
+ function clampTag(previous, next) {
268
+ const previousCodepoints = Array.from(previous);
269
+ const nextCodepoints = Array.from(next);
270
+ if (nextCodepoints.length <= previousCodepoints.length) return next;
271
+ let sharedPrefixLength = 0;
272
+ while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++;
273
+ const added = nextCodepoints.slice(sharedPrefixLength).join("");
274
+ if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added;
275
+ return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next;
265
276
  }
266
277
  function isValidProfile(value) {
267
278
  if (!value || typeof value !== "object") return false;
@@ -335,7 +346,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
335
346
  [tagManuallySet]
336
347
  );
337
348
  const handleTagChange = (0, import_react2.useCallback)((tag) => {
338
- setDraftTag(tag);
349
+ setDraftTag((previous) => clampTag(previous, tag));
339
350
  setTagManuallySet(true);
340
351
  }, []);
341
352
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
@@ -348,7 +359,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
348
359
  }, [confirmDeleteId, editingId, onDelete]);
349
360
  const sortedProfiles = (0, import_react2.useMemo)(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
350
361
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
351
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
352
362
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
353
363
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
354
364
  headerLeft,
@@ -362,11 +372,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
362
372
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Text, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
363
373
  ] }) }, "new");
364
374
  }
365
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
375
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
366
376
  }
367
377
  const { profile } = row;
368
378
  if (editingId === profile.id) {
369
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
379
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
370
380
  }
371
381
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.rowInner, children: [
372
382
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -386,7 +396,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
386
396
  ] }) }) })
387
397
  ] });
388
398
  }
389
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
399
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
390
400
  const register = (0, import_focus_chain.useFocusChain)();
391
401
  const tag = register();
392
402
  const name = register();
@@ -399,7 +409,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
399
409
  }, [host.openId]);
400
410
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.editRow, children: [
401
411
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_hud2.InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
402
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
412
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
403
413
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
404
414
  onDelete && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_feedback_press2.TouchableRipple, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native_paper3.Icon, { source: "trash-can-outline", size: 18, color: fgMuted }) })
405
415
  ] });
@@ -482,31 +492,14 @@ var styles3 = import_react_native3.StyleSheet.create({
482
492
  scrollView: {
483
493
  flex: 1
484
494
  },
485
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
486
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
487
- // fraction of the row's width, since the name field next to it is what should actually get the
488
- // row's remaining space.
489
- tagInput: {
490
- height: CHIP_SIZE,
491
- width: CHIP_SIZE
492
- },
493
- tagInputContent: {
494
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
495
- // placeholder/label's measured layout width (sized for a real label, not this field's own
496
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
497
- // editable box silently overflows past this field's own visible outline and centers within
498
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
499
- // instead, which is what actually makes `textAlign: 'center'` below center against the
500
- // visible box rather than an overflowing invisible one.
501
- minWidth: 0,
502
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
503
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
504
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
505
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
506
- // box silently overflow wider to compensate.
507
- paddingHorizontal: 2,
508
- textAlign: "center",
509
- textAlignVertical: "center"
495
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
496
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
497
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
498
+ hiddenTagInput: {
499
+ height: 1,
500
+ opacity: 0,
501
+ position: "absolute",
502
+ width: 1
510
503
  },
511
504
  title: {
512
505
  flexShrink: 1,
package/dist/index.mjs CHANGED
@@ -229,11 +229,22 @@ function isValidHexColor(value) {
229
229
  var VARIATION_SELECTOR = String.fromCodePoint(65039);
230
230
  var ZERO_WIDTH_JOINER = String.fromCodePoint(8205);
231
231
  var EMOJI_PATTERN2 = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, "u");
232
+ var EMOJI_PRESENT = /\p{Extended_Pictographic}/u;
232
233
  function isValidTag(value) {
233
234
  if (typeof value !== "string") return false;
234
235
  if (value.length === 0) return true;
235
236
  if (EMOJI_PATTERN2.test(value)) return true;
236
- return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
237
+ return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH;
238
+ }
239
+ function clampTag(previous, next) {
240
+ const previousCodepoints = Array.from(previous);
241
+ const nextCodepoints = Array.from(next);
242
+ if (nextCodepoints.length <= previousCodepoints.length) return next;
243
+ let sharedPrefixLength = 0;
244
+ while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++;
245
+ const added = nextCodepoints.slice(sharedPrefixLength).join("");
246
+ if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added;
247
+ return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next;
237
248
  }
238
249
  function isValidProfile(value) {
239
250
  if (!value || typeof value !== "object") return false;
@@ -307,7 +318,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
307
318
  [tagManuallySet]
308
319
  );
309
320
  const handleTagChange = useCallback((tag) => {
310
- setDraftTag(tag);
321
+ setDraftTag((previous) => clampTag(previous, tag));
311
322
  setTagManuallySet(true);
312
323
  }, []);
313
324
  const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null;
@@ -320,7 +331,6 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
320
331
  }, [confirmDeleteId, editingId, onDelete]);
321
332
  const sortedProfiles = useMemo2(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles]);
322
333
  const rows = [...sortedProfiles.map((profile) => ({ kind: "profile", profile })), { kind: "new" }];
323
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag);
324
334
  return /* @__PURE__ */ jsxs2(Fragment2, { children: [
325
335
  /* @__PURE__ */ jsxs2(View3, { style: [styles3.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }], children: [
326
336
  headerLeft,
@@ -334,11 +344,11 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
334
344
  /* @__PURE__ */ jsx3(Text3, { variant: "bodyLarge", style: { color: fg }, children: "New Profile" })
335
345
  ] }) }, "new");
336
346
  }
337
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
347
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, autoFocus: true, fgMuted, host, dark }, "new");
338
348
  }
339
349
  const { profile } = row;
340
350
  if (editingId === profile.id) {
341
- return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, tagInvalid, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
351
+ return /* @__PURE__ */ jsx3(EditRow, { draftName, onNameChange: handleNameChange, draftColor, onColorChange: setDraftColor, draftTag, onTagChange: handleTagChange, onSubmit: commitEdit, onDelete: () => setConfirmDeleteId(profile.id), fgMuted, host, dark }, profile.id);
342
352
  }
343
353
  return /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: () => startEdit(profile), style: styles3.row, children: /* @__PURE__ */ jsxs2(View3, { style: styles3.rowInner, children: [
344
354
  /* @__PURE__ */ jsx3(ProfileChip, { profile, size: CHIP_SIZE }),
@@ -358,7 +368,7 @@ function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, h
358
368
  ] }) }) })
359
369
  ] });
360
370
  }
361
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
371
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }) {
362
372
  const register = useFocusChain();
363
373
  const tag = register();
364
374
  const name = register();
@@ -371,7 +381,7 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
371
381
  }, [host.openId]);
372
382
  return /* @__PURE__ */ jsxs2(View3, { style: styles3.editRow, children: [
373
383
  /* @__PURE__ */ jsx3(InlineColorPicker, { id: "color", host, value: draftColor, onChange: onColorChange, tag: draftTag, dark }),
374
- /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, mode: "outlined", dense: true, value: draftTag, onChangeText: onTagChange, placeholder: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, error: tagInvalid, returnKeyType: "next", style: styles3.tagInput, contentStyle: styles3.tagInputContent }),
384
+ /* @__PURE__ */ jsx3(TextInput, { ref: tag.ref, ...tag.props, value: draftTag, onChangeText: onTagChange, accessibilityLabel: "Tag", autoCapitalize: "characters", selectTextOnFocus: true, returnKeyType: "next", style: styles3.hiddenTagInput, pointerEvents: "none" }),
375
385
  /* @__PURE__ */ jsx3(TextInput, { ref: name.ref, ...name.props, onSubmitEditing: onSubmit, mode: "outlined", dense: true, value: draftName, onChangeText: onNameChange, maxLength: MAX_PROFILE_NAME_LENGTH, placeholder: "Name", autoFocus, returnKeyType: "done", style: styles3.nameInput }),
376
386
  onDelete && /* @__PURE__ */ jsx3(TouchableRipple2, { onPress: onDelete, style: styles3.iconButton, children: /* @__PURE__ */ jsx3(Icon3, { source: "trash-can-outline", size: 18, color: fgMuted }) })
377
387
  ] });
@@ -454,31 +464,14 @@ var styles3 = StyleSheet3.create({
454
464
  scrollView: {
455
465
  flex: 1
456
466
  },
457
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
458
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
459
- // fraction of the row's width, since the name field next to it is what should actually get the
460
- // row's remaining space.
461
- tagInput: {
462
- height: CHIP_SIZE,
463
- width: CHIP_SIZE
464
- },
465
- tagInputContent: {
466
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
467
- // placeholder/label's measured layout width (sized for a real label, not this field's own
468
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
469
- // editable box silently overflows past this field's own visible outline and centers within
470
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
471
- // instead, which is what actually makes `textAlign: 'center'` below center against the
472
- // visible box rather than an overflowing invisible one.
473
- minWidth: 0,
474
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
475
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
476
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
477
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
478
- // box silently overflow wider to compensate.
479
- paddingHorizontal: 2,
480
- textAlign: "center",
481
- textAlignVertical: "center"
467
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
468
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
469
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
470
+ hiddenTagInput: {
471
+ height: 1,
472
+ opacity: 0,
473
+ position: "absolute",
474
+ width: 1
482
475
  },
483
476
  title: {
484
477
  flexShrink: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",
@@ -9,12 +9,11 @@ import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
9
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
10
 
11
11
  import { ProfileChip } from './ProfileChip'
12
- import { isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
12
+ import { clampTag, isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
13
13
  import { Profile } from './types'
14
14
 
15
- // Shared by the New Profile row's own dashed circle, every profile row's own ProfileChip, the edit
16
- // row's color trigger, and its tag field — one size for every circle on this screen, rather than
17
- // each picking its own.
15
+ // Shared by the New Profile row's own dashed circle and every profile row's own ProfileChip — one
16
+ // size for every circle on this screen, rather than each picking its own.
18
17
  const CHIP_SIZE = 40
19
18
 
20
19
  interface ProfileEditPatch {
@@ -145,8 +144,14 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
145
144
  [tagManuallySet]
146
145
  )
147
146
 
147
+ // The functional setDraftTag form — reading `previous` from React's own latest-applied state
148
+ // rather than closing over the `draftTag` variable — matters here, not just style: fast/rapid
149
+ // keystrokes can fire their onChangeText calls before a re-render lands, batched into the same
150
+ // update, and clamping every one of them against the same stale `draftTag` (captured once at
151
+ // render time) would let the later ones silently clobber what the earlier ones already clamped
152
+ // to, rather than each building on the last.
148
153
  const handleTagChange = useCallback((tag: string) => {
149
- setDraftTag(tag)
154
+ setDraftTag((previous) => clampTag(previous, tag))
150
155
  setTagManuallySet(true)
151
156
  }, [])
152
157
 
@@ -164,7 +169,6 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
164
169
  // handful of profiles on one shared device.
165
170
  const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
166
171
  const rows: Row[] = [...sortedProfiles.map((profile): Row => ({ kind: 'profile', profile })), { kind: 'new' }]
167
- const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag)
168
172
 
169
173
  return (
170
174
  <>
@@ -198,12 +202,12 @@ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDe
198
202
  </TouchableRipple>
199
203
  )
200
204
  }
201
- return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
205
+ 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} />
202
206
  }
203
207
 
204
208
  const { profile } = row
205
209
  if (editingId === profile.id) {
206
- return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
210
+ 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} />
207
211
  }
208
212
 
209
213
  // No delete affordance at rest — tap the row to start editing, which is the one place
@@ -260,7 +264,6 @@ interface EditRowProps {
260
264
  onColorChange: (color: string) => void
261
265
  draftTag: string
262
266
  onTagChange: (tag: string) => void
263
- tagInvalid: boolean
264
267
  onSubmit: () => void
265
268
  // Omitted for the 'new' row — nothing saved yet to delete.
266
269
  onDelete?: () => void
@@ -270,18 +273,19 @@ interface EditRowProps {
270
273
  dark: boolean
271
274
  }
272
275
 
273
- // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker), a tag field clamped to
274
- // CHIP_SIZE, the name taking up whatever's
275
- // left, and — only for an existing profile — a delete icon at the very end. `tag={draftTag}` IS
276
- // passed to the color trigger here (unlike a plain identity badge elsewhere) so it shows the same
277
- // live draft value the tag field itself does — the two effects below are what wires tapping the
278
- // swatch to popping the keyboard on the tag field too, so typing a tag and picking a color read as
279
- // one combined action instead of two separate taps.
276
+ // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker) — doubling as the tag's
277
+ // own display via its `tag` prop below, since there's no separate visible tag field anymore — an
278
+ // invisible TextInput that exists purely to actually capture what's typed (RN has no way to bring
279
+ // up a keyboard without a real focusable input; this one is just never meant to be *seen*), the
280
+ // name taking up the row's remaining space, and — only for an existing profile — a delete icon at
281
+ // the very end. The two effects below are what wires tapping the swatch to popping the keyboard on
282
+ // the hidden tag field too, so typing a tag and picking a color read as one combined action instead
283
+ // of two separate taps, with the swatch itself as the only place the tag ever visibly shows.
280
284
  // Return-key chains tag -> name via @rific/focus-chain rather than each field submitting on its
281
285
  // own — submitting the *name* field is what actually commits the row, so its registration's own
282
286
  // onSubmitEditing (a no-op — it's last in the chain) is overridden with onSubmit below, matching
283
287
  // how a normal multi-field form reads: fill fields in order, the last one finishes it.
284
- function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
288
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
285
289
  const register = useFocusChain()
286
290
  const tag = register()
287
291
  const name = register()
@@ -309,12 +313,21 @@ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag,
309
313
  return (
310
314
  <View style={styles.editRow}>
311
315
  <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} tag={draftTag} dark={dark} />
312
- {/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
313
- — the far more common edit than inserting into the middle of a 1-3 character value. */}
316
+ {/* Invisible on purpose (styles.hiddenTagInput: zero footprint, position: 'absolute' so it
317
+ doesn't reserve space in the row's own flex layout) — the swatch above is the only place this
318
+ value is ever meant to be seen. pointerEvents='none' is what actually keeps it out of the
319
+ way: without it, this field — even at 1x1 and fully transparent — still sits in front of the
320
+ swatch for hit-testing purposes and swallows the tap meant to open the color popover, since
321
+ focusing this field is never done by tapping it directly anyway (see the effect above, which
322
+ focuses it programmatically). accessibilityLabel stands in for the placeholder a visible field
323
+ would have had, so a screen reader still identifies it, and so this file's own tests can still
324
+ find it as "Tag" the same way they find the Name field by its placeholder. selectTextOnFocus
325
+ so re-tapping the swatch to revise an already-set tag replaces it wholesale rather than
326
+ inserting mid-string. */}
314
327
  {/* eslint-disable-next-line react-hooks/refs -- tag.ref/tag.props come from useFocusChain's
315
328
  register(), called during render by design (see the hook's own doc); the actual DOM/native
316
329
  focus() call it wraps only ever fires later, from an event handler, never synchronously here */}
317
- <TextInput ref={tag.ref} {...tag.props} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
330
+ <TextInput ref={tag.ref} {...tag.props} value={draftTag} onChangeText={onTagChange} accessibilityLabel='Tag' autoCapitalize='characters' selectTextOnFocus returnKeyType='next' style={styles.hiddenTagInput} pointerEvents='none' />
318
331
  {/* eslint-disable-next-line react-hooks/refs -- see the tag field's identical note above */}
319
332
  <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} />
320
333
  {onDelete && (
@@ -404,31 +417,14 @@ const styles = StyleSheet.create({
404
417
  scrollView: {
405
418
  flex: 1
406
419
  },
407
- // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
408
- // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
409
- // fraction of the row's width, since the name field next to it is what should actually get the
410
- // row's remaining space.
411
- tagInput: {
412
- height: CHIP_SIZE,
413
- width: CHIP_SIZE
414
- },
415
- tagInputContent: {
416
- // react-native-paper's outlined TextInput sets its own native input's minWidth from the
417
- // placeholder/label's measured layout width (sized for a real label, not this field's own
418
- // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
419
- // editable box silently overflows past this field's own visible outline and centers within
420
- // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
421
- // instead, which is what actually makes `textAlign: 'center'` below center against the
422
- // visible box rather than an overflowing invisible one.
423
- minWidth: 0,
424
- // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
425
- // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
426
- // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
427
- // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
428
- // box silently overflow wider to compensate.
429
- paddingHorizontal: 2,
430
- textAlign: 'center',
431
- textAlignVertical: 'center'
420
+ // 1x1 and fully transparent — there's nothing to see here on purpose (see EditRow's own comment
421
+ // above the field this styles). position: 'absolute' pulls it out of editRow's flex flow
422
+ // entirely, so it doesn't reserve a gap in the row the way a normal flex sibling would.
423
+ hiddenTagInput: {
424
+ height: 1,
425
+ opacity: 0,
426
+ position: 'absolute',
427
+ width: 1
432
428
  },
433
429
  title: {
434
430
  flexShrink: 1,
@@ -21,6 +21,12 @@ const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
21
21
  // segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
22
22
  const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
23
23
 
24
+ // Presence check, not the full anchored EMOJI_PATTERN above — clampTag below only needs to know
25
+ // whether a just-typed chunk contains a pictograph at all, not whether an entire value is exactly
26
+ // one (possibly ZWJ-joined) emoji. Same distinction @tastic/hud's InlineColorPicker draws for its
27
+ // own font-sizing check.
28
+ const EMOJI_PRESENT = /\p{Extended_Pictographic}/u
29
+
24
30
  // Empty is valid too — a profile can have no tag yet, same as it can go without ever entering an
25
31
  // editor's own tag field at all; every place this renders falls back to a generic icon in that case
26
32
  // (see ProfileChip).
@@ -28,7 +34,33 @@ export function isValidTag(value: unknown): value is string {
28
34
  if (typeof value !== 'string') return false
29
35
  if (value.length === 0) return true
30
36
  if (EMOJI_PATTERN.test(value)) return true
31
- return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
37
+ return !EMOJI_PRESENT.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
38
+ }
39
+
40
+ // Clamps a tag field's live value on every keystroke so it's never possible to type your way into
41
+ // a state isValidTag would reject, rather than allowing it and only catching it later (a toast on
42
+ // save, or a swatch preview that overflows/truncates). `previous` is the value before this
43
+ // keystroke, `next` is what the field reports after it.
44
+ export function clampTag(previous: string, next: string): string {
45
+ const previousCodepoints = Array.from(previous)
46
+ const nextCodepoints = Array.from(next)
47
+
48
+ // Shrinking (backspace/delete, or a selectTextOnFocus-driven wholesale replacement — see
49
+ // ProfilesManager's own EditRow — reporting its own replacement text as no longer than what it
50
+ // overwrote) always passes through untouched: there's nothing to clamp about removing content.
51
+ if (nextCodepoints.length <= previousCodepoints.length) return next
52
+
53
+ let sharedPrefixLength = 0
54
+ while (sharedPrefixLength < previousCodepoints.length && previousCodepoints[sharedPrefixLength] === nextCodepoints[sharedPrefixLength]) sharedPrefixLength++
55
+ const added = nextCodepoints.slice(sharedPrefixLength).join('')
56
+
57
+ // A tag is either a handful of plain characters or exactly one emoji, never a mix — typing more
58
+ // of either kind on top of the other one starts fresh with just what's newly typed, rather than
59
+ // producing a combination isValidTag would reject anyway (an emoji followed by letters, or a
60
+ // second emoji tacked onto the first).
61
+ if (EMOJI_PRESENT.test(added) || EMOJI_PRESENT.test(previous)) return added
62
+
63
+ return nextCodepoints.length > MAX_TAG_LENGTH ? previous : next
32
64
  }
33
65
 
34
66
  // Validates only the fields this package's own `Profile` type declares — an app that extends