@buoy-gg/jotai 7.0.30 → 7.0.32

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 (46) hide show
  1. package/lib/commonjs/index.js +7 -0
  2. package/lib/commonjs/jotai/components/JotaiAtomBrowser.js +30 -119
  3. package/lib/commonjs/jotai/components/JotaiAtomChangeItem.js +19 -7
  4. package/lib/commonjs/jotai/components/JotaiAtomDetailContent.js +41 -5
  5. package/lib/commonjs/jotai/components/JotaiAtomValueDetailContent.js +444 -0
  6. package/lib/commonjs/jotai/components/JotaiEventFilterView.js +15 -0
  7. package/lib/commonjs/jotai/components/JotaiModal.js +159 -23
  8. package/lib/commonjs/jotai/utils/jotaiStateStore.js +9 -4
  9. package/lib/commonjs/jotai/utils/testIDs.js +90 -0
  10. package/lib/commonjs/jotai/utils/watchAtoms.js +8 -2
  11. package/lib/commonjs/jotai/utils/writeSemantics.js +47 -0
  12. package/lib/module/index.js +1 -0
  13. package/lib/module/jotai/components/JotaiAtomBrowser.js +33 -122
  14. package/lib/module/jotai/components/JotaiAtomChangeItem.js +19 -7
  15. package/lib/module/jotai/components/JotaiAtomDetailContent.js +41 -5
  16. package/lib/module/jotai/components/JotaiAtomValueDetailContent.js +442 -0
  17. package/lib/module/jotai/components/JotaiEventFilterView.js +15 -0
  18. package/lib/module/jotai/components/JotaiModal.js +160 -24
  19. package/lib/module/jotai/utils/jotaiStateStore.js +9 -4
  20. package/lib/module/jotai/utils/testIDs.js +82 -0
  21. package/lib/module/jotai/utils/watchAtoms.js +8 -2
  22. package/lib/module/jotai/utils/writeSemantics.js +43 -0
  23. package/lib/typescript/index.d.ts +1 -0
  24. package/lib/typescript/index.d.ts.map +1 -1
  25. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts +6 -5
  26. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts.map +1 -1
  27. package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts +5 -0
  28. package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts.map +1 -1
  29. package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts.map +1 -1
  30. package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts +40 -0
  31. package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts.map +1 -0
  32. package/lib/typescript/jotai/components/JotaiEventFilterView.d.ts.map +1 -1
  33. package/lib/typescript/jotai/components/JotaiModal.d.ts.map +1 -1
  34. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts +1 -0
  35. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts.map +1 -1
  36. package/lib/typescript/jotai/types/index.d.ts +12 -0
  37. package/lib/typescript/jotai/types/index.d.ts.map +1 -1
  38. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts +1 -1
  39. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts.map +1 -1
  40. package/lib/typescript/jotai/utils/testIDs.d.ts +62 -0
  41. package/lib/typescript/jotai/utils/testIDs.d.ts.map +1 -0
  42. package/lib/typescript/jotai/utils/watchAtoms.d.ts +8 -2
  43. package/lib/typescript/jotai/utils/watchAtoms.d.ts.map +1 -1
  44. package/lib/typescript/jotai/utils/writeSemantics.d.ts +25 -0
  45. package/lib/typescript/jotai/utils/writeSemantics.d.ts.map +1 -0
  46. package/package.json +3 -3
@@ -27,6 +27,12 @@ Object.defineProperty(exports, "JotaiAtomDetailFooter", {
27
27
  return _JotaiAtomDetailContent.JotaiAtomDetailFooter;
28
28
  }
29
29
  });
