@buoy-gg/jotai 7.0.41 → 7.0.43

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 (54) hide show
  1. package/README.md +11 -80
  2. package/lib/commonjs/index.js +1 -112
  3. package/lib/commonjs/jotai/components/JotaiAtomBrowser.js +1 -199
  4. package/lib/commonjs/jotai/components/JotaiAtomChangeItem.js +1 -95
  5. package/lib/commonjs/jotai/components/JotaiAtomDetailContent.js +1 -715
  6. package/lib/commonjs/jotai/components/JotaiAtomValueDetailContent.js +1 -444
  7. package/lib/commonjs/jotai/components/JotaiEventFilterView.js +1 -43
  8. package/lib/commonjs/jotai/components/JotaiIcon.js +1 -18
  9. package/lib/commonjs/jotai/components/JotaiModal.js +1 -743
  10. package/lib/commonjs/jotai/hooks/useJotaiAtomChanges.js +1 -69
  11. package/lib/commonjs/jotai/sync/jotaiSyncAdapter.js +1 -306
  12. package/lib/commonjs/jotai/types/index.js +1 -1
  13. package/lib/commonjs/jotai/utils/jotaiStateStore.js +1 -321
  14. package/lib/commonjs/jotai/utils/snapshotProvider.js +1 -215
  15. package/lib/commonjs/jotai/utils/testIDs.js +1 -90
  16. package/lib/commonjs/jotai/utils/watchAtoms.js +1 -143
  17. package/lib/commonjs/jotai/utils/writeSemantics.js +1 -47
  18. package/lib/commonjs/preset.js +1 -90
  19. package/lib/module/index.js +1 -85
  20. package/lib/module/jotai/components/JotaiAtomBrowser.js +1 -195
  21. package/lib/module/jotai/components/JotaiAtomChangeItem.js +1 -91
  22. package/lib/module/jotai/components/JotaiAtomDetailContent.js +1 -710
  23. package/lib/module/jotai/components/JotaiAtomValueDetailContent.js +1 -442
  24. package/lib/module/jotai/components/JotaiEventFilterView.js +1 -39
  25. package/lib/module/jotai/components/JotaiIcon.js +1 -11
  26. package/lib/module/jotai/components/JotaiModal.js +1 -738
  27. package/lib/module/jotai/hooks/useJotaiAtomChanges.js +1 -65
  28. package/lib/module/jotai/sync/jotaiSyncAdapter.js +1 -303
  29. package/lib/module/jotai/types/index.js +1 -1
  30. package/lib/module/jotai/utils/jotaiStateStore.js +1 -317
  31. package/lib/module/jotai/utils/snapshotProvider.js +1 -214
  32. package/lib/module/jotai/utils/testIDs.js +1 -82
  33. package/lib/module/jotai/utils/watchAtoms.js +1 -138
  34. package/lib/module/jotai/utils/writeSemantics.js +1 -43
  35. package/lib/module/preset.js +1 -87
  36. package/lib/web/index.mjs +2 -3
  37. package/package.json +4 -7
  38. package/lib/typescript/index.d.ts.map +0 -1
  39. package/lib/typescript/jotai/components/JotaiAtomBrowser.d.ts.map +0 -1
  40. package/lib/typescript/jotai/components/JotaiAtomChangeItem.d.ts.map +0 -1
  41. package/lib/typescript/jotai/components/JotaiAtomDetailContent.d.ts.map +0 -1
  42. package/lib/typescript/jotai/components/JotaiAtomValueDetailContent.d.ts.map +0 -1
  43. package/lib/typescript/jotai/components/JotaiEventFilterView.d.ts.map +0 -1
  44. package/lib/typescript/jotai/components/JotaiIcon.d.ts.map +0 -1
  45. package/lib/typescript/jotai/components/JotaiModal.d.ts.map +0 -1
  46. package/lib/typescript/jotai/hooks/useJotaiAtomChanges.d.ts.map +0 -1
  47. package/lib/typescript/jotai/sync/jotaiSyncAdapter.d.ts.map +0 -1
  48. package/lib/typescript/jotai/types/index.d.ts.map +0 -1
  49. package/lib/typescript/jotai/utils/jotaiStateStore.d.ts.map +0 -1
  50. package/lib/typescript/jotai/utils/snapshotProvider.d.ts.map +0 -1
  51. package/lib/typescript/jotai/utils/testIDs.d.ts.map +0 -1
  52. package/lib/typescript/jotai/utils/watchAtoms.d.ts.map +0 -1
  53. package/lib/typescript/jotai/utils/writeSemantics.d.ts.map +0 -1
  54. package/lib/typescript/preset.d.ts.map +0 -1
