@buoy-gg/highlight-updates 7.0.34 → 7.0.36

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.
Files changed (36) hide show
  1. package/lib/commonjs/highlight-updates/HighlightUpdatesOverlay.js +1 -1
  2. package/lib/commonjs/highlight-updates/components/HighlightFilterView.js +6 -6
  3. package/lib/commonjs/highlight-updates/components/IdentifierBadge.js +2 -2
  4. package/lib/commonjs/highlight-updates/components/RenderCauseBadge.js +7 -7
  5. package/lib/commonjs/highlight-updates/components/RenderDetailView.js +6 -6
  6. package/lib/commonjs/highlight-updates/components/RenderHistoryViewer.js +8 -8
  7. package/lib/commonjs/highlight-updates/components/RenderListItem.js +2 -2
  8. package/lib/commonjs/highlight-updates/components/RendersCopySettingsView.js +1 -1
  9. package/lib/commonjs/highlight-updates/components/StatsDisplay.js +1 -1
  10. package/lib/commonjs/highlight-updates/utils/screenElements.js +272 -70
  11. package/lib/commonjs/highlight-updates/utils/tapMatch.js +26 -1
  12. package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +1 -1
  13. package/lib/module/highlight-updates/HighlightUpdatesOverlay.js +2 -2
  14. package/lib/module/highlight-updates/components/HighlightFilterView.js +7 -7
  15. package/lib/module/highlight-updates/components/IdentifierBadge.js +3 -3
  16. package/lib/module/highlight-updates/components/RenderCauseBadge.js +8 -8
  17. package/lib/module/highlight-updates/components/RenderDetailView.js +7 -7
  18. package/lib/module/highlight-updates/components/RenderHistoryViewer.js +9 -9
  19. package/lib/module/highlight-updates/components/RenderListItem.js +3 -3
  20. package/lib/module/highlight-updates/components/RendersCopySettingsView.js +2 -2
  21. package/lib/module/highlight-updates/components/StatsDisplay.js +2 -2
  22. package/lib/module/highlight-updates/utils/screenElements.js +272 -70
  23. package/lib/module/highlight-updates/utils/tapMatch.js +25 -1
  24. package/lib/module/sync/highlightUpdatesSyncAdapter.js +1 -1
  25. package/lib/typescript/highlight-updates/components/HighlightFilterView.d.ts.map +1 -1
  26. package/lib/typescript/highlight-updates/components/IdentifierBadge.d.ts.map +1 -1
  27. package/lib/typescript/highlight-updates/components/RenderDetailView.d.ts.map +1 -1
  28. package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts.map +1 -1
  29. package/lib/typescript/highlight-updates/components/RendersCopySettingsView.d.ts.map +1 -1
  30. package/lib/typescript/highlight-updates/utils/screenElements.d.ts +52 -1
  31. package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -1
  32. package/lib/typescript/highlight-updates/utils/tapMatch.d.ts +11 -0
  33. package/lib/typescript/highlight-updates/utils/tapMatch.d.ts.map +1 -1
  34. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +1 -1
  35. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
  36. package/package.json +7 -7
@@ -46,6 +46,69 @@ function getReactDevToolsHook() {
46
46
  return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
47
47
  }
48
48
 
