@buoy-gg/time-machine 7.0.22 → 7.0.24

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 (160) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +147 -677
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/components/TimeMachineModal.js +10 -11
  4. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  5. package/lib/commonjs/components/confirm.js +39 -0
  6. package/lib/commonjs/components/confirm.js.map +1 -0
  7. package/lib/commonjs/components/format.js +24 -0
  8. package/lib/commonjs/components/format.js.map +1 -0
  9. package/lib/commonjs/components/list/CaptureBar.js +95 -0
  10. package/lib/commonjs/components/list/CaptureBar.js.map +1 -0
  11. package/lib/commonjs/components/list/RestoreOutcomeCard.js +162 -0
  12. package/lib/commonjs/components/list/RestoreOutcomeCard.js.map +1 -0
  13. package/lib/commonjs/components/list/SnapshotRow.js +141 -0
  14. package/lib/commonjs/components/list/SnapshotRow.js.map +1 -0
  15. package/lib/commonjs/components/list/SourcesStrip.js +126 -0
  16. package/lib/commonjs/components/list/SourcesStrip.js.map +1 -0
  17. package/lib/commonjs/components/list/UndoDeleteBar.js +81 -0
  18. package/lib/commonjs/components/list/UndoDeleteBar.js.map +1 -0
  19. package/lib/commonjs/components/list/WipeAllRow.js +46 -0
  20. package/lib/commonjs/components/list/WipeAllRow.js.map +1 -0
  21. package/lib/commonjs/components/preview/ItemDiffPanel.js +77 -0
  22. package/lib/commonjs/components/preview/ItemDiffPanel.js.map +1 -0
  23. package/lib/commonjs/components/preview/PreviewFlow.js +51 -0
  24. package/lib/commonjs/components/preview/PreviewFlow.js.map +1 -0
  25. package/lib/commonjs/components/preview/RestorePointNameRow.js +83 -0
  26. package/lib/commonjs/components/preview/RestorePointNameRow.js.map +1 -0
  27. package/lib/commonjs/components/preview/RestorePreviewScreen.js +549 -635
  28. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -1
  29. package/lib/commonjs/components/preview/verdictMeta.js +8 -8
  30. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -1
  31. package/lib/commonjs/components/sources.js +72 -0
  32. package/lib/commonjs/components/sources.js.map +1 -0
  33. package/lib/commonjs/store/snapshotVault.js +93 -9
  34. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  35. package/lib/commonjs/store/timeMachineStore.js +125 -6
  36. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  37. package/lib/commonjs/sync/timeMachineSyncAdapter.js +41 -0
  38. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  39. package/lib/commonjs/ui/SwipeActionsRow.js +167 -0
  40. package/lib/commonjs/ui/SwipeActionsRow.js.map +1 -0
  41. package/lib/commonjs/ui/primitives.js +164 -0
  42. package/lib/commonjs/ui/primitives.js.map +1 -0
  43. package/lib/commonjs/ui/tmTheme.js +103 -0
  44. package/lib/commonjs/ui/tmTheme.js.map +1 -0
  45. package/lib/module/components/TimeMachineBody.js +150 -680
  46. package/lib/module/components/TimeMachineBody.js.map +1 -1
  47. package/lib/module/components/TimeMachineModal.js +11 -12
  48. package/lib/module/components/TimeMachineModal.js.map +1 -1
  49. package/lib/module/components/confirm.js +34 -0
  50. package/lib/module/components/confirm.js.map +1 -0
  51. package/lib/module/components/format.js +19 -0
  52. package/lib/module/components/format.js.map +1 -0
  53. package/lib/module/components/list/CaptureBar.js +90 -0
  54. package/lib/module/components/list/CaptureBar.js.map +1 -0
  55. package/lib/module/components/list/RestoreOutcomeCard.js +156 -0
  56. package/lib/module/components/list/RestoreOutcomeCard.js.map +1 -0
  57. package/lib/module/components/list/SnapshotRow.js +138 -0
  58. package/lib/module/components/list/SnapshotRow.js.map +1 -0
  59. package/lib/module/components/list/SourcesStrip.js +121 -0
  60. package/lib/module/components/list/SourcesStrip.js.map +1 -0
  61. package/lib/module/components/list/UndoDeleteBar.js +78 -0
  62. package/lib/module/components/list/UndoDeleteBar.js.map +1 -0
  63. package/lib/module/components/list/WipeAllRow.js +41 -0
  64. package/lib/module/components/list/WipeAllRow.js.map +1 -0
  65. package/lib/module/components/preview/ItemDiffPanel.js +72 -0
  66. package/lib/module/components/preview/ItemDiffPanel.js.map +1 -0
  67. package/lib/module/components/preview/PreviewFlow.js +46 -0
  68. package/lib/module/components/preview/PreviewFlow.js.map +1 -0
  69. package/lib/module/components/preview/RestorePointNameRow.js +78 -0
  70. package/lib/module/components/preview/RestorePointNameRow.js.map +1 -0
  71. package/lib/module/components/preview/RestorePreviewScreen.js +552 -638
  72. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -1
  73. package/lib/module/components/preview/verdictMeta.js +9 -8
  74. package/lib/module/components/preview/verdictMeta.js.map +1 -1
  75. package/lib/module/components/sources.js +66 -0
  76. package/lib/module/components/sources.js.map +1 -0
  77. package/lib/module/store/snapshotVault.js +87 -9
  78. package/lib/module/store/snapshotVault.js.map +1 -1
  79. package/lib/module/store/timeMachineStore.js +125 -7
  80. package/lib/module/store/timeMachineStore.js.map +1 -1
  81. package/lib/module/sync/timeMachineSyncAdapter.js +41 -0
  82. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  83. package/lib/module/ui/SwipeActionsRow.js +162 -0
  84. package/lib/module/ui/SwipeActionsRow.js.map +1 -0
  85. package/lib/module/ui/primitives.js +156 -0
  86. package/lib/module/ui/primitives.js.map +1 -0
  87. package/lib/module/ui/tmTheme.js +99 -0
  88. package/lib/module/ui/tmTheme.js.map +1 -0
  89. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  90. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  91. package/lib/typescript/components/confirm.d.ts +2 -0
  92. package/lib/typescript/components/confirm.d.ts.map +1 -0
  93. package/lib/typescript/components/format.d.ts +4 -0
  94. package/lib/typescript/components/format.d.ts.map +1 -0
  95. package/lib/typescript/components/list/CaptureBar.d.ts +6 -0
  96. package/lib/typescript/components/list/CaptureBar.d.ts.map +1 -0
  97. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts +11 -0
  98. package/lib/typescript/components/list/RestoreOutcomeCard.d.ts.map +1 -0
  99. package/lib/typescript/components/list/SnapshotRow.d.ts +12 -0
  100. package/lib/typescript/components/list/SnapshotRow.d.ts.map +1 -0
  101. package/lib/typescript/components/list/SourcesStrip.d.ts +5 -0
  102. package/lib/typescript/components/list/SourcesStrip.d.ts.map +1 -0
  103. package/lib/typescript/components/list/UndoDeleteBar.d.ts +8 -0
  104. package/lib/typescript/components/list/UndoDeleteBar.d.ts.map +1 -0
  105. package/lib/typescript/components/list/WipeAllRow.d.ts +5 -0
  106. package/lib/typescript/components/list/WipeAllRow.d.ts.map +1 -0
  107. package/lib/typescript/components/preview/ItemDiffPanel.d.ts +5 -0
  108. package/lib/typescript/components/preview/ItemDiffPanel.d.ts.map +1 -0
  109. package/lib/typescript/components/preview/PreviewFlow.d.ts +9 -0
  110. package/lib/typescript/components/preview/PreviewFlow.d.ts.map +1 -0
  111. package/lib/typescript/components/preview/RestorePointNameRow.d.ts +5 -0
  112. package/lib/typescript/components/preview/RestorePointNameRow.d.ts.map +1 -0
  113. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +1 -5
  114. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -1
  115. package/lib/typescript/components/preview/verdictMeta.d.ts +5 -0
  116. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -1
  117. package/lib/typescript/components/sources.d.ts +36 -0
  118. package/lib/typescript/components/sources.d.ts.map +1 -0
  119. package/lib/typescript/store/snapshotVault.d.ts +48 -0
  120. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  121. package/lib/typescript/store/timeMachineStore.d.ts +35 -1
  122. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  123. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +17 -0
  124. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  125. package/lib/typescript/ui/SwipeActionsRow.d.ts +32 -0
  126. package/lib/typescript/ui/SwipeActionsRow.d.ts.map +1 -0
  127. package/lib/typescript/ui/primitives.d.ts +57 -0
  128. package/lib/typescript/ui/primitives.d.ts.map +1 -0
  129. package/lib/typescript/ui/tmTheme.d.ts +67 -0
  130. package/lib/typescript/ui/tmTheme.d.ts.map +1 -0
  131. package/package.json +7 -7
  132. package/src/components/TimeMachineBody.tsx +183 -817
  133. package/src/components/TimeMachineModal.tsx +10 -11
  134. package/src/components/confirm.ts +35 -0
  135. package/src/components/format.ts +17 -0
  136. package/src/components/list/CaptureBar.tsx +100 -0
  137. package/src/components/list/RestoreOutcomeCard.tsx +159 -0
  138. package/src/components/list/SnapshotRow.tsx +155 -0
  139. package/src/components/list/SourcesStrip.tsx +124 -0
  140. package/src/components/list/UndoDeleteBar.tsx +76 -0
  141. package/src/components/list/WipeAllRow.tsx +42 -0
  142. package/src/components/preview/ItemDiffPanel.tsx +77 -0
  143. package/src/components/preview/PreviewFlow.tsx +70 -0
  144. package/src/components/preview/RestorePointNameRow.tsx +80 -0
  145. package/src/components/preview/RestorePreviewScreen.tsx +535 -535
  146. package/src/components/preview/verdictMeta.ts +8 -8
  147. package/src/components/sources.ts +92 -0
  148. package/src/store/snapshotVault.ts +131 -9
  149. package/src/store/timeMachineStore.ts +136 -5
  150. package/src/sync/timeMachineSyncAdapter.ts +30 -0
  151. package/src/ui/SwipeActionsRow.tsx +199 -0
  152. package/src/ui/primitives.tsx +157 -0
  153. package/src/ui/tmTheme.ts +107 -0
  154. package/lib/commonjs/components/preview/ItemDetailScreen.js +0 -189
  155. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +0 -1
  156. package/lib/module/components/preview/ItemDetailScreen.js +0 -184
  157. package/lib/module/components/preview/ItemDetailScreen.js.map +0 -1
  158. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +0 -11
  159. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +0 -1
  160. package/src/components/preview/ItemDetailScreen.tsx +0 -172