30
+ Object.defineProperty(exports, "JotaiAtomValueDetailContent", {
31
+ enumerable: true,
32
+ get: function () {
33
+ return _JotaiAtomValueDetailContent.JotaiAtomValueDetailContent;
34
+ }
35
+ });
30
36
  Object.defineProperty(exports, "JotaiIcon", {
31
37
  enumerable: true,
32
38
  get: function () {
@@ -100,6 +106,7 @@ var _useJotaiAtomChanges = require("./jotai/hooks/useJotaiAtomChanges");
100
106
  var _JotaiModal = require("./jotai/components/JotaiModal");
101
107
  var _JotaiAtomChangeItem = require("./jotai/components/JotaiAtomChangeItem");
102
108
  var _JotaiAtomDetailContent = require("./jotai/components/JotaiAtomDetailContent");
109
+ var _JotaiAtomValueDetailContent = require("./jotai/components/JotaiAtomValueDetailContent");
103
110
  var _JotaiIcon = require("./jotai/components/JotaiIcon");
104
111
  var _jotaiSyncAdapter = require("./jotai/sync/jotaiSyncAdapter");
105
112
  var _jotaiStateStore = require("./jotai/utils/jotaiStateStore");
@@ -7,14 +7,16 @@ exports.JotaiAtomBrowser = JotaiAtomBrowser;
7
7
  var _react = require("react");
8
8
  var _reactNative = require("react-native");
9
9
  var _sharedUi = require("@buoy-gg/shared-ui");
10
- var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
11
10
  var _jotaiStateStore = require("../utils/jotaiStateStore");
11
+ var _testIDs = require("../utils/testIDs");
12
12
  var _jsxRuntime = require("react/jsx-runtime");
13
13
  /**
14
14
  * JotaiAtomBrowser
15
15
  *
16
16
  * Atoms tab — shows all registered Jotai atoms and their current value.
17
- * Mirrors ZustandStoreBrowser.tsx from @buoy-gg/zustand.
17
+ * Mirrors ZustandStoreBrowser.tsx from @buoy-gg/zustand: tapping a card
18
+ * NAVIGATES to the atom's detail screen (live editor + details + read-only
19
+ * tree) rather than expanding in place — the modal owns that selection.
18
20
  */
19
21
 
20
22
  function getValuePreview(atom) {
@@ -40,84 +42,26 @@ function getValuePreview(atom) {
40
42
  return "";
41
43
  }
42
44
  }
43
- function getValueType(atom) {
44
- try {
45
- const value = atom.getValue();
46
- if (value === null) return "null";
47
- if (value === undefined) return "undefined";
48
- if (Array.isArray(value)) return `array · ${value.length}`;
49
- return typeof value;
50
- } catch {
51
- return "unknown";
52
- }
53
- }
54
- function AtomExpandedContent({
55
- atom,
56
- onViewHistory
57
- }) {
58
- const value = atom.getValue();
59
- const displayValue = (0, _react.useMemo)(() => {
60
- if (value && typeof value === "object") {
61
- const filtered = {};
62
- for (const [key, v] of Object.entries(value)) {
63
- if (typeof v !== "function") filtered[key] = v;
64
- }
65
- return (0, _sharedUi.parseValue)(filtered);
66
- }
67
- return (0, _sharedUi.parseValue)(value);
68
- }, [value]);
69
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
70
- style: expandedStyles.container,
71
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ExpandedInfoRow, {
72
- label: "Type",
73
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PillBadge, {
74
- color: atom.color,
75
- children: "JOTAI"
76
- })
77
- }), atom.changeCount > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ExpandedInfoRow, {
78
- label: "Changes",
79
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PillBadge, {
80
- color: _sharedUi.buoyColors.warning,
81
- children: String(atom.changeCount)
82
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
83
- onPress: () => onViewHistory(atom.label),
84
- style: expandedStyles.viewHistoryButton,
85
- hitSlop: {
86
- top: 6,
87
- bottom: 6,
88
- left: 6,
89
- right: 6
90
- },
91
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
92
- style: [expandedStyles.viewHistoryText, {
93
- color: atom.color
94
- }],
95
- children: "view history \u2192"
96
- })
97
- })]
98
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
99
- style: expandedStyles.dataContainer,
100
- children: displayValue && typeof displayValue === "object" ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_dataViewer.DataViewer, {
101
- title: "",
102
- data: displayValue,
103
- showTypeFilter: true,
104
- rawMode: true,
105
- initialExpanded: true
106
- }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
107
- style: expandedStyles.primitiveText,
108
- children: String(value ?? "undefined")
109
- })
110
- })]
111
- });
45
+
46
+ /**
47
+ * The card's categorical slot — the atom's write story, mirroring the
48
+ * zustand card's "persisted"/"in-memory" backing label.
49
+ */
50
+ function getWriteKindLabel(atom) {
51
+ if (atom.writeKind === "value") return "primitive";
52
+ if (atom.writeKind === "action") return "custom write";
53
+ return atom.setValue ? "writable" : "derived";
112
54
  }
