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