@@ -1,296 +1,110 @@
1
1
  /**
2
- * Time Machine body — snapshot list, capture bar, provider status strip and
3
- * the Restore Preview flow (tap a snapshot row → item-level diff of what a
4
- * restore would change; tap an item → tree/split detail).
2
+ * Time Machine body — the screen router plus the restore-point list.
5
3
  *
6
- * Shared by the RN modal and the inline (desktop) tool view. The host must
7
- * NOT wrap this in a scroll view — each screen owns its scrolling (the
8
- * preview is a virtualized FlatList; wrapping it would defeat that).
4
+ * Screens: list → preview. Each is its own component that mounts and
5
+ * unmounts whole (no branching inside a single tree), and each owns its own
6
+ * scrolling — the host must NOT wrap this in a scroll view, or the preview's
7
+ * virtualized FlatList is defeated. Item diffs expand inline in that list
8
+ * rather than being a third screen.
9
+ *
10
+ * The list follows the grouped-inset surface in ../ui: capture bar, the
11
+ * Sources strip, one card of restore points with swipe-to-delete, and the
12
+ * app-wide wipe kept last and understated.
9
13
  */
10
14
  import { useState } from "react";
11
- import {
12
- Alert,
13
- Platform,
14
- ScrollView,
15
- StyleSheet,
16
- Text,
17
- TextInput,
18
- TouchableOpacity,
19
- View,
20
- } from "react-native";
21
- import {
22
- Plus,
23
- EventPickerModal,
24
- Home,
25
- Trash2,
26
- buoyColors,
27
- } from "@buoy-gg/shared-ui";
28
- import {
29
- JotaiIcon,
30
- QueryIcon,
31
- ReduxIcon,
32
- RoutesIcon,
33
- StorageIcon,
34
- TimeMachineIcon,
35
- ZustandIcon,
36
- } from "@buoy-gg/floating-tools-core";
37
- import type {
38
- EventPickerItem,
39
- StateSnapshotPreviewItem,
40
- } from "@buoy-gg/shared-ui";
15
+ import { ScrollView, StyleSheet, Text, View } from "react-native";
16
+ import { useSafeAreaInsets, useLimit } from "@buoy-gg/shared-ui";
41
17
  import { useToolHost } from "@buoy-gg/core";