113
55
  function EmptyBrowserState() {
114
56
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
115
57
  style: styles.emptyState,
58
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-empty`,
116
59
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Box, {
117
60
  size: 32,
118
61
  color: _sharedUi.night.color.textTertiary
119
62
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
120
63
  style: styles.emptyTitle,
64
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-empty-title`,
121
65
  children: "No atoms registered"
122
66
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
123
67
  style: styles.emptyText,
@@ -128,30 +72,23 @@ function EmptyBrowserState() {
128
72
  function JotaiAtomBrowser({
129
73
  atoms,
130
74
  searchQuery,
131
- onViewHistory,
132
- onAtomExpand
75
+ onOpenAtom
133
76
  }) {
134
- const [expandedAtom, setExpandedAtom] = (0, _react.useState)(null);
135
77
  const filteredAtoms = (0, _react.useMemo)(() => {
136
78
  if (!searchQuery) return atoms;
137
79
  const search = searchQuery.toLowerCase();
138
80
  return atoms.filter(a => a.label.toLowerCase().includes(search));
139
81
  }, [atoms, searchQuery]);
140
- const handleAtomPress = (0, _react.useCallback)(atom => {
141
- setExpandedAtom(prev => {
142
- const next = prev === atom.label ? null : atom.label;
143
- if (next) onAtomExpand?.(atom.label);
144
- return next;
145
- });
146
- }, [onAtomExpand]);
147
82
  if (filteredAtoms.length === 0 && !searchQuery) {
148
83
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(EmptyBrowserState, {});
149
84
  }
150
85
  if (filteredAtoms.length === 0 && searchQuery) {
151
86
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
152
87
  style: styles.emptyState,
88
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-no-match`,
153
89
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
154
90
  style: styles.emptyTitle,
91
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-no-match-title`,
155
92
  children: "No matching atoms"
156
93
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Text, {
157
94
  style: styles.emptyText,
@@ -163,6 +100,7 @@ function JotaiAtomBrowser({
163
100
  style: styles.container,
164
101
  contentContainerStyle: styles.scrollContent,
165
102
  showsVerticalScrollIndicator: true,
103
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-scroll`,
166
104
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
167
105
  style: styles.sectionHeader,
168
106
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
@@ -172,27 +110,27 @@ function JotaiAtomBrowser({
172
110
  style: styles.sectionCountBadge,
173
111
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
174
112
  style: styles.sectionCountText,
113
+ testID: `${_testIDs.JOTAI_TEST_ID}-atoms-count`,
175
114
  children: filteredAtoms.length
176
115
  })
177
116
  })]
178
117
  }), filteredAtoms.map(atom => {
179
- const isExpanded = expandedAtom === atom.label;
180
118
  const valuePreview = getValuePreview(atom);
181
119
  const atomColor = _jotaiStateStore.jotaiStateStore.getAtomColor(atom.label);
120
+ const rowID = (0, _testIDs.atomRowTestID)(atom.label);
182
121
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
183
122
  style: styles.atomRowWrapper,
184
123
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompactRow, {
185
124
  statusDotColor: atomColor,
186
- statusLabel: atom.label,
187
- statusSublabel: getValueType(atom),
188
- primaryText: valuePreview,
125
+ statusLabel: getWriteKindLabel(atom),
126
+ primaryText: atom.label,
127
+ secondaryText: valuePreview || undefined,
189
128
  showChevron: true,
190
- isExpanded: isExpanded,
191
- onPress: () => handleAtomPress(atom),
192
- expandedContent: isExpanded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(AtomExpandedContent, {
193
- atom: atom,
194
- onViewHistory: onViewHistory
195
- }) : undefined
129
+ onPress: () => onOpenAtom(atom.label),
130
+ testID: rowID,
131
+ statusTestID: `${rowID}-kind`,
132
+ primaryTestID: `${rowID}-name`,
133
+ secondaryTestID: `${rowID}-preview`
196
134
  }), atom.changeCount > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
197
135
  style: [styles.absCountBadge, {
198
136
  backgroundColor: atomColor + "22",
@@ -203,6 +141,7 @@ function JotaiAtomBrowser({
203
141
  style: [styles.absCountText, {
204
142
  color: atomColor
205
143
  }],
144
+ testID: `${rowID}-count`,
206
145
  children: String(atom.changeCount)
207
146
  })
208
147
  })]
@@ -278,32 +217,4 @@ const styles = _reactNative.StyleSheet.create({
278
217
  textAlign: "center",
279
218
  lineHeight: 18
280
219
  }
281
- });
282
- const expandedStyles = _reactNative.StyleSheet.create({
283
- container: {
284
- gap: 10
285
- },
286
- viewHistoryButton: {
287
- marginLeft: 4
288
- },
289
- viewHistoryText: {
290
- fontSize: 10,
291
- fontWeight: "600",
292
- fontFamily: "monospace"
293
- },
294
- dataContainer: {
295
- // Inset display panel — darker than the card, the night track color.
296
- backgroundColor: _sharedUi.night.color.bg,
297
- borderRadius: 6,
298
- borderWidth: 1,
299
- borderColor: _sharedUi.night.color.border,
300
- overflow: "hidden",
301
- minHeight: 60
302
- },
303
- primitiveText: {
304
- color: _sharedUi.night.color.text,
305
- fontSize: 12,
306
- fontFamily: "monospace",
307
- padding: 14
308
- }
309
220
  });
@@ -64,28 +64,37 @@ function getPrimaryText(change) {
64
64
  // ZustandStateChangeItem (same pattern).
65
65
  const JotaiAtomChangeItem = exports.JotaiAtomChangeItem = /*#__PURE__*/(0, _react.memo)(function JotaiAtomChangeItem({
66
66
  change,
67
- onPress
67
+ onPress,
68
+ testID
68
69
  }) {
69
70
  const statusColor = getCategoryColor(change.category, change.atomLabel);
70
71
  const statusLabel = getStatusLabel(change.category, change.atomLabel);
71
72
  const sublabel = getSublabel(change);
72
73
  const primaryText = getPrimaryText(change);
73
74
  const badgeText = getBadgeText(change);
74
- const customBadge = change.hasValueChange ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
75
+
76
+ // Always a custom badge, even when there is no icon to hang off it. The
77
+ // alternative — letting CompactRow draw `badgeText` itself — leaves the one
78
+ // row whose badge matters most (a write that moved the atom's identity but
79
+ // none of its contents) as the only row in the list with no handle on its
80
+ // badge. Same call, and the same reason, as ZustandStateChangeItem.
81
+ const customBadge = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
75
82
  style: styles.badgeContainer,
76
83
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
77
84
  style: [styles.badgeText, {
78
85
  color: statusColor
79
86
  }],
87
+ testID: testID ? `${testID}-badge` : undefined,
80
88
  children: badgeText
81
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
89
+ }), change.hasValueChange ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
82
90
  style: styles.changeBadge,
91
+ testID: testID ? `${testID}-change` : undefined,
83
92
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Zap, {
84
93
  size: 12,
85
94
  color: _sharedUi.buoyColors.warning
86
95
  })
87
- })]
88
- }) : undefined;
96
+ }) : null]
97
+ });
89
98
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompactRow, {
90
99
  statusDotColor: statusColor,
91
100
  statusLabel: statusLabel,
@@ -95,10 +104,13 @@ const JotaiAtomChangeItem = exports.JotaiAtomChangeItem = /*#__PURE__*/(0, _reac
95
104
  timestamp: change.timestamp
96
105
  }),
97
106
  customBadge: customBadge,
98
- badgeText: customBadge ? undefined : badgeText,
99
107
  badgeColor: statusColor,
100
108
  showChevron: true,
101
- onPress: () => onPress(change)
109
+ onPress: () => onPress(change),
110
+ testID: testID,
111
+ statusTestID: testID ? `${testID}-status` : undefined,
112
+ statusSublabelTestID: testID ? `${testID}-sublabel` : undefined,
113
+ primaryTestID: testID ? `${testID}-keys` : undefined
102
114
  });
103
115
  });
104
116
  const styles = _reactNative.StyleSheet.create({
@@ -11,6 +11,7 @@ var _sharedUi = require("@buoy-gg/shared-ui");
11
11
  var _dataViewer = require("@buoy-gg/shared-ui/dataViewer");
12
12
  var _license = require("@buoy-gg/license");
13
13
  var _jotaiStateStore = require("../utils/jotaiStateStore");
14
+ var _testIDs = require("../utils/testIDs");
14
15
  var _jsxRuntime = require("react/jsx-runtime");
15
16
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
16
17
  /**
@@ -58,6 +59,13 @@ function DetailViewToggle({
58
59
  }], isDisabled && toggleStyles.cardDisabled],
59
60
  onPress: () => !isDisabled && onViewChange(config.key),
60
61
  activeOpacity: 0.7,
62
+ testID: `${_testIDs.JOTAI_TEST_ID}-view-${config.key}`,
63
+ accessibilityRole: "button",
64
+ accessibilityLabel: config.label,
65
+ accessibilityState: {
66
+ disabled: isDisabled,
67
+ selected: isActive
68
+ },
61
69
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
62
70
  style: toggleStyles.cardContent,
63
71
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(IconComponent, {
@@ -148,8 +156,10 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
148
156
  style: infoStyles.container,
149
157
  contentContainerStyle: infoStyles.content,
150
158
  showsVerticalScrollIndicator: false,
159
+ testID: `${_testIDs.JOTAI_TEST_ID}-detail-scroll`,
151
160
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
152
161
  style: infoStyles.card,
162
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-card`,
153
163
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
154
164
  style: infoStyles.sectionHeader,
155
165
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -175,6 +185,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
175
185
  }],
176
186
  numberOfLines: 1,
177
187
  ellipsizeMode: "middle",
188
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-atom`,
178
189
  children: change.atomLabel
179
190
  })]
180
191
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -184,6 +195,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
184
195
  children: "Time"
185
196
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
186
197
  style: [infoStyles.infoValue, infoStyles.infoValueMono],
198
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-time`,
187
199
  children: formatTime(change.timestamp)
188
200
  })]
189
201
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -196,6 +208,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
196
208
  style: [infoStyles.categoryText, {
197
209
  color: categoryInfo.color
198
210
  }],
211
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-category`,
199
212
  children: categoryInfo.label
200
213
  })
201
214
  }), change.hasValueChange ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -205,12 +218,14 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
205
218
  color: _sharedUi.buoyColors.success
206
219
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
207
220
  style: infoStyles.changesText,
221
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-changes`,
208
222
  children: change.diffSummary || "changed"
209
223
  })]