@@ -1,738 +1 @@
1
- "use strict";
2
-
3
- /**
4
- * Main Jotai DevTools modal
5
- *
6
- * Two tabs mirroring the Zustand DevTools pattern:
7
- * - Atoms tab: Browse all registered atoms and their current value
8
- * - Events tab: Live atom change monitoring with diffs
9
- */
10
-
11
- import { useState, useMemo, useRef, useCallback, useEffect } from "react";
12
- import { View, Text, StyleSheet, FlatList, TouchableOpacity } from "react-native";
13
- import { JsModal, ModalHeader, TabSelector, macOSColors, night, Search, Filter, Power, ToolEmptyState, resolveListEmptyState, devToolsStorageKeys, ProUpgradeModal, PowerToggleButton, ToolbarCopyButton, ToolbarClearButton, truncatePayload, TickProvider, useLimit, windowDragProps, EVENT_LIST_PRESET } from "@buoy-gg/shared-ui";
14
- import { useIsPro } from "@buoy-gg/license";
15
- import { useJotaiAtomChanges } from "../hooks/useJotaiAtomChanges";
16
- import { JotaiAtomChangeItem } from "./JotaiAtomChangeItem";
17
- import { JotaiAtomDetailContent, JotaiAtomDetailFooter } from "./JotaiAtomDetailContent";
18
- import { JotaiAtomBrowser } from "./JotaiAtomBrowser";
19
- import { JotaiAtomValueDetailContent } from "./JotaiAtomValueDetailContent";
20
- import { JotaiEventFilterView } from "./JotaiEventFilterView";
21
- import { JOTAI_TEST_ID, buildChangeRowTestIDs } from "../utils/testIDs";
22
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
23
- /**
24
- * The change log's "nothing here". Everything but the nouns is the shared
25
- * precedence — see `ToolEmptyState` in shared-ui for why it lives there.
26
- */
27
- function EventsEmptyState({
28
- isEnabled,
29
- searchText,
30
- totalChanges
31
- }) {
32
- const content = resolveListEmptyState({
33
- subject: "changes",
34
- search: searchText,
35
- total: totalChanges,
36
- capturing: isEnabled,
37
- waiting: {
38
- otto: "waiting",
39
- title: "No atom changes",
40
- message: "Atom changes will appear here as values update."
41
- },
42
- paused: {
43
- title: "No atom changes",
44
- message: "Enable capture to start recording atom changes"
45
- }
46
- });
47
- return /*#__PURE__*/_jsx(ToolEmptyState, {
48
- ...content,
49
- testID: `${JOTAI_TEST_ID}-empty`
50
- });
51
- }
52
- export function JotaiModal({
53
- visible,
54
- onClose,
55
- onBack,
56
- onMinimize,
57
- enableSharedModalDimensions = false,
58
- inline = false,
59
- initialTab,
60
- initialSelectedChangeId,
61
- initialDetailView,
62
- onChangeSelect,
63
- onAtomExpand
64
- }) {
65
- const isPro = useIsPro();
66
- const changeLimit = useLimit("jotai.changes");
67
- const [showProModal, setShowProModal] = useState(false);
68
- const [activeTab, setActiveTab] = useState(initialTab ?? "atoms");
69
- const [selectedHistoryAtom, setSelectedHistoryAtom] = useState(null);
70
- // The atom whose DETAIL SCREEN (live editor) is open — tapping a card in
71
- // the atoms tab navigates here, mirroring ZustandModal's selectedStoreName.
72
- const [selectedAtomLabel, setSelectedAtomLabel] = useState(null);
73
- const [showFilters, setShowFilters] = useState(false);
74
- const [ignoredPatterns, setIgnoredPatterns] = useState(new Set());
75
- const {
76
- atomChanges,
77
- filteredChanges,
78
- stats,
79
- filter,
80
- setFilter,
81
- atoms,
82
- clearChanges,
83
- isEnabled,
84
- toggleCapture
85
- } = useJotaiAtomChanges();
86
-
87
- // Apply ignored patterns on top of filteredChanges
88
- const displayedChanges = useMemo(() => {
89
- if (ignoredPatterns.size === 0) return filteredChanges;
90
- return filteredChanges.filter(c => !Array.from(ignoredPatterns).some(p => c.atomLabel.toLowerCase().includes(p.toLowerCase())));
91
- }, [filteredChanges, ignoredPatterns]);
92
-
93
- // Apply ignored patterns to atoms list
94
- const displayedAtoms = useMemo(() => {
95
- if (ignoredPatterns.size === 0) return atoms;
96
- return atoms.filter(a => !Array.from(ignoredPatterns).some(p => a.label.toLowerCase().includes(p.toLowerCase())));
97
- }, [atoms, ignoredPatterns]);
98
-
99
- // Pro is Infinity here, so slice() returns everything and the locked count
100
- // floors at 0 — no tier branching needed at the call site.
101
- /**
102
- * How many changes this atom has captured, before the search — the
103
- * denominator the scoped empty state needs so "clear the search" is only
104
- * offered when clearing it would actually show something.
105
- */
106
- const scopedTotalForAtom = useMemo(() => {
107
- if (!selectedHistoryAtom) return 0;
108
- return atomChanges.filter(c => c.atomLabel === selectedHistoryAtom).length;
109
- }, [atomChanges, selectedHistoryAtom]);
110
- const visibleChanges = useMemo(() => displayedChanges.slice(0, changeLimit), [displayedChanges, changeLimit]);
111
- const lockedChangeCount = useMemo(() => Math.max(0, displayedChanges.length - changeLimit), [displayedChanges.length, changeLimit]);
112
- const hasLockedChanges = lockedChangeCount > 0;
113
-
114
- // When viewing a single atom's history, scope the list
115
- const activeChanges = useMemo(() => {
116
- if (selectedHistoryAtom) {
117
- return visibleChanges.filter(c => c.atomLabel === selectedHistoryAtom);
118
- }
119
- return visibleChanges;
120
- }, [visibleChanges, selectedHistoryAtom]);
121
- const [selectedChangeId, setSelectedChangeId] = useState(null);
122
- const selectedChangeIndex = useMemo(() => {
123
- if (selectedChangeId === null) return null;
124
- const idx = activeChanges.findIndex(c => c.id === selectedChangeId);
125
- return idx >= 0 ? idx : null;
126
- }, [selectedChangeId, activeChanges]);
127
- const selectedChange = selectedChangeIndex !== null ? activeChanges[selectedChangeIndex] : null;
128
-
129
- // Notify the host on selection (keyed by change id so re-renders with the
130
- // same selection don't re-fire; remote mirrors fetch atom values here).
131
- const lastNotifiedSelectionRef = useRef(null);
132
- useEffect(() => {
133
- if (!onChangeSelect) return;
134
- const id = selectedChange?.id ?? null;
135
- if (id === null || id === lastNotifiedSelectionRef.current) return;
136
- lastNotifiedSelectionRef.current = id;
137
- onChangeSelect(selectedChange);
138
- }, [selectedChange, onChangeSelect]);
139
-
140
- // Deep link into a change's detail (see initialSelectedChangeId). Applied
141
- // at most once per id, so the list re-rendering — or the user pressing Back
142
- // — never re-opens it.
143
- const appliedInitialSelectionRef = useRef(null);
144
- useEffect(() => {
145
- if (!initialSelectedChangeId) return;
146
- if (appliedInitialSelectionRef.current === initialSelectedChangeId) return;
147
- const exists = activeChanges.some(c => c.id === initialSelectedChangeId);
148
- if (!exists) return;
149
- appliedInitialSelectionRef.current = initialSelectedChangeId;
150
- setSelectedChangeId(initialSelectedChangeId);
151
- }, [initialSelectedChangeId, activeChanges]);
152
-
153
- // Search state
154
- const [searchText, setSearchText] = useState("");
155
- const [isSearchActive, setIsSearchActive] = useState(false);
156
- const searchInputRef = useRef(null);
157
- const flatListRef = useRef(null);
158
- const handleModeChange = useCallback(_mode => {}, []);
159
- const handleSearch = text => {
160
- setSearchText(text);
161
- if (activeTab === "events") {
162
- setFilter(prev => ({
163
- ...prev,
164
- searchText: text
165
- }));
166
- }
167
- };
168
-
169
- /** Drop the query AND put the header back. The X, and only the X. */
170
- const closeSearch = useCallback(() => {
171
- setSearchText("");
172
- setFilter(prev => ({
173
- ...prev,
174
- searchText: ""
175
- }));
176
- setIsSearchActive(false);
177
- }, [setFilter]);
178
- const handleChangePress = useCallback(change => {
179
- setSelectedChangeId(change.id);
180
- }, []);
181
- const handleUpgradePress = useCallback(() => {
182
- setShowProModal(true);
183
- }, []);
184
- const handleBack = useCallback(() => {
185
- setSelectedChangeId(null);
186
- }, []);
187
- const handleIndexChange = useCallback(newIndex => {
188
- const change = activeChanges[newIndex];
189
- if (change) setSelectedChangeId(change.id);
190
- }, [activeChanges]);
191
- const handleViewHistory = useCallback(atomLabel => {
192
- setSelectedHistoryAtom(atomLabel);
193
- setSelectedChangeId(null);
194
- }, []);
195
- const handleBackFromHistory = useCallback(() => {
196
- setSelectedHistoryAtom(null);
197
- setSelectedChangeId(null);
198
- }, []);
199
- const handleOpenAtom = useCallback(label => {
200
- setSelectedAtomLabel(label);
201
- // The desktop mirror hydrates an atom's full value on demand — this is
202
- // the moment it becomes needed (was fired on card-expand before the
203
- // detail screen replaced expansion).
204
- onAtomExpand?.(label);
205
- }, [onAtomExpand]);
206
- const handleBackFromAtom = useCallback(() => {
207
- setSelectedAtomLabel(null);
208
- }, []);
209
- const getAtomsSnapshot = useCallback(() => {
210
- return displayedAtoms.reduce((acc, atom) => {
211
- try {
212
- const value = atom.getValue();
213
- acc[atom.label] = truncatePayload(value);
214
- } catch {
215
- acc[atom.label] = null;
216
- }
217
- return acc;
218
- }, {});
219
- }, [displayedAtoms]);
220
- const getEventsSnapshot = useCallback(() => {
221
- return displayedChanges.map(change => ({
222
- id: change.id,
223
- atomLabel: change.atomLabel,
224
- timestamp: change.timestamp,
225
- prevValue: truncatePayload(change.prevValue),
226
- nextValue: truncatePayload(change.nextValue),
227
- hasValueChange: change.hasValueChange,
228
- changedKeys: change.changedKeys
229
- }));
230
- }, [displayedChanges]);
231
- const getHistorySnapshot = useCallback(() => {
232
- return activeChanges.map(change => ({
233
- id: change.id,
234
- atomLabel: change.atomLabel,
235
- timestamp: change.timestamp,
236
- prevValue: truncatePayload(change.prevValue),
237
- nextValue: truncatePayload(change.nextValue),
238
- hasValueChange: change.hasValueChange,
239
- changedKeys: change.changedKeys
240
- }));
241
- }, [activeChanges]);
242
- const handleTogglePattern = useCallback(pattern => {
243
- setIgnoredPatterns(prev => {
244
- const next = new Set(prev);
245
- if (next.has(pattern)) next.delete(pattern);else next.add(pattern);
246
- return next;
247
- });
248
- }, []);
249
- const handleAddPattern = useCallback(pattern => {
250
- setIgnoredPatterns(prev => new Set([...prev, pattern]));
251
- }, []);
252
- const handleTabChange = useCallback(tab => {
253
- setActiveTab(tab);
254
- setSelectedHistoryAtom(null);
255
- setSelectedChangeId(null);
256
- setShowFilters(false);
257
- if (searchText) {
258
- setSearchText("");
259
- setFilter(prev => ({
260
- ...prev,
261
- searchText: ""
262
- }));
263
- }
264
- setIsSearchActive(false);
265
- }, [searchText, setFilter]);
266
- const keyExtractor = useCallback(item => item.id, []);
267
-
268
- // Built for the whole list rather than per row: the ordinal a row carries is
269
- // a fact about the list around it. See ../utils/testIDs.ts for why an atom
270
- // label alone cannot address a change row — and why the ordinal is counted
271
- // per atom when one write can move several.
272
- const rowTestIDs = useMemo(() => buildChangeRowTestIDs(activeChanges), [activeChanges]);
273
- const renderItem = useCallback(({
274
- item
275
- }) => /*#__PURE__*/_jsx(JotaiAtomChangeItem, {
276
- change: item,
277
- onPress: handleChangePress,
278
- testID: rowTestIDs.get(item.id)
279
- }), [handleChangePress, rowTestIDs]);
280
-
281
- // ---- Header ----
282
-
283
- const hasActiveFilters = ignoredPatterns.size > 0;
284
- const renderHeaderContent = () => {
285
- if (showFilters) {
286
- return /*#__PURE__*/_jsxs(ModalHeader, {
287
- children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
288
- onBack: () => setShowFilters(false)
289
- }), /*#__PURE__*/_jsx(ModalHeader.Content, {
290
- title: "Filters",
291
- centered: true,
292
- testID: `${JOTAI_TEST_ID}-filters-title`
293
- })]
294
- });
295
- }
296
- if (selectedChange) {
297
- return /*#__PURE__*/_jsxs(ModalHeader, {
298
- children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
299
- onBack: handleBack
300
- }), /*#__PURE__*/_jsx(ModalHeader.Content, {
301
- title: `${selectedChange.atomLabel}/${selectedChange.category}`,
302
- centered: true,
303
- testID: `${JOTAI_TEST_ID}-detail-title`
304
- })]
305
- });
306
- }
307
- if (selectedHistoryAtom) {
308
- return /*#__PURE__*/_jsxs(ModalHeader, {
309
- children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
310
- onBack: handleBackFromHistory
311
- }), /*#__PURE__*/_jsx(ModalHeader.Content, {
312
- title: `${selectedHistoryAtom} History`,
313
- centered: true,
314
- testID: `${JOTAI_TEST_ID}-history-title`
315
- }), /*#__PURE__*/_jsx(ModalHeader.Actions, {
316
- children: /*#__PURE__*/_jsx(ToolbarCopyButton, {
317
- value: getHistorySnapshot,
318
- disabled: activeChanges.length === 0,
319
- buttonStyle: styles.headerActionButton,
320
- testID: `${JOTAI_TEST_ID}-copy`
321
- })
322
- })]
323
- });
324
- }
325
-
326
- // Atom detail header — the live editor screen. AFTER history: history is
327
- // reached FROM the detail screen and stacks over it, so when both are set
328
- // history owns the header, and its back lands you back on the detail.
329
- if (selectedAtomLabel) {
330
- return /*#__PURE__*/_jsxs(ModalHeader, {
331
- children: [/*#__PURE__*/_jsx(ModalHeader.Navigation, {
332
- onBack: handleBackFromAtom
333
- }), /*#__PURE__*/_jsx(ModalHeader.Content, {
334
- title: selectedAtomLabel,
335
- centered: true,
336
- testID: `${JOTAI_TEST_ID}-atom-detail-title`
337
- })]
338
- });
339
- }
340
- return /*#__PURE__*/_jsxs(ModalHeader, {
341
- children: [onBack && /*#__PURE__*/_jsx(ModalHeader.Navigation, {
342
- onBack: onBack
343
- }), /*#__PURE__*/_jsx(ModalHeader.Content, {
344
- title: "",
345
- children: isSearchActive ? /*#__PURE__*/_jsx(ModalHeader.Search, {
346
- value: searchText,
347
- onChangeText: handleSearch,
348
- onCancel: closeSearch,
349
- placeholder: activeTab === "atoms" ? "Search atoms..." : "Search events...",
350
- accessibilityLabel: "Search Jotai atoms and events",
351
- iconSize: 14,
352
- testID: `${JOTAI_TEST_ID}-search-field`,
353
- containerTestID: `${JOTAI_TEST_ID}-search`,
354
- clearTestID: `${JOTAI_TEST_ID}-search-clear`
355
- // Stay open while the query is still narrowing the list: the
356
- // header would otherwise go back to its TABS — whose counts are
357
- // of the WHOLE capture — above a filtered list, with nothing to
358
- // explain the gap.
359
- ,
360
- onBlur: () => {
361
- if (searchText.length === 0) setIsSearchActive(false);
362
- }
363
- }) : /*#__PURE__*/_jsx(TabSelector, {
364
- tabs: [{
365
- key: "atoms",
366
- label: `Atoms${displayedAtoms.length > 0 ? ` (${displayedAtoms.length})` : ""}`
367
- }, {
368
- key: "events",
369
- label: `Events${displayedChanges.length > 0 ? ` (${displayedChanges.length})` : ""}`
370
- }],
371
- activeTab: activeTab,
372
- onTabChange: handleTabChange,
373
- testIDPrefix: `${JOTAI_TEST_ID}-tab`
374
- })
375
- }), /*#__PURE__*/_jsxs(ModalHeader.Actions, {
376
- children: [/*#__PURE__*/_jsx(TouchableOpacity, {
377
- onPress: () => setIsSearchActive(true),
378
- style: styles.headerActionButton,
379
- testID: `${JOTAI_TEST_ID}-search-open`,
380
- accessibilityRole: "button",
381
- accessibilityLabel: "Search",
382
- children: /*#__PURE__*/_jsx(Search, {
383
- size: 14,
384
- color: night.color.textSecondary
385
- })
386
- }), /*#__PURE__*/_jsx(TouchableOpacity, {
387
- onPress: () => setShowFilters(true),
388
- style: [styles.headerActionButton, hasActiveFilters && styles.headerActionButtonActive],
389
- testID: `${JOTAI_TEST_ID}-filters-open`,
390
- accessibilityRole: "button",
391
- accessibilityLabel: "Filters",
392
- children: /*#__PURE__*/_jsx(Filter, {
393
- size: 14,
394
- color: hasActiveFilters ? night.color.accent : night.color.textSecondary
395
- })
396
- }), activeTab === "atoms" && /*#__PURE__*/_jsx(ToolbarCopyButton, {
397
- value: getAtomsSnapshot,
398
- disabled: displayedAtoms.length === 0,
399
- buttonStyle: styles.headerActionButton,
400
- testID: `${JOTAI_TEST_ID}-copy`
401
- }), activeTab === "events" && /*#__PURE__*/_jsxs(_Fragment, {
402
- children: [/*#__PURE__*/_jsx(ToolbarCopyButton, {
403
- value: getEventsSnapshot,
404
- disabled: displayedChanges.length === 0,
405
- buttonStyle: styles.headerActionButton,
406
- testID: `${JOTAI_TEST_ID}-copy`
407
- }), /*#__PURE__*/_jsx(PowerToggleButton, {
408
- isEnabled: isEnabled,
409
- onToggle: toggleCapture,
410
- accessibilityLabel: "Toggle Jotai atom capture",
411
- testID: `${JOTAI_TEST_ID}-power`
412
- }), /*#__PURE__*/_jsx(ToolbarClearButton, {
413
- onPress: clearChanges,
414
- disabled: displayedChanges.length === 0,
415
- buttonStyle: styles.headerActionButton,
416
- testID: `${JOTAI_TEST_ID}-clear`
417
- })]
418
- })]
419
- })]
420
- });
421
- };
422
-
423
- // ---- Content ----
424
-
425
- const renderContent = () => {
426
- if (showFilters) {
427
- return /*#__PURE__*/_jsx(JotaiEventFilterView, {
428
- ignoredPatterns: ignoredPatterns,
429
- onTogglePattern: handleTogglePattern,
430
- onAddPattern: handleAddPattern,
431
- atoms: atoms
432
- });
433
- }
434
- if (selectedChange && selectedChangeIndex !== null) {
435
- return /*#__PURE__*/_jsx(JotaiAtomDetailContent, {
436
- change: selectedChange,
437
- changes: activeChanges,
438
- selectedIndex: selectedChangeIndex,
439
- onIndexChange: handleIndexChange,
440
- disableInternalFooter: true,
441
- initialView: initialDetailView
442
- });
443
- }
444
- if (selectedHistoryAtom) {
445
- return activeChanges.length > 0 ? /*#__PURE__*/_jsx(FlatList, {
446
- testID: `${JOTAI_TEST_ID}-history-list`,
447
- data: activeChanges,
448
- renderItem: renderItem,
449
- keyExtractor: keyExtractor,
450
- contentContainerStyle: styles.listContent,
451
- showsVerticalScrollIndicator: true,
452
- ...EVENT_LIST_PRESET
453
- }) : /*#__PURE__*/_jsx(ToolEmptyState, {
454
- testID: `${JOTAI_TEST_ID}-history-empty`,
455
- ...resolveListEmptyState({
456
- subject: "changes",
457
- search: searchText,
458
- // Scoped to one atom, so the count is this atom's, not the log's.
459
- total: searchText.trim() ? scopedTotalForAtom : 0,
460
- capturing: isEnabled,
461
- waiting: {
462
- otto: "waiting",
463
- title: "No history yet",
464
- message: `Changes for ${selectedHistoryAtom} will appear here.`
465
- },
466
- paused: {
467
- title: "No history yet",
468
- message: `Capture is off, so nothing is being recorded for ${selectedHistoryAtom}.`
469
- }
470
- })
471
- });
472
- }
473
-
474
- // Atom detail — live editor + details + read-only tree. After history in
475
- // this chain for the same reason as the header: history stacks OVER the
476
- // detail screen it was opened from.
477
- if (selectedAtomLabel) {
478
- const selectedAtom = atoms.find(a => a.label === selectedAtomLabel) ?? null;
479
- return selectedAtom ? /*#__PURE__*/_jsx(JotaiAtomValueDetailContent, {
480
- atom: selectedAtom,
481
- onViewHistory: handleViewHistory
482
- }) :
483
- /*#__PURE__*/
484
- // The atom fell out of the registry mid-look (a remote mirror
485
- // rebuilding, a dev reload). Say so rather than rendering nothing.
486
- _jsx(ToolEmptyState, {
487
- testID: `${JOTAI_TEST_ID}-atom-detail-gone`
488
- // ghost = "stale or detached: an unmounted component, an old
489
- // snapshot" — exactly what the atom you are looking at has become.
490
- ,
491
- otto: "ghost",
492
- title: "Atom no longer registered",
493
- message: `${selectedAtomLabel} is not in the registry any more.`
494
- });
495
- }
496
- if (activeTab === "atoms") {
497
- return /*#__PURE__*/_jsx(JotaiAtomBrowser, {
498
- atoms: displayedAtoms,
499
- searchQuery: searchText,
500
- onOpenAtom: handleOpenAtom
501
- });
502
- }
503
-
504
- // Events tab
505
- return /*#__PURE__*/_jsxs(_Fragment, {
506
- children: [!isEnabled && /*#__PURE__*/_jsxs(View, {
507
- style: styles.disabledBanner,
508
- testID: `${JOTAI_TEST_ID}-banner-disabled`,
509
- children: [/*#__PURE__*/_jsx(Power, {
510
- size: 14,
511
- color: night.color.warning
512
- }), /*#__PURE__*/_jsx(Text, {
513
- style: styles.disabledText,
514
- children: "Atom capture is disabled"
515
- })]
516
- }), hasLockedChanges && /*#__PURE__*/_jsxs(TouchableOpacity, {
517
- style: styles.lockedBanner,
518
- onPress: handleUpgradePress,
519
- activeOpacity: 0.8,
520
- testID: `${JOTAI_TEST_ID}-locked`,
521
- accessibilityRole: "button",
522
- children: [/*#__PURE__*/_jsxs(Text, {
523
- style: styles.lockedText,
524
- testID: `${JOTAI_TEST_ID}-locked-text`,
525
- children: [lockedChangeCount, " older", " ", lockedChangeCount === 1 ? "change" : "changes", " locked"]
526
- }), /*#__PURE__*/_jsx(View, {
527
- style: styles.upgradeBadge,
528
- children: /*#__PURE__*/_jsx(Text, {
529
- style: styles.upgradeBadgeText,
530
- children: "UPGRADE"
531
- })
532
- })]
533
- }), visibleChanges.length > 0 ? /*#__PURE__*/_jsx(FlatList, {
534
- ref: flatListRef,
535
- testID: `${JOTAI_TEST_ID}-list`,
536
- data: visibleChanges,
537
- renderItem: renderItem,
538
- keyExtractor: keyExtractor,
539
- contentContainerStyle: styles.listContent,
540
- showsVerticalScrollIndicator: true,
541
- ...EVENT_LIST_PRESET
542
- }) : /*#__PURE__*/_jsx(EventsEmptyState, {
543
- isEnabled: isEnabled,
544
- searchText: searchText,
545
- totalChanges: stats.totalChanges
546
- })]
547
- });
548
- };
549
- const persistenceKey = enableSharedModalDimensions ? devToolsStorageKeys.modal.root() : "buoy-jotai-modal";
550
- if (!visible) return null;
551
- const footerNode = selectedChange && selectedChangeIndex !== null ? /*#__PURE__*/_jsx(JotaiAtomDetailFooter, {
552
- change: selectedChange,
553
- changes: activeChanges,
554
- selectedIndex: selectedChangeIndex,
555
- onIndexChange: handleIndexChange
556
- }) : null;
557
- const footerHeight = selectedChange && activeChanges.length > 1 ? 68 : 0;
558
-
559
- // The change lists own their scrolling so FlatList virtualization works
560
- // (see ZustandModal — same pattern, same tool-bench evidence).
561
- //
562
- // The ATOM BROWSER owns its scrolling too, which is not just symmetry. Its
563
- // root is a `flex: 1` ScrollView, and a `flex: 1` box inside JsModal's
564
- // wrapper (`contentContainerStyle: { flexGrow: 1 }`) resolves against the
565
- // CONTENT rather than the viewport — so nested, the browser's scroller grows
566
- // to the full height of its rows, never scrolls, and the wrapper scrolls
567
- // instead. A person cannot tell the difference; automation can, because the
568
- // scroller Detox is handed reports a frame taller than the sheet and every
569
- // scroll start point it computes lands in the clipped part ("view is not
570
- // scrollable at the given start point"). Owning the scroll gives the browser
571
- // a bounded height and one scroller with a handle on it.
572
- const listOwnsScroll = !showFilters && !(selectedChange && selectedChangeIndex !== null) && (selectedHistoryAtom != null || activeTab === "events" || activeTab === "atoms");
573
- const body = /*#__PURE__*/_jsx(View, {
574
- style: styles.container,
575
- children: renderContent()
576
- });
577
- const upgradeModal = /*#__PURE__*/_jsx(ProUpgradeModal, {
578
- visible: showProModal,
579
- onClose: () => setShowProModal(false),
580
- featureName: "Full Atom History"
581
- });
582
-
583
- // The ONLY difference between the phone and the desktop dashboard is which
584
- // shell the header, body and footer go in. Everything above this is shared.
585
- if (inline) {
586
- return /*#__PURE__*/_jsxs(TickProvider, {
587
- children: [/*#__PURE__*/_jsxs(View, {
588
- style: styles.inlineRoot,
589
- children: [/*#__PURE__*/_jsx(View, {
590
- style: styles.inlineHeader,
591
- ...windowDragProps,
592
- children: renderHeaderContent()
593
- }), body, footerNode]
594
- }), upgradeModal]
595
- });
596
- }
597
- return /*#__PURE__*/_jsxs(TickProvider, {
598
- children: [/*#__PURE__*/_jsx(JsModal, {
599
- visible: visible,
600
- onClose: onClose,
601
- onMinimize: onMinimize,
602
- persistenceKey: persistenceKey,
603
- header: {
604
- showToggleButton: true,
605
- customContent: renderHeaderContent()
606
- },
607
- onModeChange: handleModeChange,
608
- enablePersistence: true,
609
- initialMode: "bottomSheet",
610
- enableGlitchEffects: true,
611
- disableScrollWrapper: listOwnsScroll,
612
- styles: {},
613
- footer: footerNode,
614
- footerHeight: footerHeight,
615
- children: body
616
- }), upgradeModal]
617
- });
618
- }
619
-
620
- /**
621
- * The same Jotai tool as `JotaiModal`, rendered INLINE for hosts that
622
- * place tools in a panel rather than a modal. Exists so there is exactly one
623
- * implementation — the desktop dashboard used to ship its own copy of this
624
- * component's header, list and detail router.
625
- */
626
- export function JotaiToolView(props = {}) {
627
- return /*#__PURE__*/_jsx(JotaiModal, {
628
- ...props,
629
- inline: true,
630
- visible: true,
631
- onClose: () => {}
632
- });
633
- }
634
- const styles = StyleSheet.create({
635
- inlineRoot: {
636
- flex: 1,
637
- minHeight: 0,
638
- // Transparent so the desktop workbench's hoisted sky (one field spanning
639
- // sidebar + panel) shows through — the shell paints its base beneath it.
640
- backgroundColor: "transparent"
641
- },
642
- inlineHeader: {
643
- paddingHorizontal: 12,
644
- paddingVertical: 8,
645
- borderBottomWidth: 1,
646
- borderBottomColor: macOSColors.border.default,
647
- // Clear, like Bench — the host's hoisted sky shows through the header.
648
- backgroundColor: "transparent"
649
- },
650
- container: {
651
- flex: 1,
652
- // Transparent so JsModal's ToolBackground shows through (the modal paints opaque base beneath it).
653
- backgroundColor: "transparent"
654
- },
655
- headerActionButton: {
656
- width: 32,
657
- height: 32,
658
- borderRadius: 8,
659
- backgroundColor: night.color.buttonSurface,
660
- borderWidth: 1,
661
- borderColor: night.color.border,
662
- alignItems: "center",
663
- justifyContent: "center"
664
- },
665
- headerActionButtonActive: {
666
- backgroundColor: night.color.accentSoft,
667
- borderColor: night.color.accentBorderStrong
668
- },
669
- disabledBanner: {
670
- flexDirection: "row",
671
- alignItems: "center",
672
- gap: 8,
673
- padding: 10,
674
- marginHorizontal: 12,
675
- marginTop: 8,
676
- backgroundColor: night.color.warningSoft,
677
- borderRadius: 8,
678
- borderWidth: 1,
679
- borderColor: night.color.warning + "20"
680
- },
681
- disabledText: {
682
- color: night.color.warning,
683
- fontSize: 11,
684
- flex: 1
685
- },
686
- listContent: {
687
- paddingTop: 8,
688
- paddingBottom: 20
689
- },
690
- emptyState: {
691
- alignItems: "center",
692
- paddingVertical: 40
693
- },
694
- emptyTitle: {
695
- color: night.color.text,
696
- fontSize: 14,
697
- fontWeight: "600",
698
- marginTop: 12,
699
- marginBottom: 6
700
- },
701
- emptyText: {
702
- color: night.color.textTertiary,
703
- fontSize: 12,
704
- textAlign: "center",
705
- lineHeight: 18
706
- },
707
- lockedBanner: {
708
- flexDirection: "row",
709
- alignItems: "center",
710
- gap: 8,
711
- padding: 10,
712
- marginHorizontal: 12,
713
- marginTop: 8,
714
- backgroundColor: night.color.accentSoft,
715
- borderRadius: 8,
716
- borderWidth: 1,
717
- borderColor: night.color.accentBorder
718
- },
719
- lockedText: {
720
- color: night.color.accent,
721
- fontSize: 11,
722
- fontWeight: "500",
723
- flex: 1
724
- },
725
- upgradeBadge: {
726
- backgroundColor: night.color.accent,
727
- paddingHorizontal: 8,
728
- paddingVertical: 3,
729
- borderRadius: 4
730
- },
731
- upgradeBadgeText: {
732
- // Ink on a filled accent surface — the night accent takes dark ink.
733
- color: night.color.onAccent,
734
- fontSize: 9,
735
- fontWeight: "700",
736
- letterSpacing: 0.5
737
- }
738
- });
1
+ "use strict";import{useState as g,useMemo as p,useRef as E,useCallback as r,useEffect as he}from"react";import{View as D,Text as K,StyleSheet as qe,FlatList as ge,TouchableOpacity as O}from"react-native";import{JsModal as Xe,ModalHeader as a,TabSelector as Ye,macOSColors as Ze,night as l,Search as et,Filter as tt,Power as ot,ToolEmptyState as U,resolveListEmptyState as me,devToolsStorageKeys as nt,ProUpgradeModal as rt,PowerToggleButton as at,ToolbarCopyButton as G,ToolbarClearButton as lt,truncatePayload as $,TickProvider as fe,useLimit as st,windowDragProps as it,EVENT_LIST_PRESET as be}from"@buoy-gg/shared-ui";import{useIsPro as ct}from"@buoy-gg/license";import{useJotaiAtomChanges as dt}from"../hooks/useJotaiAtomChanges";import{JotaiAtomChangeItem as ut}from"./JotaiAtomChangeItem";import{JotaiAtomDetailContent as ht,JotaiAtomDetailFooter as gt}from"./JotaiAtomDetailContent";import{JotaiAtomBrowser as mt}from"./JotaiAtomBrowser";import{JotaiAtomValueDetailContent as ft}from"./JotaiAtomValueDetailContent";import{JotaiEventFilterView as bt}from"./JotaiEventFilterView";import{JOTAI_TEST_ID as n,buildChangeRowTestIDs as pt}from"../utils/testIDs";import{jsx as t,jsxs as c,Fragment as pe}from"react/jsx-runtime";function yt({isEnabled:S,searchText:H,totalChanges:k}){const z=me({subject:"changes",search:H,total:k,capturing:S,waiting:{otto:"waiting",title:"No atom changes",message:"Atom changes will appear here as values update."},paused:{title:"No atom changes",message:"Enable capture to start recording atom changes"}});return t(U,{...z,testID:`${n}-empty`})}export function JotaiModal({visible:S,onClose:H,onBack:k,onMinimize:z,enableSharedModalDimensions:ye=false,inline:Ce=false,initialTab:Se,initialSelectedChangeId:T,initialDetailView:Te,onChangeSelect:F,onAtomExpand:Q}){const Ct=ct();const P=st("jotai.changes");const[xe,q]=g(false);const[m,Ie]=g(Se??"atoms");const[d,R]=g(null);const[A,X]=g(null);const[J,M]=g(false);const[f,Y]=g(new Set);const{atomChanges:Z,filteredChanges:N,stats:Ae,filter:St,setFilter:w,atoms:v,clearChanges:we,isEnabled:V,toggleCapture:ve}=dt();const h=p(()=>{if(f.size===0)return N;return N.filter(e=>!Array.from(f).some(o=>e.atomLabel.toLowerCase().includes(o.toLowerCase())))},[N,f]);const x=p(()=>{if(f.size===0)return v;return v.filter(e=>!Array.from(f).some(o=>e.label.toLowerCase().includes(o.toLowerCase())))},[v,f]);const Be=p(()=>{if(!d)return 0;return Z.filter(e=>e.atomLabel===d).length},[Z,d]);const B=p(()=>h.slice(0,P),[h,P]);const j=p(()=>Math.max(0,h.length-P),[h.length,P]);const De=j>0;const i=p(()=>{if(d){return B.filter(e=>e.atomLabel===d)}return B},[B,d]);const[W,y]=g(null);const C=p(()=>{if(W===null)return null;const e=i.findIndex(o=>o.id===W);return e>=0?e:null},[W,i]);const u=C!==null?i[C]:null;const ee=E(null);he(()=>{if(!F)return;const e=u?.id??null;if(e===null||e===ee.current)return;ee.current=e;F(u)},[u,F]);const te=E(null);he(()=>{if(!T)return;if(te.current===T)return;const e=i.some(o=>o.id===T);if(!e)return;te.current=T;y(T)},[T,i]);const[b,_]=g("");const[$e,L]=g(false);const Tt=E(null);const ke=E(null);const Pe=r(e=>{},[]);const Ve=e=>{_(e);if(m==="events"){w(o=>({...o,searchText:e}))}};const Le=r(()=>{_("");w(e=>({...e,searchText:""}));L(false)},[w]);const oe=r(e=>{y(e.id)},[]);const Ee=r(()=>{q(true)},[]);const He=r(()=>{y(null)},[]);const ne=r(e=>{const o=i[e];if(o)y(o.id)},[i]);const ze=r(e=>{R(e);y(null)},[]);const Fe=r(()=>{R(null);y(null)},[]);const Re=r(e=>{X(e);Q?.(e)},[Q]);const Je=r(()=>{X(null)},[]);const Me=r(()=>{return x.reduce((e,o)=>{try{const I=o.getValue();e[o.label]=$(I)}catch{e[o.label]=null}return e},{})},[x]);const Ne=r(()=>{return h.map(e=>({id:e.id,atomLabel:e.atomLabel,timestamp:e.timestamp,prevValue:$(e.prevValue),nextValue:$(e.nextValue),hasValueChange:e.hasValueChange,changedKeys:e.changedKeys}))},[h]);const je=r(()=>{return i.map(e=>({id:e.id,atomLabel:e.atomLabel,timestamp:e.timestamp,prevValue:$(e.prevValue),nextValue:$(e.nextValue),hasValueChange:e.hasValueChange,changedKeys:e.changedKeys}))},[i]);const We=r(e=>{Y(o=>{const I=new Set(o);if(I.has(e))I.delete(e);else I.add(e);return I})},[]);const _e=r(e=>{Y(o=>new Set([...o,e]))},[]);const Ke=r(e=>{Ie(e);R(null);y(null);M(false);if(b){_("");w(o=>({...o,searchText:""}))}L(false)},[b,w]);const re=r(e=>e.id,[]);const ae=p(()=>pt(i),[i]);const le=r(({item:e})=>t(ut,{change:e,onPress:oe,testID:ae.get(e.id)}),[oe,ae]);const se=f.size>0;const ie=()=>{if(J){return c(a,{children:[t(a.Navigation,{onBack:()=>M(false)}),t(a.Content,{title:"Filters",centered:true,testID:`${n}-filters-title`})]})}if(u){return c(a,{children:[t(a.Navigation,{onBack:He}),t(a.Content,{title:`${u.atomLabel}/${u.category}`,centered:true,testID:`${n}-detail-title`})]})}if(d){return c(a,{children:[t(a.Navigation,{onBack:Fe}),t(a.Content,{title:`${d} History`,centered:true,testID:`${n}-history-title`}),t(a.Actions,{children:t(G,{value:je,disabled:i.length===0,buttonStyle:s.headerActionButton,testID:`${n}-copy`})})]})}if(A){return c(a,{children:[t(a.Navigation,{onBack:Je}),t(a.Content,{title:A,centered:true,testID:`${n}-atom-detail-title`})]})}return c(a,{children:[k&&t(a.Navigation,{onBack:k}),t(a.Content,{title:"",children:$e?t(a.Search,{value:b,onChangeText:Ve,onCancel:Le,placeholder:m==="atoms"?"Search atoms...":"Search events...",accessibilityLabel:"Search Jotai atoms and events",iconSize:14,testID:`${n}-search-field`,containerTestID:`${n}-search`,clearTestID:`${n}-search-clear`,onBlur:()=>{if(b.length===0)L(false)}}):t(Ye,{tabs:[{key:"atoms",label:`Atoms${x.length>0?` (${x.length})`:""}`},{key:"events",label:`Events${h.length>0?` (${h.length})`:""}`}],activeTab:m,onTabChange:Ke,testIDPrefix:`${n}-tab`})}),c(a.Actions,{children:[t(O,{onPress:()=>L(true),style:s.headerActionButton,testID:`${n}-search-open`,accessibilityRole:"button",accessibilityLabel:"Search",children:t(et,{size:14,color:l.color.textSecondary})}),t(O,{onPress:()=>M(true),style:[s.headerActionButton,se&&s.headerActionButtonActive],testID:`${n}-filters-open`,accessibilityRole:"button",accessibilityLabel:"Filters",children:t(tt,{size:14,color:se?l.color.accent:l.color.textSecondary})}),m==="atoms"&&t(G,{value:Me,disabled:x.length===0,buttonStyle:s.headerActionButton,testID:`${n}-copy`}),m==="events"&&c(pe,{children:[t(G,{value:Ne,disabled:h.length===0,buttonStyle:s.headerActionButton,testID:`${n}-copy`}),t(at,{isEnabled:V,onToggle:ve,accessibilityLabel:"Toggle Jotai atom capture",testID:`${n}-power`}),t(lt,{onPress:we,disabled:h.length===0,buttonStyle:s.headerActionButton,testID:`${n}-clear`})]})]})]})};const Oe=()=>{if(J){return t(bt,{ignoredPatterns:f,onTogglePattern:We,onAddPattern:_e,atoms:v})}if(u&&C!==null){return t(ht,{change:u,changes:i,selectedIndex:C,onIndexChange:ne,disableInternalFooter:true,initialView:Te})}if(d){return i.length>0?t(ge,{testID:`${n}-history-list`,data:i,renderItem:le,keyExtractor:re,contentContainerStyle:s.listContent,showsVerticalScrollIndicator:true,...be}):t(U,{testID:`${n}-history-empty`,...me({subject:"changes",search:b,total:b.trim()?Be:0,capturing:V,waiting:{otto:"waiting",title:"No history yet",message:`Changes for ${d} will appear here.`},paused:{title:"No history yet",message:`Capture is off, so nothing is being recorded for ${d}.`}})})}if(A){const e=v.find(o=>o.label===A)??null;return e?t(ft,{atom:e,onViewHistory:ze}):t(U,{testID:`${n}-atom-detail-gone`,otto:"ghost",title:"Atom no longer registered",message:`${A} is not in the registry any more.`})}if(m==="atoms"){return t(mt,{atoms:x,searchQuery:b,onOpenAtom:Re})}return c(pe,{children:[!V&&c(D,{style:s.disabledBanner,testID:`${n}-banner-disabled`,children:[t(ot,{size:14,color:l.color.warning}),t(K,{style:s.disabledText,children:"Atom capture is disabled"})]}),De&&c(O,{style:s.lockedBanner,onPress:Ee,activeOpacity:.8,testID:`${n}-locked`,accessibilityRole:"button",children:[c(K,{style:s.lockedText,testID:`${n}-locked-text`,children:[j," older"," ",j===1?"change":"changes"," locked"]}),t(D,{style:s.upgradeBadge,children:t(K,{style:s.upgradeBadgeText,children:"UPGRADE"})})]}),B.length>0?t(ge,{ref:ke,testID:`${n}-list`,data:B,renderItem:le,keyExtractor:re,contentContainerStyle:s.listContent,showsVerticalScrollIndicator:true,...be}):t(yt,{isEnabled:V,searchText:b,totalChanges:Ae.totalChanges})]})};const Ue=ye?nt.modal.root():"buoy-jotai-modal";if(!S)return null;const ce=u&&C!==null?t(gt,{change:u,changes:i,selectedIndex:C,onIndexChange:ne}):null;const Ge=u&&i.length>1?68:0;const Qe=!J&&!(u&&C!==null)&&(d!=null||m==="events"||m==="atoms");const de=t(D,{style:s.container,children:Oe()});const ue=t(rt,{visible:xe,onClose:()=>q(false),featureName:"Full Atom History"});if(Ce){return c(fe,{children:[c(D,{style:s.inlineRoot,children:[t(D,{style:s.inlineHeader,...it,children:ie()}),de,ce]}),ue]})}return c(fe,{children:[t(Xe,{visible:S,onClose:H,onMinimize:z,persistenceKey:Ue,header:{showToggleButton:true,customContent:ie()},onModeChange:Pe,enablePersistence:true,initialMode:"bottomSheet",enableGlitchEffects:true,disableScrollWrapper:Qe,styles:{},footer:ce,footerHeight:Ge,children:de}),ue]})}export function JotaiToolView(S={}){return t(JotaiModal,{...S,inline:true,visible:true,onClose:()=>{}})}const s=qe.create({inlineRoot:{flex:1,minHeight:0,backgroundColor:"transparent"},inlineHeader:{paddingHorizontal:12,paddingVertical:8,borderBottomWidth:1,borderBottomColor:Ze.border.default,backgroundColor:"transparent"},container:{flex:1,backgroundColor:"transparent"},headerActionButton:{width:32,height:32,borderRadius:8,backgroundColor:l.color.buttonSurface,borderWidth:1,borderColor:l.color.border,alignItems:"center",justifyContent:"center"},headerActionButtonActive:{backgroundColor:l.color.accentSoft,borderColor:l.color.accentBorderStrong},disabledBanner:{flexDirection:"row",alignItems:"center",gap:8,padding:10,marginHorizontal:12,marginTop:8,backgroundColor:l.color.warningSoft,borderRadius:8,borderWidth:1,borderColor:l.color.warning+"20"},disabledText:{color:l.color.warning,fontSize:11,flex:1},listContent:{paddingTop:8,paddingBottom:20},emptyState:{alignItems:"center",paddingVertical:40},emptyTitle:{color:l.color.text,fontSize:14,fontWeight:"600",marginTop:12,marginBottom:6},emptyText:{color:l.color.textTertiary,fontSize:12,textAlign:"center",lineHeight:18},lockedBanner:{flexDirection:"row",alignItems:"center",gap:8,padding:10,marginHorizontal:12,marginTop:8,backgroundColor:l.color.accentSoft,borderRadius:8,borderWidth:1,borderColor:l.color.accentBorder},lockedText:{color:l.color.accent,fontSize:11,fontWeight:"500",flex:1},upgradeBadge:{backgroundColor:l.color.accent,paddingHorizontal:8,paddingVertical:3,borderRadius:4},upgradeBadgeText:{color:l.color.onAccent,fontSize:9,fontWeight:"700",letterSpacing:.5}});