49
+ /**
50
+ * React commits observed since this counter was installed.
51
+ *
52
+ * `tapElement` reads it either side of firing a handler to say whether
53
+ * ANYTHING re-rendered afterwards. Without that a tap that matched a button
54
+ * on the screen BENEATH the current one (a stack keeps it mounted) came back
55
+ * `tapped:true` — the handler really did run — and an agent then built its
56
+ * next three turns on a sign-in that never happened. Zero commits inside the
57
+ * settle window is the honest signal: the handler ran and the app did nothing
58
+ * visible with it.
59
+ *
60
+ * Installed once and chained, the same way CommitProfiler wraps the hook;
61
+ * counting is the only work it does on the commit path.
62
+ */
63
+ let commitCount = 0;
64
+ let commitCounterInstalled = false;
65
+ function ensureCommitCounter() {
66
+ if (commitCounterInstalled) return;
67
+ const hook = getReactDevToolsHook();
68
+ if (!hook) return;
69
+ const original = hook.onCommitFiberRoot;
70
+ hook.onCommitFiberRoot = function (...args) {
71
+ commitCount++;
72
+ return typeof original === "function" ? original.apply(hook, args) : undefined;
73
+ };
74
+ commitCounterInstalled = true;
75
+ }
76
+
77
+ /** How long a tap waits to see the app react before reporting. */
78
+ const TAP_SETTLE_MS = 250;
79
+
80
+ /**
81
+ * Per-walk memo of "is this fiber under Buoy's own overlay". Every candidate
82
+ * asks, and they share ancestors, so the answer is cached along the path
83
+ * walked rather than recomputed 400 levels deep per row.
84
+ */
85
+ function makeChromeCheck() {
86
+ const memo = new Map();
87
+ return fiber => {
88
+ const path = [];
89
+ let cur = fiber;
90
+ let depth = 0;
91
+ let inChrome = false;
92
+ while (cur && depth < 400) {
93
+ const known = memo.get(cur);
94
+ if (known !== undefined) {
95
+ inChrome = known;
96
+ break;
97
+ }
98
+ if (getProps(cur)?.__buoyDevToolsRoot) {
99
+ inChrome = true;
100
+ memo.set(cur, true);
101
+ break;
102
+ }
103
+ path.push(cur);
104
+ cur = cur.return;
105
+ depth++;
106
+ }
107
+ for (const f of path) memo.set(f, inChrome);
108
+ return inChrome;
109
+ };
110
+ }
111
+
49
112
  /**
50
113
  * Collect fiber roots across ALL renderers. A single renderer id is not safe
51
114
  * to assume: secondary reconcilers (react-native-skia, react-native-svg, …)
@@ -264,13 +327,48 @@ function getOwningComponentName(fiber) {
264
327
  function getProps(fiber) {
265
328
  return fiber?.memoizedProps ?? fiber?.pendingProps ?? null;
266
329
  }
330
+
331
+ /**
332
+ * The text of a `children` value, INCLUDING one assembled from several pieces.
333
+ *
334
+ * `<Text>{value} {label}</Text>` compiles to `children: ["1,450", " ", "Points"]`
335
+ * and `Check Back Soon{"\n"}For Available Offers` to three strings. This used
336
+ * to accept a lone string or number and nothing else, so EVERY composed label
337
+ * in an app was invisible to describeScreen, tapElement and waitFor — while
338
+ * native accessibility, which concatenates the same children, could read them.
339
+ * Measured on a live QA run: five waits failed against an empty state that was
340
+ * on screen the whole time, and the agent then reported the app was missing
341
+ * one; a points balance could not be found by its own number.
342
+ *
343
+ * Nested React ELEMENTS are not walked here — `collectDescendantText` owns
344
+ * that, with its stop-at-a-nested-button rule — so this stays a leaf join, and
345
+ * adjacent pieces are concatenated exactly as React renders them.
346
+ */
347
+ function flattenText(value, depth = 0) {
348
+ if (typeof value === "string") return value;
349
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
350
+ if (Array.isArray(value) && depth < 4) {
351
+ const parts = [];
352
+ for (const child of value) {
353
+ const part = flattenText(child, depth + 1);
354
+ if (part) parts.push(part);
355
+ }
356
+ return parts.length > 0 ? parts.join("") : undefined;
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ /** What a person reads: newlines and runs of spaces collapse to one space. */
362
+ function normalizeVisibleText(value) {
363
+ return value.replace(/\s+/g, " ").trim();
364
+ }
267
365
  function getVisibleText(props) {
268
366
  if (!props) return undefined;
269
367
  const pick = v => {
270
- if (typeof v === "string") return v.trim() ? v.trim().slice(0, 80) : undefined;
271
- // Numeric children are common (counters, badges) — coerce them too.
272
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
273
- return undefined;
368
+ const flat = flattenText(v);
369
+ if (flat === undefined) return undefined;
370
+ const text = normalizeVisibleText(flat);
371
+ return text ? text.slice(0, 80) : undefined;
274
372
  };
275
373
  return pick(props.children) ?? pick(props.title) ?? pick(props.label) ?? pick(props.placeholder) ?? undefined;
276
374
  }
@@ -384,7 +482,19 @@ function collectCandidates() {
384
482
  const roots = getAllFiberRoots();
385
483
  if (roots.length === 0) return [];
386
484
  const out = [];
387
- const seenInstances = new Set();
485
+ /**
486
+ * De-dup indexes. Two fibers resolving to the same host node collapse to one
487
+ * row — a Pressable and its inner View, but ALSO the same native view reached
488
+ * twice by the walk, which produces two different `instance` objects for one
489
+ * `nativeTag`. Measured on a live QA run: a 141-row screen dump held 70
490
+ * distinct elements, every one listed twice, and the agent told the user
491
+ * their layout had duplicate elements. Keyed both ways so either collision
492
+ * collapses, and as Maps because the old `out.find(…)` was a linear scan per
493
+ * candidate on a list that can run to hundreds.
494
+ */
495
+ const byInstance = new Map();
496
+ const byTag = new Map();
497
+ const inBuoyChrome = makeChromeCheck();
388
498
  for (const root of roots) {
389
499
  traverseFiberTree(root.current, fiber => {
390
500
  const props = getProps(fiber);
@@ -442,25 +552,25 @@ function collectCandidates() {
442
552
  text = collectDescendantText(fiber, 6);
443
553
  }
444
554
  const instance = host?.instance ?? null;
445
- // De-dup: two fibers resolving to the same host instance (e.g. Pressable
446
- // and its inner View) collapse to one row — keep the more interactive.
447
- if (instance && seenInstances.has(instance)) {
448
- const existing = out.find(c => c.instance === instance);
449
- if (existing) {
450
- if (!existing.press && press) existing.press = press;else if (existing.press && press?.longPress && !existing.press.longPress) {
451
- existing.press.longPress = press.longPress;
452
- }
453
- if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
454
- if (!existing.textControl && textControl) existing.textControl = textControl;
455
- if (!existing.testID && testID) existing.testID = testID;
456
- if (!existing.label && label) existing.label = label;
457
- if (!existing.text && text) existing.text = text;
458
- return;
555
+ const nativeTag = host?.nativeTag ?? null;
556
+ const existing = (instance ? byInstance.get(instance) : undefined) ?? (nativeTag != null ? byTag.get(nativeTag) : undefined);
557
+ if (existing) {
558
+ // Keep the more interactive / better-labelled of the two.
559
+ if (!existing.press && press) existing.press = press;else if (existing.press && press?.longPress && !existing.press.longPress) {
560
+ existing.press.longPress = press.longPress;
459
561
  }
562
+ if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
563
+ if (!existing.textControl && textControl) existing.textControl = textControl;
564
+ if (!existing.testID && testID) existing.testID = testID;
565
+ if (!existing.label && label) existing.label = label;
566
+ if (!existing.text && text) existing.text = text;
567
+ // Index the alias too, so a third sighting through either key finds it.
568
+ if (instance && !byInstance.has(instance)) byInstance.set(instance, existing);
569
+ if (nativeTag != null && !byTag.has(nativeTag)) byTag.set(nativeTag, existing);
570
+ return;
460
571
  }
461
- if (instance) seenInstances.add(instance);
462
- out.push({
463
- nativeTag: host?.nativeTag ?? null,
572
+ const candidate = {
573
+ nativeTag,
464
574
  name,
465
575
  role,
466
576
  testID,
@@ -472,12 +582,48 @@ function collectCandidates() {
472
582
  press,
473
583
  valueControl,
474
584
  textControl,
475
- rect: null
476
- });
585
+ rect: null,
586
+ buoy: inBuoyChrome(fiber)
587
+ };
588
+ out.push(candidate);
589
+ if (instance) byInstance.set(instance, candidate);
590
+ if (nativeTag != null) byTag.set(nativeTag, candidate);
477
591
  });
478
592
  }
479
593
  return out;
480
594
  }
595
+
596
+ /**
597
+ * Whether the caller named THIS element, rather than described one like it.
598
+ * An exact nativeTag or testID is a deliberate choice, and is honoured even
599
+ * inside Buoy's own chrome — that is how a test or an editor agent drives a
600
+ * Buoy tool on purpose. A fuzzy `query` is not.
601
+ */
602
+ function explicitlyTargets(c, params) {
603
+ if (params.nativeTag != null) return c.nativeTag === params.nativeTag;
604
+ if (params.testID && c.testID) return c.testID.toLowerCase() === params.testID.toLowerCase();
605
+ return false;
606
+ }
607
+
608
+ /**
609
+ * The app under test, not the tool looking at it.
610
+ *
611
+ * Buoy runs INSIDE the app, so its own sheet is in the same fiber tree as the
612
+ * screens it inspects — and Ask Buoy's activity log echoes whatever the model
613
+ * is searching for back as visible text. Left in, that echo was the best
614
+ * match for the next fuzzy tap or wait, so every text-driven action landed on
615
+ * Buoy's own panel and nothing in the app moved. The agent diagnosed it
616
+ * itself, four chats in ("every text-based tap and wait I've done this
617
+ * session has been landing on Buoy's panel"), and still could not escape it.
618
+ *
619
+ * `includeBuoy` lists the chrome for callers that want it; an exact id gets
620
+ * through regardless (see `explicitlyTargets`).
621
+ */
622
+ function appCandidates(params) {
623
+ const all = collectCandidates();
624
+ if (params.includeBuoy) return all;
625
+ return all.filter(c => !c.buoy || explicitlyTargets(c, params));
626
+ }
481
627
  async function measureCandidates(candidates) {
482
628
  await Promise.all(candidates.map(async c => {
483
629
  if (c.instance) c.rect = await measureInstance(c.instance);
@@ -486,8 +632,10 @@ async function measureCandidates(candidates) {
486
632
 
487
633
  // --- describeScreen -------------------------------------------------------
488
634
 
489
- async function describeScreen() {
490
- const candidates = collectCandidates();
635
+ async function describeScreen(params = {}) {
636
+ const all = collectCandidates();
637
+ const candidates = params.includeBuoy ? all : all.filter(c => !c.buoy);
638
+ const hiddenBuoy = all.length - candidates.length;
491
639
  await measureCandidates(candidates);
492
640
  const win = _reactNative.Dimensions.get("window");
493
641
  const screenW = Math.round(win.width) || 1;
@@ -549,7 +697,10 @@ async function describeScreen() {
549
697
  height: screenH
550
698
  },
551
699
  count: elements.length,
552
- elements
700
+ elements,
701
+ ...(hiddenBuoy > 0 ? {
702
+ hiddenBuoy
703
+ } : {})
553
704
  };
554
705
  }
555
706
  function round2(n) {
@@ -605,7 +756,7 @@ function makeSyntheticPressEvent(rect) {
605
756
  };
606
757
  }
607
758
  async function tapElement(params) {
608
- const candidates = collectCandidates();
759
+ const candidates = appCandidates(params);
609
760
  if (candidates.length === 0) {
610
761
  return {
611
762
  tapped: false,
@@ -654,6 +805,21 @@ async function tapElement(params) {
654
805
  // sit below 15 text-score peers.
655
806
  const target = top.find(r => isInteractive(r.c)) ?? ranked.find(r => isInteractive(r.c)) ?? top[0];
656
807
  const c = target.c;
808
+
809
+ // Everything else that matched just as well. A fuzzy "SIGN IN" on a stack
810
+ // that still has the home screen mounted underneath has two equal answers,
811
+ // and the caller has to know that before trusting whichever one fired.
812
+ const sameControl = (a, b) => a.press && a.press.handler === b.press?.handler || a.valueControl && a.valueControl.handler === b.valueControl?.handler || a.textControl && a.textControl.handler === b.textControl?.handler;
813
+ const alsoMatched = top.filter(r => r !== target && isInteractive(r.c) && r.score === target.score)
814
+ // The label Text inside the tapped button matches its own button's text
815
+ // and inherits its onPress: same control, not a rival.
816
+ .filter(r => !sameControl(r.c, c)).filter((r, i, arr) => arr.findIndex(o => sameControl(o.c, r.c)) === i).slice(0, 3).map(r => ({
817
+ nativeTag: r.c.nativeTag,
818
+ name: r.c.name,
819
+ testID: r.c.testID,
820
+ text: r.c.text,
821
+ tap: tapPoint(r.c.rect, screenW, screenH)
822
+ }));
657
823
  if (!isInteractive(c)) {
658
824
  return {
659
825
  tapped: false,
@@ -691,6 +857,28 @@ async function tapElement(params) {
691
857
  label: c.label,
692
858
  text: c.text
693
859
  };
860
+
861
+ // Fire, then watch. The handler is the app's own; what it did — or did
862
+ // not do — shows up as commits (or none) in the settle window.
863
+ ensureCommitCounter();
864
+ // Read BEFORE the handler runs: a synchronous setState commits inside it.
865
+ const c0 = commitCount;
866
+ const t0 = Date.now();
867
+ const fired = async matched => {
868
+ await new Promise(resolve => setTimeout(resolve, TAP_SETTLE_MS));
869
+ return {
870
+ tapped: true,
871
+ scrolled,
872
+ matched,
873
+ effect: {
874
+ commits: commitCount - c0,
875
+ observedMs: Date.now() - t0
876
+ },
877
+ ...(alsoMatched.length ? {
878
+ alsoMatched
879
+ } : {})
880
+ };
881
+ };
694
882
  try {
695
883
  if (c.press) {
696
884
  if (params.longPress) {
@@ -707,24 +895,16 @@ async function tapElement(params) {
707
895
  };
708
896
  }
709
897
  lp(makeSyntheticPressEvent(c.rect));
710
- return {
711
- tapped: true,
712
- scrolled,
713
- matched: {
714
- ...base,
715
- via: "onLongPress"
716
- }
717
- };
898
+ return await fired({
899
+ ...base,
900
+ via: "onLongPress"
901
+ });
718
902
  }
719
903
  c.press.handler(makeSyntheticPressEvent(c.rect));
720
- return {
721
- tapped: true,
722
- scrolled,
723
- matched: {
724
- ...base,
725
- via: c.press.kind
726
- }
727
- };
904
+ return await fired({
905
+ ...base,
906
+ via: c.press.kind
907
+ });
728
908
  }
729
909
  if (c.valueControl) {
730
910
  const vc = c.valueControl;
@@ -742,28 +922,20 @@ async function tapElement(params) {
742
922
  }
743
923
  }
744
924
  vc.handler(next);
745
- return {
746
- tapped: true,
747
- scrolled,
748
- matched: {
749
- ...base,
750
- via: "onValueChange",
751
- value: next
752
- }
753
- };
925
+ return await fired({
926
+ ...base,
927
+ via: "onValueChange",
928
+ value: next
929
+ });
754
930
  }
755
931
  // textControl
756
932
  const next = params.text ?? (typeof params.value === "string" ? params.value : "");
757
933
  c.textControl.handler(next);
758
- return {
759
- tapped: true,
760
- scrolled,
761
- matched: {
762
- ...base,
763
- via: "onChangeText",
764
- value: next
765
- }
766
- };
934
+ return await fired({
935
+ ...base,
936
+ via: "onChangeText",
937
+ value: next
938
+ });
767
939
  } catch (e) {
768
940
  return {
769
941
  tapped: false,
@@ -812,7 +984,9 @@ const WAIT_DEFAULT_POLL_MS = 150;
812
984
  * waiting for.
813
985
  */
814
986
  async function waitFor(params) {
815
- const hasTarget = params.nativeTag != null || !!params.testID?.trim() || !!params.query?.trim();
987
+ // Coerced, not optional-chained: a model sends `query: 1450` often enough,
988
+ // and `.trim()` on a number throws before the wait ever starts.
989
+ const hasTarget = params.nativeTag != null || !!String(params.testID ?? "").trim() || !!String(params.query ?? "").trim();
816
990
  if (!hasTarget) {
817
991
  return {
818
992
  ok: false,
@@ -828,7 +1002,10 @@ async function waitFor(params) {
828
1002
  let polls = 0;
829
1003
  for (;;) {
830
1004
  polls += 1;
831
- const hit = await findOnScreen(params);
1005
+ const {
1006
+ hit,
1007
+ closest
1008
+ } = await findOnScreen(params);
832
1009
  if (wantGone ? !hit : !!hit) {
833
1010
  return {
834
1011
  ok: true,
@@ -838,12 +1015,18 @@ async function waitFor(params) {
838
1015
  };
839
1016
  }
840
1017
  if (Date.now() - started >= timeoutMs) {
1018
+ const nearMisses = closest?.length ? closest : undefined;
1019
+ // Say which of the two failures this is. "Nothing is called that" and
1020
+ // "it exists but is not visible" need opposite next moves, and a caller
1021
+ // that cannot tell them apart tries the same wait with new words.
1022
+ const why = wantGone ? `Still on screen after ${timeoutMs}ms.` : nearMisses ? `Not on screen after ${timeoutMs}ms — ${nearMisses.length === 1 ? "one element" : `${nearMisses.length} elements`} matched but ${nearMisses.some(n => n.onScreen) ? "not well enough" : "none is visible"}. See \`closest\`; do not retry with different words, call describeScreen.` : `Not on screen after ${timeoutMs}ms, and nothing matched those words at all. Call describeScreen to see what is there.`;
841
1023
  return {
842
1024
  ok: false,
843
1025
  waitedMs: Date.now() - started,
844
1026
  polls,
845
- reason: wantGone ? `Still on screen after ${timeoutMs}ms.` : `Not on screen after ${timeoutMs}ms. Call describeScreen to see what is.`,
846
- matched: hit ?? undefined
1027
+ reason: why,
1028
+ matched: hit ?? undefined,
1029
+ closest: nearMisses
847
1030
  };
848
1031
  }
849
1032
  await new Promise(resolve => setTimeout(resolve, pollMs));
@@ -852,22 +1035,26 @@ async function waitFor(params) {
852
1035
 
853
1036
  /** One scan: rank by identity, then confirm the best match is really visible. */
854
1037
  async function findOnScreen(params) {
855
- const ranked = collectCandidates().map(c => ({
1038
+ const ranked = appCandidates(params).map(c => ({
856
1039
  c,
857
1040
  score: matchScore(c, params)
858
1041
  })).filter(r => r.score > 0).sort((a, b) => b.score - a.score)
859
1042
  // Only the plausible few get measured; a broad query can match dozens.
860
1043
  .slice(0, 8);
861
- if (ranked.length === 0) return null;
1044
+ if (ranked.length === 0) return {
1045
+ hit: null,
1046
+ closest: undefined
1047
+ };
862
1048
  await measureCandidates(ranked.map(r => r.c));
863
1049
  const win = _reactNative.Dimensions.get("window");
864
1050
  const screenW = Math.round(win.width) || 1;
865
1051
  const screenH = Math.round(win.height) || 1;
1052
+ let hit = null;
866
1053
  for (const {
867
1054
  c
868
1055
  } of ranked) {
869
1056
  if (!(0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)) continue;
870
- return {
1057
+ hit = {
871
1058
  nativeTag: c.nativeTag,
872
1059
  name: c.name,
873
1060
  testID: c.testID,
@@ -875,8 +1062,23 @@ async function findOnScreen(params) {
875
1062
  text: c.text,
876
1063
  interactive: !!(c.press || c.valueControl || c.textControl)
877
1064
  };
1065
+ break;
878
1066
  }
879
- return null;
1067
+
1068
+ // Built from the same measured ranking, so a failed wait can say what it
1069
+ // nearly matched and whether that thing was visible.
1070
+ const closest = ranked.slice(0, 3).map(({
1071
+ c
1072
+ }) => ({
1073
+ name: c.name,
1074
+ testID: c.testID,
1075
+ text: c.text,
1076
+ onScreen: (0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)
1077
+ }));
1078
+ return {
1079
+ hit,
1080
+ closest
1081
+ };
880
1082
  }
881
1083
 
882
1084
  // --- resolveTouchTarget ---------------------------------------------------
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.isOnScreenTap = isOnScreenTap;
7
7
  exports.isRectCenterOnScreen = isRectCenterOnScreen;
8
+ exports.looseText = looseText;
8
9
  exports.scoreTapMatch = scoreTapMatch;
9
10
  /**
10
11
  * Shared tap-target scoring for describe_screen / tap_element.
@@ -33,15 +34,39 @@ function isRectCenterOnScreen(rect, screenW, screenH) {
33
34
  const slackY = screenH * 0.1;
34
35
  return cx >= -slackX && cx <= screenW + slackX && cy >= -slackY && cy <= screenH + slackY;
35
36
  }
37
+
38
+ /**
39
+ * The comparable form of a piece of text.
40
+ *
41
+ * "1,450", `1450` and "1,450 Points" are one request as far as a person is
42
+ * concerned. Measured on a live QA run: waits for BOTH "1,450" and 1450 failed
43
+ * against a balance that was on screen, because the thousands separator made
44
+ * every comparison miss. Lowercased, whitespace collapsed, digit-group
45
+ * separators dropped. A number arrives here as a number often enough that the
46
+ * coercion is not optional — `.trim()` on one is a crash.
47
+ */
48
+ function looseText(value) {
49
+ return String(value ?? "").toLowerCase().replace(/\s+/g, " ").replace(/(\d)[,\u00a0\u202f](?=\d{3}\b)/g, "$1").trim();
50
+ }
36
51
  function scoreTapMatch(c, params) {
37
52
  if (params.nativeTag != null && c.nativeTag === params.nativeTag) return 1000;
38
- const q = (params.query ?? params.testID ?? "").trim().toLowerCase();
53
+ const q = String(params.query ?? params.testID ?? "").trim().toLowerCase();
39
54
  if (!q) return 0;
40
55
  const fields = [c.testID, c.label, c.text, c.name].filter(Boolean).map(s => s.toLowerCase());
41
56
  let best = 0;
42
57
  for (const f of fields) {
43
58
  if (f === q) best = Math.max(best, 100);else if (f.startsWith(q)) best = Math.max(best, 50);else if (f.includes(q)) best = Math.max(best, 25);
44
59
  }
60
+ // Same tiers on the loose form, one notch lower so an exact hit still wins
61
+ // when both match. Skipped when the loose query is empty, or `includes("")`
62
+ // would score every candidate on the screen.
63
+ const looseQuery = looseText(q);
64
+ if (looseQuery) {
65
+ for (const f of fields) {
66
+ const loose = looseText(f);
67
+ if (loose === looseQuery) best = Math.max(best, 90);else if (loose.startsWith(looseQuery)) best = Math.max(best, 45);else if (loose.includes(looseQuery)) best = Math.max(best, 22);
68
+ }
69
+ }
45
70
  if (params.testID && c.testID && c.testID.toLowerCase() === params.testID.toLowerCase()) {
46
71
  best = Math.max(best, 200);
47
72
  }
@@ -205,7 +205,7 @@ const highlightUpdatesSyncAdapter = exports.highlightUpdatesSyncAdapter = {
205
205
  // Walk the live fiber tree and return the current screen's meaningful /
206
206
  // interactive elements, each with normalized tap coordinates. Powers the
207
207
  // MCP `describe_screen` reader — no screenshot, no silent-tracking needed.
208
- describeScreen: () => (0, _screenElements.describeScreen)(),
208
+ describeScreen: params => (0, _screenElements.describeScreen)(params ?? {}),
209
209
  // Tap an element purely in JS: resolve it (by nativeTag / testID / query),
210
210
  // find the nearest onPress up its fiber chain, and invoke it with a
211
211
  // synthetic press event. Powers the MCP `tap_element` action.
@@ -10,7 +10,7 @@
10
10
 
11
11
  import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from "react";
12
12
  import { View, Text, StyleSheet } from "react-native";
13
- import { night } from "@buoy-gg/shared-ui";
13
+ import { night, monoFont } from "@buoy-gg/shared-ui";
14
14
  import HighlightUpdatesController from "./utils/HighlightUpdatesController";
15
15
  import { PerformanceLogger, markOverlayRendered } from "./utils/PerformanceLogger";
16
16
  import { RenderTracker } from "./utils/RenderTracker";
@@ -268,7 +268,7 @@ const styles = StyleSheet.create({
268
268
  color: night.color.accent,
269
269
  fontSize: 11,
270
270
  fontWeight: "600",
271
- fontFamily: "monospace"
271
+ fontFamily: monoFont
272
272
  }
273
273
  });
274
274
  export default HighlightUpdatesOverlay;
@@ -10,7 +10,7 @@
10
10
 
11
11
  import React, { useState, useCallback } from "react";
12
12
  import { View, Text, TouchableOpacity, TextInput, StyleSheet, ScrollView, Modal, Pressable, Switch } from "react-native";
13
- import { Eye, Filter, Plus, X, Box, Settings, Hash, SectionHeader, night } from "@buoy-gg/shared-ui";
13
+ import { Eye, Filter, Plus, X, Box, Settings, Hash, SectionHeader, night, monoFont } from "@buoy-gg/shared-ui";
14
14
  import { IdentifierBadge, IDENTIFIER_CONFIG } from "./IdentifierBadge";
15
15
  import { detectedItemTestID, patternChipTestID, settingTestID } from "../utils/testIDs";
16
16
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
@@ -1095,7 +1095,7 @@ const styles = StyleSheet.create({
1095
1095
  patternChipValue: {
1096
1096
  fontSize: 11,
1097
1097
  color: night.color.text,
1098
- fontFamily: "monospace",
1098
+ fontFamily: monoFont,
1099
1099
  flexShrink: 1
1100
1100
  },
1101
1101
  patternChipX: {
@@ -1238,7 +1238,7 @@ const styles = StyleSheet.create({
1238
1238
  detectedItemText: {
1239
1239
  fontSize: 11,
1240
1240
  color: night.color.text,
1241
- fontFamily: "monospace"
1241
+ fontFamily: monoFont
1242
1242
  },
1243
1243
  emptyText: {
1244
1244
  fontSize: 11,
@@ -1256,7 +1256,7 @@ const styles = StyleSheet.create({
1256
1256
  fontSize: 11,
1257
1257
  color: night.color.textTertiary,
1258
1258
  lineHeight: 18,
1259
- fontFamily: "monospace"
1259
+ fontFamily: monoFont
1260
1260
  },
1261
1261
  howItWorksBold: {
1262
1262
  fontWeight: "700",
@@ -1356,7 +1356,7 @@ const styles = StyleSheet.create({
1356
1356
  fontSize: 12,
1357
1357
  fontWeight: "700",
1358
1358
  color: night.color.textTertiary,
1359
- fontFamily: "monospace"
1359
+ fontFamily: monoFont
1360
1360
  },
1361
1361
  settingDescription: {
1362
1362
  fontSize: 11,
@@ -1399,7 +1399,7 @@ const styles = StyleSheet.create({
1399
1399
  fontSize: 12,
1400
1400
  fontWeight: "500",
1401
1401
  color: night.color.textSecondary,
1402
- fontFamily: "monospace"
1402
+ fontFamily: monoFont
1403
1403
  },
1404
1404
  batchSizePresetTextActive: {
1405
1405
  color: night.color.textTertiary,
@@ -1438,7 +1438,7 @@ const styles = StyleSheet.create({
1438
1438
  fontSize: 14,
1439
1439
  fontWeight: "600",
1440
1440
  color: night.color.text,
1441
- fontFamily: "monospace",
1441
+ fontFamily: monoFont,
1442
1442
  textAlign: "center"
1443
1443
  },
1444
1444
  renderCountSeparator: {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from "react";
11
11
  import { View, Text, StyleSheet } from "react-native";
12
- import { Box, Hash, Layers, Eye, Search, FileCode, buoyColors, night } from "@buoy-gg/shared-ui";
12
+ import { Box, Hash, Layers, Eye, Search, FileCode, buoyColors, night, monoFont } from "@buoy-gg/shared-ui";
13
13
 
14
14
  // All possible identifier types
15
15
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -198,7 +198,7 @@ const styles = StyleSheet.create({
198
198
  value: {
199
199
  fontSize: 12,
200
200
  color: night.color.text,
201
- fontFamily: "monospace",
201
+ fontFamily: monoFont,
202
202
  flex: 1
203
203
  },
204
204
  valueCompact: {
@@ -231,7 +231,7 @@ const styles = StyleSheet.create({
231
231
  // Value only
232
232
  valueOnly: {
233
233
  fontSize: 12,
234
- fontFamily: "monospace"
234
+ fontFamily: monoFont
235
235
  },
236
236
  valueOnlyCompact: {
237
237
  fontSize: 11