42
18
  import { useIsPro } from "@buoy-gg/license";
43
- import { useSnapshotProviders, useTimeMachineState } from "../hooks/useTimeMachine";
19
+ import { useTimeMachineState } from "../hooks/useTimeMachine";
44
20
  import { timeMachineStore } from "../store/timeMachineStore";
45
- import type { PreviewSideRef, RestoreOutcome } from "../store/timeMachineStore";
46
- import { canNavigate, currentRoute } from "../store/routeRestore";
47
21
  import type { SnapshotMeta } from "../store/snapshotVault";
48
- import { RestorePreviewScreen } from "./preview/RestorePreviewScreen";
49
- import { ItemDetailScreen } from "./preview/ItemDetailScreen";
50
-
51
- const FREE_SNAPSHOT_LIMIT = 2;
52
-
53
- const SOURCE_ICONS: Record<
54
- string,
55
- React.ComponentType<{ size?: number; color?: string }>
56
- > = {
57
- storage: StorageIcon,
58
- redux: ReduxIcon,
59
- zustand: ZustandIcon,
60
- jotai: JotaiIcon,
61
- query: QueryIcon,
62
- route: RoutesIcon,
63
- };
64
-
65
- /** Id of the route tile — a device capability, not a snapshot provider. */
66
- const ROUTE_SOURCE_ID = "route";
67
-
68
- function timeAgo(ts: number): string {
69
- const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
70
- if (s < 60) return `${s}s ago`;
71
- const m = Math.round(s / 60);
72
- if (m < 60) return `${m}m ago`;
73
- const h = Math.round(m / 60);
74
- if (h < 24) return `${h}h ago`;
75
- return `${Math.round(h / 24)}d ago`;
76
- }
22
+ import { Footnote, Group, SectionLabel } from "../ui/primitives";
23
+ import { tm } from "../ui/tmTheme";
24
+ import { useSourceStatuses, type SourceStatus } from "./sources";
25
+ import { confirmDestructive } from "./confirm";
26
+ import { CaptureBar } from "./list/CaptureBar";
27
+ import {
28
+ RestoreOutcomeCard,
29
+ restoreNeedsAttention,
30
+ } from "./list/RestoreOutcomeCard";
31
+ import { SnapshotRow } from "./list/SnapshotRow";
32
+ import { UndoDeleteBar } from "./list/UndoDeleteBar";
33
+ import { SourcesStrip } from "./list/SourcesStrip";
34
+ import { WipeAllRow } from "./list/WipeAllRow";
35
+ import { PreviewScreen } from "./preview/PreviewFlow";
77
36
 
78
- function formatBytes(bytes: number): string {
79
- if (bytes < 1024) return `${bytes} B`;
80
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
81
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
82
- }
37
+ type Screen = { kind: "list" } | { kind: "preview" };
83
38
 
