@buoy-gg/time-machine 7.0.17 → 7.0.18

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 (69) hide show
  1. package/lib/commonjs/components/HeaderReloadButton.js +61 -0
  2. package/lib/commonjs/components/HeaderReloadButton.js.map +1 -0
  3. package/lib/commonjs/components/TimeMachineBody.js +343 -127
  4. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  5. package/lib/commonjs/components/TimeMachineModal.js +1 -0
  6. package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
  7. package/lib/commonjs/components/preview/ItemDetailScreen.js +189 -0
  8. package/lib/commonjs/components/preview/ItemDetailScreen.js.map +1 -0
  9. package/lib/commonjs/components/preview/RestorePreviewScreen.js +1056 -0
  10. package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -0
  11. package/lib/commonjs/components/preview/verdictMeta.js +58 -0
  12. package/lib/commonjs/components/preview/verdictMeta.js.map +1 -0
  13. package/lib/commonjs/preset.js +11 -2
  14. package/lib/commonjs/preset.js.map +1 -1
  15. package/lib/commonjs/store/snapshotVault.js +19 -0
  16. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  17. package/lib/commonjs/store/timeMachineStore.js +298 -7
  18. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  19. package/lib/commonjs/sync/timeMachineSyncAdapter.js +62 -2
  20. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  21. package/lib/module/components/HeaderReloadButton.js +56 -0
  22. package/lib/module/components/HeaderReloadButton.js.map +1 -0
  23. package/lib/module/components/TimeMachineBody.js +345 -129
  24. package/lib/module/components/TimeMachineBody.js.map +1 -1
  25. package/lib/module/components/TimeMachineModal.js +1 -0
  26. package/lib/module/components/TimeMachineModal.js.map +1 -1
  27. package/lib/module/components/preview/ItemDetailScreen.js +184 -0
  28. package/lib/module/components/preview/ItemDetailScreen.js.map +1 -0
  29. package/lib/module/components/preview/RestorePreviewScreen.js +1051 -0
  30. package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -0
  31. package/lib/module/components/preview/verdictMeta.js +53 -0
  32. package/lib/module/components/preview/verdictMeta.js.map +1 -0
  33. package/lib/module/preset.js +11 -2
  34. package/lib/module/preset.js.map +1 -1
  35. package/lib/module/store/snapshotVault.js +18 -0
  36. package/lib/module/store/snapshotVault.js.map +1 -1
  37. package/lib/module/store/timeMachineStore.js +299 -8
  38. package/lib/module/store/timeMachineStore.js.map +1 -1
  39. package/lib/module/sync/timeMachineSyncAdapter.js +62 -2
  40. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  41. package/lib/typescript/components/HeaderReloadButton.d.ts +2 -0
  42. package/lib/typescript/components/HeaderReloadButton.d.ts.map +1 -0
  43. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  44. package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
  45. package/lib/typescript/components/preview/ItemDetailScreen.d.ts +11 -0
  46. package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +1 -0
  47. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +15 -0
  48. package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -0
  49. package/lib/typescript/components/preview/verdictMeta.d.ts +10 -0
  50. package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -0
  51. package/lib/typescript/preset.d.ts +15 -3
  52. package/lib/typescript/preset.d.ts.map +1 -1
  53. package/lib/typescript/store/snapshotVault.d.ts +7 -0
  54. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  55. package/lib/typescript/store/timeMachineStore.d.ts +49 -2
  56. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  57. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +36 -0
  58. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  59. package/package.json +13 -6
  60. package/src/components/HeaderReloadButton.tsx +45 -0
  61. package/src/components/TimeMachineBody.tsx +416 -137
  62. package/src/components/TimeMachineModal.tsx +1 -0
  63. package/src/components/preview/ItemDetailScreen.tsx +172 -0
  64. package/src/components/preview/RestorePreviewScreen.tsx +1089 -0
  65. package/src/components/preview/verdictMeta.ts +40 -0
  66. package/src/preset.tsx +11 -2
  67. package/src/store/snapshotVault.ts +28 -0
  68. package/src/store/timeMachineStore.ts +330 -7
  69. package/src/sync/timeMachineSyncAdapter.ts +63 -3