210
224
  }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
211
225
  style: infoStyles.noChangesBadge,
212
226
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
213
227
  style: infoStyles.noChangesText,
228
+ testID: `${_testIDs.JOTAI_TEST_ID}-info-no-changes`,
214
229
  children: "no change"
215
230
  })
216
231
  })]
@@ -218,6 +233,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
218
233
  })]
219
234
  }), change.changedKeys.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
220
235
  style: infoStyles.card,
236
+ testID: `${_testIDs.JOTAI_TEST_ID}-keys-card`,
221
237
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
222
238
  style: infoStyles.sectionHeader,
223
239
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -233,6 +249,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
233
249
  style: infoStyles.countBadge,
234
250
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
235
251
  style: infoStyles.countText,
252
+ testID: `${_testIDs.JOTAI_TEST_ID}-keys-count`,
236
253
  children: change.changedKeys.length
237
254
  })
238
255
  })]
@@ -244,6 +261,7 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
244
261
  style: infoStyles.keyBadge,
245
262
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
246
263
  style: infoStyles.keyText,
264
+ testID: `${_testIDs.JOTAI_TEST_ID}-key-${(0, _testIDs.slugForTestID)(key)}`,
247
265
  children: key
248
266
  })
249
267
  }, key))
@@ -254,6 +272,9 @@ const JotaiAtomInfoView = /*#__PURE__*/(0, _react.memo)(function JotaiAtomInfoVi
254
272
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
255
273
  style: [infoStyles.actionButton, infoStyles.actionButtonCopy],
256
274
  onTouchEnd: handleCopyValue,
275
+ testID: `${_testIDs.JOTAI_TEST_ID}-copy-value`,
276
+ accessibilityRole: "button",
277
+ accessibilityLabel: "Copy value",
257
278
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
258
279
  style: [infoStyles.actionButtonText, {
259
280
  color: _sharedUi.night.color.accent
@@ -328,6 +349,7 @@ function JotaiAtomDetailContent({
328
349
  const parsed = (0, _sharedUi.parseValue)(change.nextValue);
329
350
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
330
351
  style: styles.contentCard,
352
+ testID: `${_testIDs.JOTAI_TEST_ID}-value-card`,
331
353
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
332
354
  style: styles.valueHeader,
333
355
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
@@ -349,6 +371,7 @@ function JotaiAtomDetailContent({
349
371
  style: styles.typeBadge,
350
372
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
351
373
  style: styles.typeText,
374
+ testID: `${_testIDs.JOTAI_TEST_ID}-value-type`,
352
375
  children: valueType.toUpperCase()
353
376
  })
354
377
  })]
@@ -400,7 +423,13 @@ function JotaiAtomDetailContent({
400
423
  const handleFooterNext = (0, _react.useCallback)(() => {
401
424
  onIndexChange(Math.max(0, selectedIndex - 1));
402
425
  }, [selectedIndex, onIndexChange]);
403
- const diffDisabled = totalChanges <= 1;
426
+
427
+ // The diff compares THIS change's prevValue/nextValue — no siblings needed.
428
+ // Gating on `changes.length` greyed DIFF out for every jotai event opened
429
+ // from the Events timeline (a one-element array). Only a change with no
430
+ // captured previous value has nothing to compare.
431
+ const diffDisabled = change.prevValue === undefined;
432
+ const showInternalFooter = !disableInternalFooter && totalChanges > 1;
404
433
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
405
434
  style: styles.container,
406
435
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(DetailViewToggle, {
@@ -408,7 +437,7 @@ function JotaiAtomDetailContent({
408
437
  onViewChange: setActiveView,
409
438
  diffDisabled: diffDisabled
410
439
  }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
411
- style: styles.contentArea,
440
+ style: [styles.contentArea, showInternalFooter && styles.contentAreaFooterPad],
412
441
  children: [activeView === "change" && /*#__PURE__*/(0, _jsxRuntime.jsx)(JotaiAtomInfoView, {
413
442
  change: change
414
443
  }), activeView === "value" && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
@@ -416,19 +445,22 @@ function JotaiAtomDetailContent({
416
445
  children: renderValueView()
417
446
  }), activeView === "diff" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
418
447
  style: styles.diffContainer,
448
+ testID: `${_testIDs.JOTAI_TEST_ID}-diff`,
419
449
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.DiffModeTabs, {
420
450
  tabs: DIFF_MODE_TABS,
421
451
  activeTab: diffMode,
422
- onTabChange: setDiffMode
452
+ onTabChange: setDiffMode,
453
+ testIDPrefix: `${_testIDs.JOTAI_TEST_ID}-diff-tab`
423
454
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.CompareBar, {
424
455
  leftEvent: leftEvent,
425
456
  rightEvent: rightEvent
426
457
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
427
458
  style: styles.diffContent,
459
+ testID: `${_testIDs.JOTAI_TEST_ID}-diff-content`,
428
460
  children: renderDiffContent()
429
461
  })]
430
462
  })]
431
- }), !disableInternalFooter && totalChanges > 1 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.EventStepperFooter, {
463
+ }), showInternalFooter && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.EventStepperFooter, {
432
464
  currentIndex: chronologicalNumber - 1,
433
465
  totalItems: totalChanges,
434
466
  onPrevious: handleFooterPrevious,
@@ -464,7 +496,11 @@ const styles = _reactNative.StyleSheet.create({
464
496
  backgroundColor: "transparent"
465
497
  },
466
498
  contentArea: {
467
- flex: 1,
499
+ flex: 1
500
+ },
501
+ // Clearance for the absolute EventStepperFooter — only when it renders,
502
+ // otherwise hosts like the Events tool get a dead band at the sheet bottom.
503
+ contentAreaFooterPad: {
468
504
  paddingBottom: 96
469
505
  },
470
506
  stateContainer: {