84
- function warningCount(meta: SnapshotMeta): number {
85
- return Object.values(meta.sources).reduce(
86
- (sum, s) => sum + s.warnings.length,
87
- 0
88
- );
89
- }
39
+ export function TimeMachineBody() {
40
+ const [screen, setScreen] = useState<Screen>({ kind: "list" });
41
+ const { snapshots, busy, preview } = useTimeMachineState();
42
+ const sources = useSourceStatuses();
90
43
 
91
- /** RN-web's Alert renders no buttons — fall back to the browser dialog there. */
92
- function confirmDestructive(
93
- title: string,
94
- message: string,
95
- confirmLabel: string,
96
- onConfirm: () => void
97
- ) {
98
- if (Platform.OS === "web") {
99
- const confirmFn = (globalThis as { confirm?: (msg: string) => boolean }).confirm;
100
- if (!confirmFn || confirmFn(`${title}\n\n${message}`)) onConfirm();
101
- return;
102
- }
103
- Alert.alert(title, message, [
104
- { text: "Cancel", style: "cancel" },
105
- { text: confirmLabel, style: "destructive", onPress: onConfirm },
106
- ]);
107
- }
44
+ const backToList = () => {
45
+ timeMachineStore.clearPreview();
46
+ setScreen({ kind: "list" });
47
+ };
108
48
 
109
- /**
110
- * "Also go back to the screen this was captured on?"
111
- *
112
- * Asked BEFORE the restore runs, because a reload-mode restore has to park the
113
- * route in storage before the JS realm dies — there is no "after" to ask in.
114
- * `onChoose(withRoute)` runs the restore; dismissing cancels it entirely.
115
- *
116
- * RN-web's Alert renders no buttons, so the web (Buoy Desktop) path uses the
117
- * browser dialog: OK = state + route, Cancel = state only.
118
- */
119
- function confirmRestoreRoute(
120
- snapshotName: string,
121
- href: string,
122
- onChoose: (withRoute: boolean) => void
123
- ) {
124
- const message = `“${snapshotName}” was captured on ${href}. Go back to that screen too, or restore state only?`;
125
- if (Platform.OS === "web") {
126
- const confirmFn = (globalThis as { confirm?: (msg: string) => boolean })
127
- .confirm;
128
- onChoose(
129
- confirmFn
130
- ? confirmFn(`Also return to ${href}?\n\n${message}`)
131
- : false
49
+ // The preview can't render without a built diff (clearPreview races a
50
+ // back-navigation) — fall through to the list rather than an empty shell.
51
+ if (screen.kind === "preview" && preview)
52
+ return (
53
+ <PreviewScreen
54
+ preview={preview}
55
+ snapshots={snapshots}
56
+ busy={busy !== null}
57
+ onClose={backToList}
58
+ />
132
59
  );
133
- return;
134
- }
135
- Alert.alert("Restore route too?", message, [
136
- { text: "Cancel", style: "cancel" },
137
- { text: "State only", onPress: () => onChoose(false) },
138
- { text: `Go to ${href}`, onPress: () => onChoose(true) },
139
- ]);
140
- }
141
60
 
142
- /**
143
- * Route leg of a restore: prompt when the snapshot has a captured route we
144
- * aren't already sitting on, otherwise restore straight away. On Buoy Desktop
145
- * the device's live route is unknown (no local router), so we always ask.
146
- */
147
- function restoreWithRoutePrompt(
148
- meta: SnapshotMeta,
149
- run: (restoreRoute: boolean) => void
150
- ) {
151
- const route = meta.route;
152
- if (!route) {
153
- run(false);
154
- return;
155
- }
156
- if (currentRoute()?.href === route.href) {
157
- run(false); // already on that screen — nothing to ask
158
- return;
159
- }
160
- confirmRestoreRoute(meta.name, route.href, run);
161
- }
162
-
163
- function OutcomePanel({ outcome }: { outcome: RestoreOutcome }) {
164
- const entries = Object.entries(outcome.results);
165
61
  return (
166
- <View style={styles.outcomePanel}>
167
- <View style={styles.outcomeHeaderRow}>
168
- <Text style={styles.outcomeTitle}>
169
- Restored “{outcome.snapshotName}”
170
- {outcome.mode === "reload" || outcome.willReload ? " · reloading" : ""}
171
- </Text>
172
- <TouchableOpacity
173
- onPress={() => timeMachineStore.clearLastOutcome()}
174
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
175
- >
176
- <Text style={styles.outcomeDismiss}>✕</Text>
177
- </TouchableOpacity>
178
- </View>
179
- {entries.length === 0 && (
180
- <Text style={styles.outcomeLine}>no sources restored</Text>
181
- )}
182
- {outcome.route && (
183
- <Text
184
- style={[
185
- styles.outcomeLine,
186
- (outcome.route.status === "failed" ||
187
- outcome.route.status === "unavailable") &&
188
- styles.outcomeLineBad,
189
- ]}
190
- >
191
- {outcome.route.status === "navigated"
192
- ? `✓ route — ${outcome.route.href}`
193
- : outcome.route.status === "deferred"
194
- ? `↻ route — ${outcome.route.href} after reload`
195
- : `✕ route — ${outcome.route.error ?? outcome.route.status}`}
196
- </Text>
197
- )}
198
- {entries.map(([sourceId, r]) => (
199
- <View key={sourceId} style={styles.outcomeSource}>
200
- <Text style={[styles.outcomeLine, !r.ok && styles.outcomeLineBad]}>
201
- {r.ok ? "✓" : "✕"} {sourceId} — applied {r.applied}
202
- {r.skipped.length > 0 ? `, skipped ${r.skipped.length}` : ""}
203
- </Text>
204
- {r.skipped.slice(0, 3).map((skip) => (
205
- <Text key={skip.id} style={styles.outcomeDetail}>
206
- · {skip.id}: {skip.reason}
207
- </Text>
208
- ))}
209
- {r.warnings.slice(0, 3).map((warning) => (
210
- <Text key={warning} style={styles.outcomeDetail}>
211
- ⚠ {warning}
212
- </Text>
213
- ))}
214
- </View>
215
- ))}
216
- </View>
62
+ <RestorePointList
63
+ sources={sources}
64
+ onOpenPreview={(meta) => {
65
+ void timeMachineStore.buildPreview({ snapshotId: meta.id });
66
+ setScreen({ kind: "preview" });
67
+ }}
68
+ />
217
69
  );
218
70
  }
219
71
 
220
- type Screen =
221
- | { kind: "list" }
222
- | { kind: "preview" }
223
- | {
224
- kind: "item";
225
- sourceId: string;
226
- sourceLabel: string;
227
- item: StateSnapshotPreviewItem;
228
- };
229
-
230
- export function TimeMachineBody() {
231
- const {
232
- snapshots,
233
- hasLoaded,
234
- busy,
235
- lastOutcome,
236
- preview,
237
- remoteProviders,
238
- remoteRoute,
239
- } = useTimeMachineState();
240
- const localProviders = useSnapshotProviders();
72
+ function RestorePointList({
73
+ sources,
74
+ onOpenPreview,
75
+ }: {
76
+ sources: SourceStatus[];
77
+ onOpenPreview: (meta: SnapshotMeta) => void;
78
+ }) {
79
+ const { snapshots, hasLoaded, busy, lastOutcome, recentlyDeleted } =
80
+ useTimeMachineState();
241
81
  const isPro = useIsPro();
82
+ const snapshotLimit = useLimit("timeMachine.snapshots");
242
83
  const host = useToolHost();
243
- const [name, setName] = useState("");
244
- const [screen, setScreen] = useState<Screen>({ kind: "list" });
245
- const [picker, setPicker] = useState<"left" | "right" | null>(null);
84
+ const insets = useSafeAreaInsets({ minBottom: 16 });
246
85
 
247
- // Header search (when hosted by the core shell) filters the snapshot list.
248
- const listQuery = host.search.trim().toLowerCase();
249
- const visibleSnapshots = listQuery
250
- ? snapshots.filter((s) => s.name.toLowerCase().includes(listQuery))
86
+ // Header search (when hosted by the core shell) filters the list.
87
+ const query = host.search.trim().toLowerCase();
88
+ const visible = query
89
+ ? snapshots.filter((s) => s.name.toLowerCase().includes(query))
251
90
  : snapshots;
252
91
 
253
- // On-device: live registry probes. Buoy Desktop: statuses mirrored from
254
- // the device's sync payload.
255
- const providers =
256
- remoteProviders ??
257
- localProviders.map((p) => ({ id: p.id, label: p.label, ...p.status() }));
258
-
259
- // Route sits in the same strip as the state sources so its availability is
260
- // visible before you ever open a restore point — but it's a device
261
- // capability, not a registered provider, so it's derived separately (from
262
- // the live router here, from the device's payload on Buoy Desktop).
263
- const routeAvailable = remoteProviders
264
- ? remoteRoute?.available === true
265
- : canNavigate();
266
- const routeSource = {
267
- id: ROUTE_SOURCE_ID,
268
- label: "Route",
269
- canCapture: routeAvailable,
270
- canRestore: routeAvailable,
271
- // No live route in the caption: this screen only re-renders on store
272
- // changes, so a pathname printed here goes stale the moment you navigate
273
- // with the tool still open. Each restore point shows the route it holds.
274
- detail: routeAvailable
275
- ? "recorded with every snapshot — restore can take you back"
276
- : "no Expo Router detected — snapshots record no route",
277
- };
278
- const sourceTiles = [...providers, routeSource];
279
-
280
- const atFreeLimit = !isPro && snapshots.length >= FREE_SNAPSHOT_LIMIT;
281
- const captureDisabled = busy !== null || atFreeLimit;
282
-
283
- const capture = () => {
284
- if (captureDisabled) return;
285
- void timeMachineStore.capture(name);
286
- setName("");
287
- };
288
-
289
- const restore = (
290
- meta: SnapshotMeta,
291
- mode: "live" | "reload",
292
- excludeKeys?: string[]
293
- ) => {
92
+ // Infinity for Pro, so this is simply false for them.
93
+ const atCaptureLimit = snapshots.length >= snapshotLimit;
94
+ const captureDisabled = busy !== null || atCaptureLimit;
95
+
96
+ /**
97
+ * Restoring is the last thing anyone wants from this screen, so the tool
98
+ * gets out of the way the moment it worked — with no toast and no banner.
99
+ * The restored app IS the confirmation, and anything announcing it is one
100
+ * more thing to dismiss on a flow people run dozens of times an hour.
101
+ *
102
+ * It MINIMIZES rather than closes: the list, the search and the screen you
103
+ * were on all survive, and coming back is one tap on the dock. A partial
104
+ * restore stays open instead, because the outcome card is the only place
105
+ * the per-source failures are reported.
106
+ */
107
+ const restore = (meta: SnapshotMeta) => {
294
108
  if (busy) return;
295
109
  if (meta.baseline) {
296
110
  confirmDestructive(
@@ -301,570 +115,122 @@ export function TimeMachineBody() {
301
115
  );
302
116
  return;
303
117
  }
304
- restoreWithRoutePrompt(meta, (restoreRoute) => {
305
- void timeMachineStore.restore(meta.id, mode, excludeKeys, {
306
- restoreRoute,
307
- });
118
+ void timeMachineStore.restore(meta.id, "live").then((outcome) => {
119
+ const clean = Object.values(outcome.results).every((r) => r.ok);
120
+ if (!clean || outcome.willReload) return;
121
+ host.minimize();
308
122
  });
309
123
  };
310
124
 
311
- const remove = (meta: SnapshotMeta) => {
312
- confirmDestructive(
313
- "Delete snapshot?",
314
- `“${meta.name}” cannot be recovered.`,
315
- "Delete",
316
- () => void timeMachineStore.delete(meta.id)
317
- );
318
- };
319
-
320
- const wipeAll = () => {
321
- if (busy) return;
322
- confirmDestructive(
323
- "Wipe all app state?",
324
- "This deletes all app storage and reloads the app — like a fresh install.",
325
- "Wipe & reload",
326
- () => void timeMachineStore.wipeAll()
327
- );
328
- };
329
-
330
- const openPreview = (meta: SnapshotMeta) => {
331
- void timeMachineStore.buildPreview({ snapshotId: meta.id });
332
- setScreen({ kind: "preview" });
333
- };
334
-
335
- const closePreview = () => {
336
- timeMachineStore.clearPreview();
337
- setScreen({ kind: "list" });
338
- };
339
-
340
- // ── Preview / item screens ──
341
- if (screen.kind === "item" && preview) {
342
- return (
343
- <ItemDetailScreen
344
- sourceLabel={screen.sourceLabel}
345
- item={screen.item}
346
- leftLabel={
347
- preview.left === "current"
348
- ? "CURRENT"
349
- : snapshots.find(
350
- (s) => s.id === (preview.left as { snapshotId: string }).snapshotId
351
- )?.name ?? "snapshot"
352
- }
353
- rightLabel={
354
- preview.right === "current"
355
- ? "CURRENT"
356
- : snapshots.find(
357
- (s) =>
358
- s.id === (preview.right as { snapshotId: string }).snapshotId
359
- )?.name ?? "snapshot"
360
- }
361
- onBack={() => setScreen({ kind: "preview" })}
362
- />
363
- );
364
- }
365
-
366
- if (screen.kind === "preview" && preview) {
367
- const rightMeta =
368
- preview.right === "current"
369
- ? undefined
370
- : snapshots.find(
371
- (s) => s.id === (preview.right as { snapshotId: string }).snapshotId
372
- );
373
-
374
- const pickerItems: EventPickerItem[] = [];
375
- if (picker) {
376
- const otherRef = picker === "left" ? preview.right : preview.left;
377
- const otherId =
378
- otherRef === "current" ? null : (otherRef as { snapshotId: string }).snapshotId;
379
- if (picker === "left") {
380
- pickerItems.push({
381
- index: 0,
382
- label: "Current state",
383
- timestamp: "live",
384
- relativeTime: "now",
385
- selected: preview.left === "current",
386
- });
387
- }
388
- snapshots.forEach((s, i) => {
389
- const ref = { snapshotId: s.id };
390
- const current = picker === "left" ? preview.left : preview.right;
391
- pickerItems.push({
392
- index: picker === "left" ? i + 1 : i,
393
- label: s.baseline ? `⌂ ${s.name}` : s.name,
394
- timestamp: new Date(s.createdAt).toLocaleTimeString(),
395
- relativeTime: timeAgo(s.createdAt),
396
- disabled: s.id === otherId || (picker === "right" && s.baseline),
397
- selected:
398
- current !== "current" &&
399
- (current as { snapshotId: string }).snapshotId === s.id,
400
- });
401
- });
402
- }
403
-
404
- const onPickerSelect = (index: number) => {
405
- const side = picker;
406
- setPicker(null);
407
- if (!side) return;
408
- let ref: PreviewSideRef;
409
- if (side === "left") {
410
- ref = index === 0 ? "current" : { snapshotId: snapshots[index - 1].id };
411
- void timeMachineStore.buildPreview(preview.right, ref);
412
- } else {
413
- ref = { snapshotId: snapshots[index].id };
414
- void timeMachineStore.buildPreview(ref, preview.left);
415
- }
416
- };
417
-
418
- return (
419
- <View style={styles.screenFill}>
420
- <RestorePreviewScreen
421
- preview={preview}
422
- snapshots={snapshots}
423
- busy={busy !== null}
424
- onBack={closePreview}
425
- onOpenItem={(sourceId, item) => {
426
- const source = preview.sources.find((s) => s.id === sourceId);
427
- setScreen({
428
- kind: "item",
429
- sourceId,
430
- sourceLabel: source?.label ?? sourceId,
431
- item,
432
- });
433
- }}
434
- onRestore={(mode, excludeKeys) => {
435
- if (!rightMeta || busy) return;
436
- if (rightMeta.baseline) {
437
- confirmDestructive(
438
- "Restore fresh install?",
439
- "This wipes all app storage and reloads the app.",
440
- "Wipe & reload",
441
- () => void timeMachineStore.restore(rightMeta.id, "reload")
442
- );
443
- return;
444
- }
445
- // Land back on the list, where the outcome panel reports the
446
- // results — the preview's diff is stale the moment restore runs.
447
- restoreWithRoutePrompt(rightMeta, (restoreRoute) => {
448
- void timeMachineStore
449
- .restore(rightMeta.id, mode, excludeKeys, { restoreRoute })
450
- .then(() => closePreview());
451
- });
452
- }}
453
- onPickSide={setPicker}
454
- onRefresh={() =>
455
- void timeMachineStore.buildPreview(preview.right, preview.left)
456
- }
457
- />
458
- {picker && (
459
- <EventPickerModal
460
- isOpen
461
- onClose={() => setPicker(null)}
462
- title={picker === "left" ? "Compare from" : "Compare to"}
463
- position={picker}
464
- items={pickerItems}
465
- onSelect={onPickerSelect}
466
- />
467
- )}
468
- </View>
469
- );
470
- }
471
-
472
- // ── List screen ──
473
- const unavailable = sourceTiles.filter(
474
- (p) => p.detail && (!p.canCapture || !p.canRestore)
475
- );
476
-
477
125
  return (
478
- <ScrollView style={styles.screenFill} contentContainerStyle={styles.container}>
479
- {lastOutcome && <OutcomePanel outcome={lastOutcome} />}
126
+ <ScrollView
127
+ style={styles.fill}
128
+ contentContainerStyle={[styles.content, { paddingBottom: insets.bottom }]}
129
+ >
130
+ {/* Only when the restore did something the app itself won't show you. */}
131
+ {lastOutcome && restoreNeedsAttention(lastOutcome) ? (
132
+ <RestoreOutcomeCard outcome={lastOutcome} />
133
+ ) : null}
134
+
135
+ {recentlyDeleted ? (
136
+ <UndoDeleteBar
137
+ name={recentlyDeleted.stored.meta.name}
138
+ at={recentlyDeleted.at}
139
+ onUndo={() => void timeMachineStore.undoDelete()}
140
+ onExpire={() => timeMachineStore.clearRecentlyDeleted()}
141
+ />
142
+ ) : null}
480
143
 
481
- {/* Capture card */}
482
- <View style={styles.captureCard}>
483
- <View style={styles.captureField}>
484
- <Plus size={14} color={buoyColors.textMuted} />
485
- <TextInput
486
- style={styles.captureInput}
487
- value={name}
488
- onChangeText={setName}
489
- placeholder="Name this restore point…"
490
- placeholderTextColor={buoyColors.textMuted}
491
- editable={!captureDisabled}
492
- onSubmitEditing={capture}
493
- returnKeyType="done"
494
- testID="time-machine-name-input"
495
- />
496
- </View>
497
- <TouchableOpacity
498
- style={[styles.captureButton, captureDisabled && styles.buttonDisabled]}
499
- onPress={capture}
144
+ <View>
145
+ <CaptureBar
500
146
  disabled={captureDisabled}
501
- testID="time-machine-capture"
502
- >
503
- <Text style={styles.captureButtonText}>
504
- {busy === "capture" ? "…" : "CAPTURE"}
505
- </Text>
506
- </TouchableOpacity>
507
- </View>
508
- {atFreeLimit && (
509
- <Text style={styles.freeLimitNote}>
510
- Free plan: {FREE_SNAPSHOT_LIMIT} restore points. Upgrade to Pro for
511
- unlimited snapshots.
512
- </Text>
513
- )}
514
-
515
- {/* Sources — detection at a glance */}
516
- <View style={styles.sectionRow}>
517
- <Text style={styles.sectionTitle}>Sources</Text>
518
- <View style={styles.sourceTiles}>
519
- {sourceTiles.map((p) => {
520
- const IconComp = SOURCE_ICONS[p.id] ?? TimeMachineIcon;
521
- const dotColor = !p.canCapture
522
- ? buoyColors.textMuted
523
- : p.canRestore
524
- ? buoyColors.success
525
- : buoyColors.warning;
526
- return (
527
- <View
528
- key={p.id}
529
- style={styles.sourceTile}
530
- testID={`time-machine-source-${p.id}`}
531
- accessibilityLabel={`${p.label}: ${
532
- p.canCapture
533
- ? p.canRestore
534
- ? "capture and restore"
535
- : "capture only"
536
- : "unavailable"
537
- }`}
538
- >
539
- <IconComp size={16} />
540
- <View
541
- style={[styles.sourceTileDot, { backgroundColor: dotColor }]}
542
- />
543
- </View>
544
- );
545
- })}
546
- {providers.length === 0 && !routeAvailable && (
547
- <Text style={styles.emptyText}>none registered</Text>
548
- )}
549
- </View>
147
+ busy={busy === "capture"}
148
+ onCapture={(name) => void timeMachineStore.capture(name)}
149
+ />
550
150
  </View>
551
- {unavailable.map((p) => (
552
- <Text key={p.id} style={styles.providerDetail}>
553
- {p.label}: {p.detail}
554
- </Text>
555
- ))}
556
- {/* Route is opt-in per restore, so say so where the tiles are read. */}
557
- {routeAvailable && (
558
- <Text style={styles.providerDetail} numberOfLines={1}>
559
- {routeSource.label}: {routeSource.detail}
560
- </Text>
561
- )}
562
-
563
- {/* Restore points */}
564
- <Text style={[styles.sectionTitle, styles.sectionSpacing]}>
565
- Restore points
566
- </Text>
567
- {hasLoaded && snapshots.length === 0 && (
568
- <Text style={styles.emptyText}>
569
- No restore points yet. Set up the state you want to return to, then
570
- capture it.
571
- </Text>
572
- )}
573
- {visibleSnapshots.map((meta) => {
574
- const warnings = warningCount(meta);
575
- return (
576
- <TouchableOpacity
151
+ {atCaptureLimit ? (
152
+ <Footnote tone="warning">
153
+ Free plan keeps {snapshotLimit} restore points. Upgrade to Pro
154
+ for unlimited snapshots.
155
+ </Footnote>
156
+ ) : null}
157
+
158
+ <SectionLabel>Sources</SectionLabel>
159
+ <SourcesStrip sources={sources} />
160
+ {/* A source that can't fully participate says why, right where its
161
+ tile is — the strip only has room for the dot. */}
162
+ {sources
163
+ .filter((s) => s.detail && !(s.canCapture && s.canRestore))
164
+ .map((s) => (
165
+ <Footnote key={s.id}>
166
+ {s.label}: {s.detail}
167
+ </Footnote>
168
+ ))}
169
+ <SectionLabel>Restore Points</SectionLabel>
170
+ <Group>
171
+ {visible.map((meta) => (
172
+ <SnapshotRow
577
173
  key={meta.id}
578
- style={styles.snapshotCard}
579
- onPress={() => openPreview(meta)}
580
- testID={`time-machine-snap-${meta.id}`}
581
- >
582
- <View style={styles.snapshotIconTile}>
583
- {meta.baseline ? (
584
- <Home size={17} color={buoyColors.textSecondary} />
585
- ) : (
586
- <TimeMachineIcon size={18} />
587
- )}
588
- </View>
589
- <View style={styles.snapshotInfo}>
590
- <Text style={styles.snapshotName} numberOfLines={1}>
591
- {meta.name}
592
- </Text>
593
- <Text style={styles.snapshotMeta} numberOfLines={1}>
594
- {meta.baseline
595
- ? "wipes to fresh install"
596
- : `${timeAgo(meta.createdAt)} · ${formatBytes(meta.sizeBytes)} · ${Object.keys(meta.sources).length} sources`}
597
- {meta.scope?.length
598
- ? ` · scoped (${meta.scope.length})`
599
- : ""}
600
- {warnings > 0 ? ` · ⚠ ${warnings}` : ""}
601
- </Text>
602
- {!meta.baseline && meta.route && (
603
- <Text style={styles.snapshotRoute} numberOfLines={1}>
604
- ⌖ {meta.route.href}
605
- </Text>
606
- )}
607
- </View>
608
- {!meta.baseline ? (
609
- <TouchableOpacity
610
- style={[styles.rowAction, busy && styles.buttonDisabled]}
611
- onPress={() => restore(meta, "live")}
612
- disabled={busy !== null}
613
- accessibilityLabel="Restore"
614
- >
615
- <Text style={styles.rowActionLabel}>RESTORE</Text>
616
- </TouchableOpacity>
617
- ) : (
618
- <TouchableOpacity
619
- style={[styles.rowAction, busy && styles.buttonDisabled]}
620
- onPress={() => restore(meta, "reload")}
621
- disabled={busy !== null}
622
- accessibilityLabel="Wipe and reload"
623
- >
624
- <Text style={styles.rowActionLabelDanger}>WIPE</Text>
625
- </TouchableOpacity>
626
- )}
627
- <TouchableOpacity
628
- style={styles.rowAction}
629
- onPress={() => remove(meta)}
630
- hitSlop={{ top: 8, bottom: 8, left: 4, right: 8 }}
631
- accessibilityLabel="Delete"
632
- >
633
- <Trash2 size={14} color={buoyColors.error} />
634
- </TouchableOpacity>
635
- </TouchableOpacity>
636
- );
637
- })}
638
-
639
- {/* Destructive utility — deliberately last and understated. */}
640
- <TouchableOpacity
641
- style={[styles.wipeAllButton, busy && styles.buttonDisabled]}
642
- onPress={wipeAll}
643
- disabled={busy !== null}
644
- testID="time-machine-wipe-all"
645
- >
646
- <Trash2 size={13} color={buoyColors.error} />
647
- <Text style={styles.wipeAllText}>Wipe all app state…</Text>
648
- </TouchableOpacity>
649
- <Text style={styles.wipeAllCaption}>
174
+ meta={meta}
175
+ busy={busy !== null}
176
+ onOpen={() => onOpenPreview(meta)}
177
+ onRestore={() => restore(meta)}
178
+ onDelete={() => void timeMachineStore.delete(meta.id)}
179
+ onDuplicate={() => void timeMachineStore.duplicate(meta.id)}
180
+ />
181
+ ))}
182
+ {visible.length === 0 ? (
183
+ <Text style={styles.empty}>
184
+ {!hasLoaded
185
+ ? "Loading restore points…"
186
+ : snapshots.length > 0
187
+ ? "No restore point matches that name."
188
+ : "Set up the state you want to come back to, then capture it."}
189
+ </Text>
190
+ ) : null}
191
+ </Group>
192
+ {visible.length > 0 ? (
193
+ <Footnote>Swipe a restore point to rename, copy or delete it.</Footnote>
194
+ ) : null}
195
+
196
+ <View style={styles.spacer} />
197
+
198
+ <Group>
199
+ <WipeAllRow
200
+ disabled={busy !== null}
201
+ onWipe={() =>
202
+ confirmDestructive(
203
+ "Wipe all app state?",
204
+ "This deletes all app storage and reloads the app — like a fresh install.",
205
+ "Wipe & reload",
206
+ () => void timeMachineStore.wipeAll()
207
+ )
208
+ }
209
+ />
210
+ </Group>
211
+ <Footnote centered>
650
212
  Deletes all app storage and reloads — like a fresh install.
651
- </Text>
213
+ </Footnote>
652
214
  </ScrollView>
653
215
  );
654
216
  }
655
217
 
656
218
  const styles = StyleSheet.create({
657
- screenFill: {
658
- flex: 1,
659
- },
660
- container: {
661
- padding: 12,
662
- gap: 8,
663
- },
664
- captureCard: {
665
- flexDirection: "row",
666
- alignItems: "center",
667
- gap: 8,
668
- },
669
- captureField: {
670
- flex: 1,
671
- flexDirection: "row",
672
- alignItems: "center",
673
- gap: 8,
674
- backgroundColor: buoyColors.card,
675
- borderColor: buoyColors.border,
676
- borderWidth: 1,
677
- borderRadius: 10,
678
- paddingHorizontal: 10,
679
- },
680
- captureInput: {
681
- flex: 1,
682
- color: buoyColors.text,
683
- fontSize: 13,
684
- paddingVertical: 9,
685
- },
686
- captureButton: {
687
- backgroundColor: buoyColors.primary,
688
- borderRadius: 10,
689
- paddingHorizontal: 16,
690
- paddingVertical: 10,
691
- justifyContent: "center",
692
- },
693
- captureButtonText: {
694
- color: "#0A0A0A",
695
- fontSize: 12,
696
- fontWeight: "700",
697
- letterSpacing: 0.5,
698
- },
699
- buttonDisabled: {
700
- opacity: 0.4,
701
- },
702
- freeLimitNote: {
703
- color: buoyColors.warning,
704
- fontSize: 11,
705
- },
706
- sectionRow: {
707
- flexDirection: "row",
708
- alignItems: "center",
709
- justifyContent: "space-between",
710
- marginTop: 12,
711
- gap: 10,
712
- },
713
- sectionTitle: {
714
- color: buoyColors.text,
715
- fontSize: 14,
716
- fontWeight: "700",
717
- },
718
- sectionSpacing: {
719
- marginTop: 14,
720
- },
721
- sourceTiles: {
722
- flexDirection: "row",
723
- gap: 6,
724
- flexWrap: "wrap",
725
- justifyContent: "flex-end",
726
- flex: 1,
727
- },
728
- sourceTile: {
729
- width: 32,
730
- height: 32,
731
- borderRadius: 9,
732
- backgroundColor: buoyColors.card,
733
- borderColor: buoyColors.border,
734
- borderWidth: 1,
735
- alignItems: "center",
736
- justifyContent: "center",
737
- },
738
- sourceTileDot: {
739
- position: "absolute",
740
- top: 3,
741
- right: 3,
742
- width: 6,
743
- height: 6,
744
- borderRadius: 3,
745
- },
746
- providerDetail: {
747
- color: buoyColors.textMuted,
748
- fontSize: 10,
749
- marginTop: 4,
750
- },
751
- emptyText: {
752
- color: buoyColors.textMuted,
753
- fontSize: 12,
754
- marginTop: 8,
755
- },
756
- snapshotCard: {
757
- flexDirection: "row",
758
- alignItems: "center",
759
- backgroundColor: buoyColors.card,
760
- borderColor: buoyColors.border,
761
- borderWidth: 1,
762
- borderRadius: 12,
763
- paddingHorizontal: 12,
764
- paddingVertical: 10,
765
- marginTop: 8,
766
- gap: 10,
767
- },
768
- snapshotIconTile: {
769
- width: 36,
770
- height: 36,
771
- borderRadius: 10,
772
- backgroundColor: buoyColors.hover,
773
- alignItems: "center",
774
- justifyContent: "center",
775
- },
776
- snapshotInfo: {
219
+ fill: {
777
220
  flex: 1,
778
- minWidth: 0,
779
- gap: 2,
780
- },
781
- snapshotName: {
782
- color: buoyColors.text,
783
- fontSize: 14,
784
- fontWeight: "700",
785
- },
786
- snapshotMeta: {
787
- color: buoyColors.textMuted,
788
- fontSize: 10,
789
- fontVariant: ["tabular-nums"],
790
- },
791
- snapshotRoute: {
792
- color: buoyColors.textSecondary,
793
- fontSize: 10,
794
- },
795
- rowAction: {
796
- padding: 4,
797
- justifyContent: "center",
798
- alignItems: "center",
799
- },
800
- rowActionLabel: {
801
- color: buoyColors.primary,
802
- fontSize: 12,
803
- fontWeight: "600",
804
- },
805
- rowActionLabelDanger: {
806
- color: buoyColors.error,
807
- fontSize: 12,
808
- fontWeight: "600",
809
- },
810
- wipeAllButton: {
811
- flexDirection: "row",
812
- alignItems: "center",
813
- justifyContent: "center",
814
- gap: 7,
815
- borderColor: `${buoyColors.error}55`,
816
- borderWidth: 1,
817
- borderRadius: 10,
818
- paddingVertical: 10,
819
- marginTop: 18,
820
- },
821
- wipeAllText: {
822
- color: buoyColors.error,
823
- fontSize: 12.5,
824
- fontWeight: "600",
825
- },
826
- wipeAllCaption: {
827
- color: buoyColors.textMuted,
828
- fontSize: 10,
829
- textAlign: "center",
830
- marginTop: 6,
831
- },
832
- outcomePanel: {
833
- backgroundColor: buoyColors.card,
834
- borderColor: buoyColors.borderStrong,
835
- borderWidth: 1,
836
- borderRadius: 8,
837
- padding: 10,
838
- gap: 4,
839
- },
840
- outcomeHeaderRow: {
841
- flexDirection: "row",
842
- justifyContent: "space-between",
843
- alignItems: "center",
844
- },
845
- outcomeTitle: {
846
- color: buoyColors.text,
847
- fontSize: 12,
848
- fontWeight: "700",
849
- flex: 1,
850
- },
851
- outcomeDismiss: {
852
- color: buoyColors.textMuted,
853
- fontSize: 13,
854
- },
855
- outcomeSource: {
856
- gap: 2,
221
+ backgroundColor: tm.color.sheet,
857
222
  },
858
- outcomeLine: {
859
- color: buoyColors.success,
860
- fontSize: 11,
223
+ content: {
224
+ paddingTop: 14,
861
225
  },
862
- outcomeLineBad: {
863
- color: buoyColors.warning,
226
+ empty: {
227
+ color: tm.color.labelSecondary,
228
+ fontSize: tm.font.footnote,
229
+ lineHeight: tm.font.footnote * 1.4,
230
+ paddingHorizontal: tm.textInset,
231
+ paddingVertical: 16,
864
232
  },
865
- outcomeDetail: {
866
- color: buoyColors.textMuted,
867
- fontSize: 10,
868
- paddingLeft: 12,
233
+ spacer: {
234
+ height: 24,
869
235
  },
870
236
  });