@@ -1,27 +1,64 @@
1
1
  /**
2
- * Time Machine body — capture bar, provider status strip, snapshot list and
3
- * the last-restore results panel. Shared by the RN modal and the inline
4
- * (desktop) tool view.
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).
5
+ *
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).
5
9
  */
6
10
  import { useState } from "react";
7
11
  import {
8
12
  Alert,
9
13
  Platform,
14
+ ScrollView,
10
15
  StyleSheet,
11
16
  Text,
12
17
  TextInput,
13
18
  TouchableOpacity,
14
19
  View,
15
20
  } from "react-native";
16
- import { buoyColors } from "@buoy-gg/shared-ui";
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
+ StorageIcon,
33
+ TimeMachineIcon,
34
+ ZustandIcon,
35
+ } from "@buoy-gg/floating-tools-core";
36
+ import type {
37
+ EventPickerItem,
38
+ StateSnapshotPreviewItem,
39
+ } from "@buoy-gg/shared-ui";
40
+ import { useToolHost } from "@buoy-gg/core";
17
41
  import { useIsPro } from "@buoy-gg/license";
18
42
  import { useSnapshotProviders, useTimeMachineState } from "../hooks/useTimeMachine";
19
43
  import { timeMachineStore } from "../store/timeMachineStore";
20
- import type { RestoreOutcome } from "../store/timeMachineStore";
44
+ import type { PreviewSideRef, RestoreOutcome } from "../store/timeMachineStore";
21
45
  import type { SnapshotMeta } from "../store/snapshotVault";
46
+ import { RestorePreviewScreen } from "./preview/RestorePreviewScreen";
47
+ import { ItemDetailScreen } from "./preview/ItemDetailScreen";
22
48
 
23
49
  const FREE_SNAPSHOT_LIMIT = 2;
24
50
 
51
+ const SOURCE_ICONS: Record<
52
+ string,
53
+ React.ComponentType<{ size?: number; color?: string }>
54
+ > = {
55
+ storage: StorageIcon,
56
+ redux: ReduxIcon,
57
+ zustand: ZustandIcon,
58
+ jotai: JotaiIcon,
59
+ query: QueryIcon,
60
+ };
61
+
25
62
  function timeAgo(ts: number): string {
26
63
  const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
27
64
  if (s < 60) return `${s}s ago`;
@@ -104,12 +141,31 @@ function OutcomePanel({ outcome }: { outcome: RestoreOutcome }) {
104
141
  );
105
142
  }
106
143
 
144
+ type Screen =
145
+ | { kind: "list" }
146
+ | { kind: "preview" }
147
+ | {
148
+ kind: "item";
149
+ sourceId: string;
150
+ sourceLabel: string;
151
+ item: StateSnapshotPreviewItem;
152
+ };
153
+
107
154
  export function TimeMachineBody() {
108
- const { snapshots, hasLoaded, busy, lastOutcome, remoteProviders } =
155
+ const { snapshots, hasLoaded, busy, lastOutcome, preview, remoteProviders } =
109
156
  useTimeMachineState();
110
157
  const localProviders = useSnapshotProviders();
111
158
  const isPro = useIsPro();
159
+ const host = useToolHost();
112
160
  const [name, setName] = useState("");
161
+ const [screen, setScreen] = useState<Screen>({ kind: "list" });
162
+ const [picker, setPicker] = useState<"left" | "right" | null>(null);
163
+
164
+ // Header search (when hosted by the core shell) filters the snapshot list.
165
+ const listQuery = host.search.trim().toLowerCase();
166
+ const visibleSnapshots = listQuery
167
+ ? snapshots.filter((s) => s.name.toLowerCase().includes(listQuery))
168
+ : snapshots;
113
169
 
114
170
  // On-device: live registry probes. Buoy Desktop: statuses mirrored from
115
171
  // the device's sync payload.
@@ -126,12 +182,11 @@ export function TimeMachineBody() {
126
182
  setName("");
127
183
  };
128
184
 
129
- const captureBaseline = () => {
130
- if (captureDisabled) return;
131
- void timeMachineStore.captureBaseline();
132
- };
133
-
134
- const restore = (meta: SnapshotMeta, mode: "live" | "reload") => {
185
+ const restore = (
186
+ meta: SnapshotMeta,
187
+ mode: "live" | "reload",
188
+ excludeKeys?: string[]
189
+ ) => {
135
190
  if (busy) return;
136
191
  if (meta.baseline) {
137
192
  confirmDestructive(
@@ -142,7 +197,7 @@ export function TimeMachineBody() {
142
197
  );
143
198
  return;
144
199
  }
145
- void timeMachineStore.restore(meta.id, mode);
200
+ void timeMachineStore.restore(meta.id, mode, excludeKeys);
146
201
  };
147
202
 
148
203
  const remove = (meta: SnapshotMeta) => {
@@ -154,27 +209,181 @@ export function TimeMachineBody() {
154
209
  );
155
210
  };
156
211
 
212
+ const wipeAll = () => {
213
+ if (busy) return;
214
+ confirmDestructive(
215
+ "Wipe all app state?",
216
+ "This deletes all app storage and reloads the app — like a fresh install.",
217
+ "Wipe & reload",
218
+ () => void timeMachineStore.wipeAll()
219
+ );
220
+ };
221
+
222
+ const openPreview = (meta: SnapshotMeta) => {
223
+ void timeMachineStore.buildPreview({ snapshotId: meta.id });
224
+ setScreen({ kind: "preview" });
225
+ };
226
+
227
+ const closePreview = () => {
228
+ timeMachineStore.clearPreview();
229
+ setScreen({ kind: "list" });
230
+ };
231
+
232
+ // ── Preview / item screens ──
233
+ if (screen.kind === "item" && preview) {
234
+ return (
235
+ <ItemDetailScreen
236
+ sourceLabel={screen.sourceLabel}
237
+ item={screen.item}
238
+ leftLabel={
239
+ preview.left === "current"
240
+ ? "CURRENT"
241
+ : snapshots.find(
242
+ (s) => s.id === (preview.left as { snapshotId: string }).snapshotId
243
+ )?.name ?? "snapshot"
244
+ }
245
+ rightLabel={
246
+ preview.right === "current"
247
+ ? "CURRENT"
248
+ : snapshots.find(
249
+ (s) =>
250
+ s.id === (preview.right as { snapshotId: string }).snapshotId
251
+ )?.name ?? "snapshot"
252
+ }
253
+ onBack={() => setScreen({ kind: "preview" })}
254
+ />
255
+ );
256
+ }
257
+
258
+ if (screen.kind === "preview" && preview) {
259
+ const rightMeta =
260
+ preview.right === "current"
261
+ ? undefined
262
+ : snapshots.find(
263
+ (s) => s.id === (preview.right as { snapshotId: string }).snapshotId
264
+ );
265
+
266
+ const pickerItems: EventPickerItem[] = [];
267
+ if (picker) {
268
+ const otherRef = picker === "left" ? preview.right : preview.left;
269
+ const otherId =
270
+ otherRef === "current" ? null : (otherRef as { snapshotId: string }).snapshotId;
271
+ if (picker === "left") {
272
+ pickerItems.push({
273
+ index: 0,
274
+ label: "Current state",
275
+ timestamp: "live",
276
+ relativeTime: "now",
277
+ selected: preview.left === "current",
278
+ });
279
+ }
280
+ snapshots.forEach((s, i) => {
281
+ const ref = { snapshotId: s.id };
282
+ const current = picker === "left" ? preview.left : preview.right;
283
+ pickerItems.push({
284
+ index: picker === "left" ? i + 1 : i,
285
+ label: s.baseline ? `⌂ ${s.name}` : s.name,
286
+ timestamp: new Date(s.createdAt).toLocaleTimeString(),
287
+ relativeTime: timeAgo(s.createdAt),
288
+ disabled: s.id === otherId || (picker === "right" && s.baseline),
289
+ selected:
290
+ current !== "current" &&
291
+ (current as { snapshotId: string }).snapshotId === s.id,
292
+ });
293
+ });
294
+ }
295
+
296
+ const onPickerSelect = (index: number) => {
297
+ const side = picker;
298
+ setPicker(null);
299
+ if (!side) return;
300
+ let ref: PreviewSideRef;
301
+ if (side === "left") {
302
+ ref = index === 0 ? "current" : { snapshotId: snapshots[index - 1].id };
303
+ void timeMachineStore.buildPreview(preview.right, ref);
304
+ } else {
305
+ ref = { snapshotId: snapshots[index].id };
306
+ void timeMachineStore.buildPreview(ref, preview.left);
307
+ }
308
+ };
309
+
310
+ return (
311
+ <View style={styles.screenFill}>
312
+ <RestorePreviewScreen
313
+ preview={preview}
314
+ snapshots={snapshots}
315
+ busy={busy !== null}
316
+ onBack={closePreview}
317
+ onOpenItem={(sourceId, item) => {
318
+ const source = preview.sources.find((s) => s.id === sourceId);
319
+ setScreen({
320
+ kind: "item",
321
+ sourceId,
322
+ sourceLabel: source?.label ?? sourceId,
323
+ item,
324
+ });
325
+ }}
326
+ onRestore={(mode, excludeKeys) => {
327
+ if (!rightMeta || busy) return;
328
+ if (rightMeta.baseline) {
329
+ confirmDestructive(
330
+ "Restore fresh install?",
331
+ "This wipes all app storage and reloads the app.",
332
+ "Wipe & reload",
333
+ () => void timeMachineStore.restore(rightMeta.id, "reload")
334
+ );
335
+ return;
336
+ }
337
+ // Land back on the list, where the outcome panel reports the
338
+ // results — the preview's diff is stale the moment restore runs.
339
+ void timeMachineStore
340
+ .restore(rightMeta.id, mode, excludeKeys)
341
+ .then(() => closePreview());
342
+ }}
343
+ onPickSide={setPicker}
344
+ onRefresh={() =>
345
+ void timeMachineStore.buildPreview(preview.right, preview.left)
346
+ }
347
+ />
348
+ {picker && (
349
+ <EventPickerModal
350
+ isOpen
351
+ onClose={() => setPicker(null)}
352
+ title={picker === "left" ? "Compare from" : "Compare to"}
353
+ position={picker}
354
+ items={pickerItems}
355
+ onSelect={onPickerSelect}
356
+ />
357
+ )}
358
+ </View>
359
+ );
360
+ }
361
+
362
+ // ── List screen ──
157
363
  const unavailable = providers.filter(
158
364
  (p) => p.detail && (!p.canCapture || !p.canRestore)
159
365
  );
160
366
 
161
367
  return (
162
- <View style={styles.container}>
368
+ <ScrollView style={styles.screenFill} contentContainerStyle={styles.container}>
163
369
  {lastOutcome && <OutcomePanel outcome={lastOutcome} />}
164
370
 
165
- {/* Capture bar */}
166
- <View style={styles.captureRow}>
167
- <TextInput
168
- style={styles.nameInput}
169
- value={name}
170
- onChangeText={setName}
171
- placeholder="Snapshot name…"
172
- placeholderTextColor={buoyColors.textMuted}
173
- editable={!captureDisabled}
174
- onSubmitEditing={capture}
175
- returnKeyType="done"
176
- testID="time-machine-name-input"
177
- />
371
+ {/* Capture card */}
372
+ <View style={styles.captureCard}>
373
+ <View style={styles.captureField}>
374
+ <Plus size={14} color={buoyColors.textMuted} />
375
+ <TextInput
376
+ style={styles.captureInput}
377
+ value={name}
378
+ onChangeText={setName}
379
+ placeholder="Name this restore point…"
380
+ placeholderTextColor={buoyColors.textMuted}
381
+ editable={!captureDisabled}
382
+ onSubmitEditing={capture}
383
+ returnKeyType="done"
384
+ testID="time-machine-name-input"
385
+ />
386
+ </View>
178
387
  <TouchableOpacity
179
388
  style={[styles.captureButton, captureDisabled && styles.buttonDisabled]}
180
389
  onPress={capture}
@@ -186,16 +395,6 @@ export function TimeMachineBody() {
186
395
  </Text>
187
396
  </TouchableOpacity>
188
397
  </View>
189
- <TouchableOpacity
190
- style={[styles.baselineButton, captureDisabled && styles.buttonDisabled]}
191
- onPress={captureBaseline}
192
- disabled={captureDisabled}
193
- testID="time-machine-capture-baseline"
194
- >
195
- <Text style={styles.baselineButtonText}>
196
- ⌂ CAPTURE FRESH-INSTALL BASELINE
197
- </Text>
198
- </TouchableOpacity>
199
398
  {atFreeLimit && (
200
399
  <Text style={styles.freeLimitNote}>
201
400
  Free plan: {FREE_SNAPSHOT_LIMIT} restore points. Upgrade to Pro for
@@ -203,24 +402,30 @@ export function TimeMachineBody() {
203
402
  </Text>
204
403
  )}
205
404
 
206
- {/* Provider status */}
207
- <View style={styles.providerStrip}>
208
- {providers.map((p) => {
209
- const dotColor = !p.canCapture
210
- ? buoyColors.textMuted
211
- : p.canRestore
212
- ? buoyColors.success
213
- : buoyColors.warning;
214
- return (
215
- <View key={p.id} style={styles.providerChip}>
216
- <View style={[styles.providerDot, { backgroundColor: dotColor }]} />
217
- <Text style={styles.providerLabel}>{p.label}</Text>
218
- </View>
219
- );
220
- })}
221
- {providers.length === 0 && (
222
- <Text style={styles.emptyText}>No snapshot providers registered</Text>
223
- )}
405
+ {/* Sources — detection at a glance */}
406
+ <View style={styles.sectionRow}>
407
+ <Text style={styles.sectionTitle}>Sources</Text>
408
+ <View style={styles.sourceTiles}>
409
+ {providers.map((p) => {
410
+ const IconComp = SOURCE_ICONS[p.id] ?? TimeMachineIcon;
411
+ const dotColor = !p.canCapture
412
+ ? buoyColors.textMuted
413
+ : p.canRestore
414
+ ? buoyColors.success
415
+ : buoyColors.warning;
416
+ return (
417
+ <View key={p.id} style={styles.sourceTile}>
418
+ <IconComp size={16} />
419
+ <View
420
+ style={[styles.sourceTileDot, { backgroundColor: dotColor }]}
421
+ />
422
+ </View>
423
+ );
424
+ })}
425
+ {providers.length === 0 && (
426
+ <Text style={styles.emptyText}>none registered</Text>
427
+ )}
428
+ </View>
224
429
  </View>
225
430
  {unavailable.map((p) => (
226
431
  <Text key={p.id} style={styles.providerDetail}>
@@ -228,86 +433,129 @@ export function TimeMachineBody() {
228
433
  </Text>
229
434
  ))}
230
435
 
231
- {/* Snapshot list */}
436
+ {/* Restore points */}
437
+ <Text style={[styles.sectionTitle, styles.sectionSpacing]}>
438
+ Restore points
439
+ </Text>
232
440
  {hasLoaded && snapshots.length === 0 && (
233
441
  <Text style={styles.emptyText}>
234
- No snapshots yet. Capture one, test your flow, then restore to jump
235
- right back.
442
+ No restore points yet. Set up the state you want to return to, then
443
+ capture it.
236
444
  </Text>
237
445
  )}
238
- {snapshots.map((meta) => {
446
+ {visibleSnapshots.map((meta) => {
239
447
  const warnings = warningCount(meta);
240
448
  return (
241
- <View key={meta.id} style={styles.snapshotRow} testID={`time-machine-snap-${meta.id}`}>
449
+ <TouchableOpacity
450
+ key={meta.id}
451
+ style={styles.snapshotCard}
452
+ onPress={() => openPreview(meta)}
453
+ testID={`time-machine-snap-${meta.id}`}
454
+ >
455
+ <View style={styles.snapshotIconTile}>
456
+ {meta.baseline ? (
457
+ <Home size={17} color={buoyColors.textSecondary} />
458
+ ) : (
459
+ <TimeMachineIcon size={18} />
460
+ )}
461
+ </View>
242
462
  <View style={styles.snapshotInfo}>
243
463
  <Text style={styles.snapshotName} numberOfLines={1}>
244
- {meta.baseline ? "⌂ " : ""}
245
464
  {meta.name}
246
465
  </Text>
247
- <Text style={styles.snapshotMeta}>
248
- {timeAgo(meta.createdAt)}
466
+ <Text style={styles.snapshotMeta} numberOfLines={1}>
249
467
  {meta.baseline
250
- ? " · wipes to fresh install"
251
- : ` · ${formatBytes(meta.sizeBytes)} · ${Object.keys(meta.sources).join(", ") || "empty"}`}
468
+ ? "wipes to fresh install"
469
+ : `${timeAgo(meta.createdAt)} · ${formatBytes(meta.sizeBytes)} · ${Object.keys(meta.sources).length} sources`}
470
+ {meta.scope?.length
471
+ ? ` · scoped (${meta.scope.length})`
472
+ : ""}
252
473
  {warnings > 0 ? ` · ⚠ ${warnings}` : ""}
253
474
  </Text>
254
475
  </View>
255
- {!meta.baseline && (
476
+ {!meta.baseline ? (
256
477
  <TouchableOpacity
257
- style={[styles.rowButton, busy && styles.buttonDisabled]}
478
+ style={[styles.rowAction, busy && styles.buttonDisabled]}
258
479
  onPress={() => restore(meta, "live")}
259
480
  disabled={busy !== null}
481
+ accessibilityLabel="Restore"
482
+ >
483
+ <Text style={styles.rowActionLabel}>RESTORE</Text>
484
+ </TouchableOpacity>
485
+ ) : (
486
+ <TouchableOpacity
487
+ style={[styles.rowAction, busy && styles.buttonDisabled]}
488
+ onPress={() => restore(meta, "reload")}
489
+ disabled={busy !== null}
490
+ accessibilityLabel="Wipe and reload"
260
491
  >
261
- <Text style={styles.rowButtonText}>RESTORE</Text>
492
+ <Text style={styles.rowActionLabelDanger}>WIPE</Text>
262
493
  </TouchableOpacity>
263
494
  )}
264
495
  <TouchableOpacity
265
- style={[styles.rowButton, busy && styles.buttonDisabled]}
266
- onPress={() => restore(meta, "reload")}
267
- disabled={busy !== null}
268
- >
269
- <Text style={styles.rowButtonText}>
270
- {meta.baseline ? "RESTORE" : "↻"}
271
- </Text>
272
- </TouchableOpacity>
273
- <TouchableOpacity
274
- style={styles.deleteButton}
496
+ style={styles.rowAction}
275
497
  onPress={() => remove(meta)}
276
498
  hitSlop={{ top: 8, bottom: 8, left: 4, right: 8 }}
499
+ accessibilityLabel="Delete"
277
500
  >
278
- <Text style={styles.deleteButtonText}>✕</Text>
501
+ <Trash2 size={14} color={buoyColors.error} />
279
502
  </TouchableOpacity>
280
- </View>
503
+ </TouchableOpacity>
281
504
  );
282
505
  })}
283
- </View>
506
+
507
+ {/* Destructive utility — deliberately last and understated. */}
508
+ <TouchableOpacity
509
+ style={[styles.wipeAllButton, busy && styles.buttonDisabled]}
510
+ onPress={wipeAll}
511
+ disabled={busy !== null}
512
+ testID="time-machine-wipe-all"
513
+ >
514
+ <Trash2 size={13} color={buoyColors.error} />
515
+ <Text style={styles.wipeAllText}>Wipe all app state…</Text>
516
+ </TouchableOpacity>
517
+ <Text style={styles.wipeAllCaption}>
518
+ Deletes all app storage and reloads — like a fresh install.
519
+ </Text>
520
+ </ScrollView>
284
521
  );
285
522
  }
286
523
 
287
524
  const styles = StyleSheet.create({
525
+ screenFill: {
526
+ flex: 1,
527
+ },
288
528
  container: {
289
529
  padding: 12,
290
530
  gap: 8,
291
531
  },
292
- captureRow: {
532
+ captureCard: {
293
533
  flexDirection: "row",
534
+ alignItems: "center",
294
535
  gap: 8,
295
536
  },
296
- nameInput: {
537
+ captureField: {
297
538
  flex: 1,
298
- backgroundColor: buoyColors.input,
539
+ flexDirection: "row",
540
+ alignItems: "center",
541
+ gap: 8,
542
+ backgroundColor: buoyColors.card,
299
543
  borderColor: buoyColors.border,
300
544
  borderWidth: 1,
301
- borderRadius: 6,
302
- color: buoyColors.text,
545
+ borderRadius: 10,
303
546
  paddingHorizontal: 10,
304
- paddingVertical: 8,
547
+ },
548
+ captureInput: {
549
+ flex: 1,
550
+ color: buoyColors.text,
305
551
  fontSize: 13,
552
+ paddingVertical: 9,
306
553
  },
307
554
  captureButton: {
308
555
  backgroundColor: buoyColors.primary,
309
- borderRadius: 6,
310
- paddingHorizontal: 14,
556
+ borderRadius: 10,
557
+ paddingHorizontal: 16,
558
+ paddingVertical: 10,
311
559
  justifyContent: "center",
312
560
  },
313
561
  captureButtonText: {
@@ -316,19 +564,6 @@ const styles = StyleSheet.create({
316
564
  fontWeight: "700",
317
565
  letterSpacing: 0.5,
318
566
  },
319
- baselineButton: {
320
- borderColor: buoyColors.border,
321
- borderWidth: 1,
322
- borderRadius: 6,
323
- paddingVertical: 8,
324
- alignItems: "center",
325
- },
326
- baselineButtonText: {
327
- color: buoyColors.textSecondary,
328
- fontSize: 11,
329
- fontWeight: "600",
330
- letterSpacing: 0.5,
331
- },
332
567
  buttonDisabled: {
333
568
  opacity: 0.4,
334
569
  },
@@ -336,83 +571,127 @@ const styles = StyleSheet.create({
336
571
  color: buoyColors.warning,
337
572
  fontSize: 11,
338
573
  },
339
- providerStrip: {
574
+ sectionRow: {
340
575
  flexDirection: "row",
341
- flexWrap: "wrap",
342
- gap: 6,
343
- marginTop: 4,
576
+ alignItems: "center",
577
+ justifyContent: "space-between",
578
+ marginTop: 12,
579
+ gap: 10,
580
+ },
581
+ sectionTitle: {
582
+ color: buoyColors.text,
583
+ fontSize: 14,
584
+ fontWeight: "700",
344
585
  },
345
- providerChip: {
586
+ sectionSpacing: {
587
+ marginTop: 14,
588
+ },
589
+ sourceTiles: {
346
590
  flexDirection: "row",
347
- alignItems: "center",
348
- gap: 5,
591
+ gap: 6,
592
+ flexWrap: "wrap",
593
+ justifyContent: "flex-end",
594
+ flex: 1,
595
+ },
596
+ sourceTile: {
597
+ width: 32,
598
+ height: 32,
599
+ borderRadius: 9,
349
600
  backgroundColor: buoyColors.card,
350
601
  borderColor: buoyColors.border,
351
602
  borderWidth: 1,
352
- borderRadius: 12,
353
- paddingHorizontal: 8,
354
- paddingVertical: 3,
603
+ alignItems: "center",
604
+ justifyContent: "center",
355
605
  },
356
- providerDot: {
606
+ sourceTileDot: {
607
+ position: "absolute",
608
+ top: 3,
609
+ right: 3,
357
610
  width: 6,
358
611
  height: 6,
359
612
  borderRadius: 3,
360
613
  },
361
- providerLabel: {
362
- color: buoyColors.textSecondary,
363
- fontSize: 11,
364
- },
365
614
  providerDetail: {
366
615
  color: buoyColors.textMuted,
367
616
  fontSize: 10,
617
+ marginTop: 4,
368
618
  },
369
619
  emptyText: {
370
620
  color: buoyColors.textMuted,
371
621
  fontSize: 12,
372
622
  marginTop: 8,
373
623
  },
374
- snapshotRow: {
624
+ snapshotCard: {
375
625
  flexDirection: "row",
376
626
  alignItems: "center",
377
627
  backgroundColor: buoyColors.card,
378
628
  borderColor: buoyColors.border,
379
629
  borderWidth: 1,
380
- borderRadius: 8,
381
- padding: 10,
382
- gap: 8,
630
+ borderRadius: 12,
631
+ paddingHorizontal: 12,
632
+ paddingVertical: 10,
633
+ marginTop: 8,
634
+ gap: 10,
635
+ },
636
+ snapshotIconTile: {
637
+ width: 36,
638
+ height: 36,
639
+ borderRadius: 10,
640
+ backgroundColor: buoyColors.hover,
641
+ alignItems: "center",
642
+ justifyContent: "center",
383
643
  },
384
644
  snapshotInfo: {
385
645
  flex: 1,
386
646
  minWidth: 0,
647
+ gap: 2,
387
648
  },
388
649
  snapshotName: {
389
650
  color: buoyColors.text,
390
- fontSize: 13,
391
- fontWeight: "600",
651
+ fontSize: 14,
652
+ fontWeight: "700",
392
653
  },
393
654
  snapshotMeta: {
394
655
  color: buoyColors.textMuted,
395
656
  fontSize: 10,
396
- marginTop: 2,
657
+ fontVariant: ["tabular-nums"],
397
658
  },
398
- rowButton: {
399
- borderColor: buoyColors.primary,
400
- borderWidth: 1,
401
- borderRadius: 6,
402
- paddingHorizontal: 8,
403
- paddingVertical: 6,
659
+ rowAction: {
660
+ padding: 4,
661
+ justifyContent: "center",
662
+ alignItems: "center",
404
663
  },
405
- rowButtonText: {
664
+ rowActionLabel: {
406
665
  color: buoyColors.primary,
407
- fontSize: 10,
408
- fontWeight: "700",
666
+ fontSize: 12,
667
+ fontWeight: "600",
409
668
  },
410
- deleteButton: {
411
- paddingHorizontal: 2,
669
+ rowActionLabelDanger: {
670
+ color: buoyColors.error,
671
+ fontSize: 12,
672
+ fontWeight: "600",
412
673
  },
413
- deleteButtonText: {
674
+ wipeAllButton: {
675
+ flexDirection: "row",
676
+ alignItems: "center",
677
+ justifyContent: "center",
678
+ gap: 7,
679
+ borderColor: `${buoyColors.error}55`,
680
+ borderWidth: 1,
681
+ borderRadius: 10,
682
+ paddingVertical: 10,
683
+ marginTop: 18,
684
+ },
685
+ wipeAllText: {
686
+ color: buoyColors.error,
687
+ fontSize: 12.5,
688
+ fontWeight: "600",
689
+ },
690
+ wipeAllCaption: {
414
691
  color: buoyColors.textMuted,
415
- fontSize: 13,
692
+ fontSize: 10,
693
+ textAlign: "center",
694
+ marginTop: 6,
416
695
  },
417
696
  outcomePanel: {
418
697
  backgroundColor: buoyColors.card,