@buoy-gg/time-machine 7.0.22 → 7.0.23

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,9 +1,10 @@
1
1
  /**
2
2
  * Restore Preview — "what will restoring this snapshot actually change?"
3
3
  *
4
- * ONE virtualized FlatList is the scrollable area (compare card, filters,
5
- * source cards and item rows are all list rows) with a sticky Restore CTA
6
- * footer — no nested scroll regions, thousands of storage keys stay cheap.
4
+ * ONE virtualized FlatList is the scrollable area (the section header, the
5
+ * source rows, their item rows and the inline diffs are all list rows) with
6
+ * a sticky Restore CTA footer — no nested scroll regions, so thousands of
7
+ * storage keys stay cheap.
7
8
  *
8
9
  * Layout follows the Time Machine mockup: source sections are card
9
10
  * dropdowns with tool-icon tiles, a "N keys · M changed" detection subtitle,
@@ -13,56 +14,45 @@
13
14
  * INCLUDE checkbox (source headers toggle their whole section) — unchecked
14
15
  * items are passed to restore as exclusions and left untouched.
15
16
  */
16
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
17
+ import {
18
+ memo,
19
+ useCallback,
20
+ useEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ type ReactNode,
25
+ } from "react";
17
26
  import {
18
27
  ActivityIndicator,
19
28
  FlatList,
20
29
  StyleSheet,
21
30
  Text,
22
- TextInput,
23
31
  TouchableOpacity,
24
32
  View,
25
33
  type ListRenderItem,
26
34
  } from "react-native";
27
35
  import {
28
- ChevronDown,
29
36
  ChevronUp,
30
- Filter,
37
+ AlertTriangle,
31
38
  RefreshCw,
32
- Search,
33
- buoyColors,
34
- macOSColors,
39
+ useSafeAreaInsets,
35
40
  } from "@buoy-gg/shared-ui";
36
- import {
37
- JotaiIcon,
38
- QueryIcon,
39
- ReduxIcon,
40
- StorageIcon,
41
- TimeMachineIcon,
42
- ZustandIcon,
43
- } from "@buoy-gg/floating-tools-core";
44
41
  import type { StateSnapshotPreviewItem } from "@buoy-gg/shared-ui";
45
42
  import { useToolHeader, useToolHost } from "@buoy-gg/core";
43
+ import { DisclosureChevron, Group, SectionLabel } from "../../ui/primitives";
44
+ import { TM_MONO, tm } from "../../ui/tmTheme";
46
45
  import type {
47
46
  PreviewSideRef,
48
47
  PreviewSource,
49
48
  PreviewState,
50
49
  } from "../../store/timeMachineStore";
51
- import { timeMachineStore } from "../../store/timeMachineStore";
50
+ import { timeMachineStore, willRestoreRoute } from "../../store/timeMachineStore";
51
+ import { ItemDiffPanel } from "./ItemDiffPanel";
52
+ import { RestorePointNameRow } from "./RestorePointNameRow";
52
53
  import type { SnapshotMeta } from "../../store/snapshotVault";
53
54
  import { VERDICT_META } from "./verdictMeta";
54
55
 
55
- const SOURCE_ICONS: Record<
56
- string,
57
- React.ComponentType<{ size?: number; color?: string }>
58
- > = {
59
- storage: StorageIcon,
60
- redux: ReduxIcon,
61
- zustand: ZustandIcon,
62
- jotai: JotaiIcon,
63
- query: QueryIcon,
64
- };
65
-
66
56
  const SOURCE_NOUNS: Record<string, string> = {
67
57
  storage: "keys",
68
58
  redux: "slices",
@@ -71,7 +61,18 @@ const SOURCE_NOUNS: Record<string, string> = {
71
61
  query: "queries",
72
62
  };
73
63
 
74
- interface RowSourceHeader {
64
+ /**
65
+ * Where a row sits in the one card that holds the whole list. The list has
66
+ * to stay flat to stay virtualized, so each row paints its own share of the
67
+ * card: the first row rounds its top, the last rounds its bottom, and every
68
+ * other row draws the hairline above it.
69
+ */
70
+ interface CardPosition {
71
+ first: boolean;
72
+ last: boolean;
73
+ }
74
+
75
+ interface RowSourceHeader extends CardPosition {
75
76
  kind: "source";
76
77
  source: PreviewSource;
77
78
  collapsed: boolean;
@@ -83,18 +84,43 @@ interface RowSourceHeader {
83
84
  /** "60 keys · 7 changed" — detection proof without expanding. */
84
85
  summary: string;
85
86
  }
86
- interface RowItem {
87
+ interface RowItem extends CardPosition {
87
88
  kind: "item";
88
89
  sourceId: string;
89
90
  item: StateSnapshotPreviewItem;
90
91
  excluded: boolean;
91
92
  }
92
- interface RowNote {
93
+ /**
94
+ * The route leg, as a row in the same card as the sources. It is not a
95
+ * source — nothing diffs it — but "will this also take me back to
96
+ * /checkout?" is a decision about THIS restore, so it belongs beside the
97
+ * other things the restore is about to do rather than on the list screen.
98
+ */
99
+ interface RowRoute extends CardPosition {
100
+ kind: "route";
101
+ href: string;
102
+ on: boolean;
103
+ }
104
+ interface RowDiff extends CardPosition {
105
+ kind: "diff";
106
+ sourceId: string;
107
+ item: StateSnapshotPreviewItem;
108
+ }
109
+ interface RowNote extends CardPosition {
93
110
  kind: "note";
94
111
  id: string;
95
112
  text: string;
96
113
  }
97
- type Row = RowSourceHeader | RowItem | RowNote;
114
+ type Row = RowSourceHeader | RowItem | RowNote | RowDiff | RowRoute;
115
+
116
+ /** Shared empty selection — a stable identity for "nothing is excluded". */
117
+ const EMPTY_SELECTION: ReadonlySet<string> = new Set<string>();
118
+
119
+ function sameSelection(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
120
+ if (a.size !== b.size) return false;
121
+ for (const key of a) if (!b.has(key)) return false;
122
+ return true;
123
+ }
98
124
 
99
125
  /** Compound exclusion key — what timeMachineStore.restore expects. */
100
126
  function cKey(sourceId: string, itemKey: string): string {
@@ -112,39 +138,72 @@ function shortPreview(value: unknown): string {
112
138
  }
113
139
  }
114
140
 
115
- function sideName(ref: PreviewSideRef, snapshots: SnapshotMeta[]): string {
116
- if (ref === "current") return "Current state";
141
+
142
+
143
+ /**
144
+ * The grouped-card shell one row paints. Rounds the ends, insets the card
145
+ * from the sheet, and draws the hairline that separates it from the row
146
+ * above — the same shape the home list's <Group> makes, rebuilt per row so
147
+ * the list stays virtualized.
148
+ */
149
+ function CardRow({
150
+ position,
151
+ children,
152
+ }: {
153
+ position: CardPosition;
154
+ children: ReactNode;
155
+ }) {
117
156
  return (
118
- snapshots.find((s) => s.id === ref.snapshotId)?.name ?? ref.snapshotId
157
+ <View
158
+ style={[
159
+ styles.cardRow,
160
+ position.first ? styles.cardRowFirst : null,
161
+ position.last ? styles.cardRowLast : null,
162
+ ]}
163
+ >
164
+ {position.first ? null : <View style={styles.cardSeparator} />}
165
+ {children}
166
+ </View>
119
167
  );
120
168
  }
121
169
 
122
- function timeAgo(ts: number): string {
123
- const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
124
- if (s < 60) return `${s}s`;
125
- const m = Math.round(s / 60);
126
- if (m < 60) return `${m}m`;
127
- const h = Math.round(m / 60);
128
- return h < 24 ? `${h}h` : `${Math.round(h / 24)}d`;
170
+ function CountPill({
171
+ glyph,
172
+ count,
173
+ color,
174
+ }: {
175
+ glyph: string;
176
+ count: number;
177
+ color: string;
178
+ }) {
179
+ return (
180
+ <View style={[styles.countPill, { backgroundColor: `${color}26` }]}>
181
+ <Text style={[styles.countPillText, { color }]}>
182
+ {glyph}
183
+ {count}
184
+ </Text>
185
+ </View>
186
+ );
129
187
  }
130
188
 
131
189
  function Checkbox({ state }: { state: "all" | "some" | "none" }) {
132
190
  return (
133
- <View
134
- style={[styles.checkbox, state !== "none" && styles.checkboxChecked]}
135
- >
136
- {state === "all" && <Text style={styles.checkboxGlyph}>✓</Text>}
137
- {state === "some" && <Text style={styles.checkboxGlyph}>−</Text>}
191
+ <View style={[styles.checkbox, state !== "none" && styles.checkboxChecked]}>
192
+ {state === "none" ? null : (
193
+ <Text style={styles.checkboxGlyph}>{state === "all" ? "✓" : "−"}</Text>
194
+ )}
138
195
  </View>
139
196
  );
140
197
  }
141
198
 
142
199
  const ItemRow = memo(function ItemRow({
143
200
  row,
201
+ expanded,
144
202
  onOpen,
145
203
  onToggle,
146
204
  }: {
147
205
  row: RowItem;
206
+ expanded: boolean;
148
207
  /** Zero-arg by design — press plumbing may invoke handlers with a
149
208
  * synthetic event; the row is bound at the call site. */
150
209
  onOpen: () => void;
@@ -157,16 +216,16 @@ const ItemRow = memo(function ItemRow({
157
216
  row.item.verdict === "added" ||
158
217
  row.item.verdict === "removed";
159
218
  return (
160
- <View style={[styles.itemRow, dimmed && styles.dimmed]}>
161
- {meta.applies && (
162
- <TouchableOpacity
163
- onPress={onToggle}
164
- hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
165
- testID={`tm-preview-toggle-${row.sourceId}-${row.item.key}`}
166
- >
167
- <Checkbox state={row.excluded ? "none" : "all"} />
168
- </TouchableOpacity>
169
- )}
219
+ <CardRow position={row}>
220
+ <View style={[styles.itemRow, dimmed && styles.dimmed]}>
221
+ <TouchableOpacity
222
+ onPress={onToggle}
223
+ disabled={!meta.applies}
224
+ hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
225
+ testID={`tm-preview-toggle-${row.sourceId}-${row.item.key}`}
226
+ >
227
+ <Checkbox state={!meta.applies || row.excluded ? "none" : "all"} />
228
+ </TouchableOpacity>
170
229
  <TouchableOpacity
171
230
  style={styles.itemBodyTouch}
172
231
  onPress={() => openable && onOpen()}
@@ -211,9 +270,16 @@ const ItemRow = memo(function ItemRow({
211
270
  )
212
271
  )}
213
272
  </View>
214
- {openable && <Text style={styles.itemChevron}>›</Text>}
273
+ {openable ? (
274
+ expanded ? (
275
+ <ChevronUp size={16} color={tm.color.labelTertiary} />
276
+ ) : (
277
+ <DisclosureChevron />
278
+ )
279
+ ) : null}
215
280
  </TouchableOpacity>
216
- </View>
281
+ </View>
282
+ </CardRow>
217
283
  );
218
284
  });
219
285
 
@@ -222,10 +288,7 @@ export interface RestorePreviewScreenProps {
222
288
  snapshots: SnapshotMeta[];
223
289
  busy: boolean;
224
290
  onBack: () => void;
225
- onOpenItem: (sourceId: string, item: StateSnapshotPreviewItem) => void;
226
291
  onRestore: (mode: "live" | "reload", excludeKeys: string[]) => void;
227
- onPickSide: (position: "left" | "right") => void;
228
- onRefresh: () => void;
229
292
  }
230
293
 
231
294
  export function RestorePreviewScreen({
@@ -233,18 +296,32 @@ export function RestorePreviewScreen({
233
296
  snapshots,
234
297
  busy,
235
298
  onBack,
236
- onOpenItem,
237
299
  onRestore,
238
- onPickSide,
239
- onRefresh,
240
300
  }: RestorePreviewScreenProps) {
241
301
  const host = useToolHost();
242
302
  const hosted = host.instanceId !== undefined;
243
- const [localSearch, setLocalSearch] = useState("");
303
+ // `minBottom` is a floor, not a preference: the dimension map can't know
304
+ // about a device that ships next year, and an unmapped one falls through to
305
+ // `bottom: 0`. This keeps the CTA off the home indicator either way.
306
+ const insets = useSafeAreaInsets({ minBottom: 16 });
307
+
244
308
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
245
- const [allCollapsed, setAllCollapsed] = useState(false);
246
309
  const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
247
- const search = localSearch;
310
+ /** Set by every checkbox interaction — see the auto-save effect below. */
311
+ const userEditedRef = useRef(false);
312
+ /** Item rows whose diff is expanded inline, by compound key. */
313
+ const [openDiffs, setOpenDiffs] = useState<ReadonlySet<string>>(EMPTY_SELECTION);
314
+ const toggleDiff = useCallback((key: string) => {
315
+ setOpenDiffs((prev) => {
316
+ const next = new Set(prev);
317
+ if (next.has(key)) next.delete(key);
318
+ else next.add(key);
319
+ return next;
320
+ });
321
+ }, []);
322
+ // Filtering lives in the shared modal header (the magnifier), same as the
323
+ // restore-point list and every other Buoy tool — one place to look for it.
324
+ const search = host.search;
248
325
 
249
326
  const rightMeta =
250
327
  preview.right === "current"
@@ -252,30 +329,42 @@ export function RestorePreviewScreen({
252
329
  : snapshots.find(
253
330
  (s) => s.id === (preview.right as { snapshotId: string }).snapshotId
254
331
  );
332
+ // Latest-ref: `renderRow` is memoized on its handlers, so this one has to
333
+ // keep a stable identity while still reading the current snapshot.
334
+ const rightMetaRef = useRef(rightMeta);
335
+ rightMetaRef.current = rightMeta;
336
+
337
+ /** Flip whether this restore also navigates back to the captured route. */
338
+ const toggleRoute = useCallback(() => {
339
+ if (!rightMetaRef.current) return;
340
+ const meta = rightMetaRef.current;
341
+ void timeMachineStore.setRestoreRoute(meta.id, !willRestoreRoute(meta));
342
+ }, []);
343
+
255
344
  const isBaseline = rightMeta?.baseline === true;
256
345
  const liveLeft = preview.left === "current";
257
346
 
258
- // Master selection: "None → tick just what you want" is the fast path for
259
- // targeted restores ("only my cart"), the inverse of the default all-in.
260
- const selectAll = useCallback(() => setExcluded(new Set()), []);
261
- const selectNone = useCallback(() => {
262
- const next = new Set<string>();
263
- for (const src of preview.sources) {
264
- for (const item of src.items) {
265
- if (VERDICT_META[item.verdict].applies) next.add(cKey(src.id, item.key));
266
- }
267
- }
268
- setExcluded(next);
269
- }, [preview.sources]);
347
+ /**
348
+ * The selection this restore point already remembers, as exclusions. Null
349
+ * until there is a snapshot on the right-hand side to have one.
350
+ */
351
+ const savedExcluded = useMemo<ReadonlySet<string> | null>(() => {
352
+ if (!rightMeta) return null;
353
+ return rightMeta.excludedKeys?.length
354
+ ? new Set(rightMeta.excludedKeys)
355
+ : EMPTY_SELECTION;
356
+ }, [rightMeta]);
270
357
 
271
- // A saved scope pre-selects the checkboxes: everything applicable that is
272
- // OUTSIDE the scope starts excluded, so the preview shows exactly what a
273
- // scoped restore will do (and stays adjustable).
274
- const seededScopeFor = useRef<string | null>(null);
275
- useEffect(() => {
276
- if (!rightMeta?.scope?.length || preview.building) return;
277
- if (seededScopeFor.current === rightMeta.id) return;
278
- seededScopeFor.current = rightMeta.id;
358
+ /**
359
+ * A snapshot still carrying the older `scope` include-list ("only ever
360
+ * touch these", set via MCP). It seeds the checkboxes and is converted to
361
+ * exclusions on read — a restore of it would otherwise still apply include
362
+ * semantics, and the two forms would disagree about what one tap does.
363
+ */
364
+ const legacyScoped =
365
+ !!rightMeta?.scope?.length && !rightMeta?.excludedKeys?.length;
366
+ const legacySeed = useMemo<ReadonlySet<string>>(() => {
367
+ if (!legacyScoped || !rightMeta?.scope) return EMPTY_SELECTION;
279
368
  const scopeSet = new Set(rightMeta.scope);
280
369
  const next = new Set<string>();
281
370
  for (const src of preview.sources) {
@@ -286,34 +375,49 @@ export function RestorePreviewScreen({
286
375
  }
287
376
  }
288
377
  }
289
- setExcluded(next);
290
- }, [rightMeta, preview.building, preview.sources]);
378
+ return next;
379
+ }, [legacyScoped, rightMeta, preview.sources]);
291
380
 
292
- // Persist the current selection as this restore point's scope — from then
293
- // on, RESTORE (home row, MCP, this screen) touches only these items.
294
- // Selecting everything clears the scope back to a full restore.
295
- const saveScope = useCallback(() => {
296
- if (!rightMeta) return;
297
- const selected: string[] = [];
298
- for (const src of preview.sources) {
299
- for (const item of src.items) {
300
- const key = cKey(src.id, item.key);
301
- if (VERDICT_META[item.verdict].applies && !excluded.has(key)) {
302
- selected.push(key);
303
- }
304
- }
381
+ // Open with the saved selection already applied — once per snapshot, and
382
+ // only once there is a diff to apply it to.
383
+ const seededFor = useRef<string | null>(null);
384
+ useEffect(() => {
385
+ if (!rightMeta || preview.building || !savedExcluded) return;
386
+ if (seededFor.current === rightMeta.id) return;
387
+ seededFor.current = rightMeta.id;
388
+ userEditedRef.current = false;
389
+ const seed = savedExcluded.size > 0 ? savedExcluded : legacySeed;
390
+ if (seed.size > 0) setExcluded(new Set(seed));
391
+ // Convert an older include-list scope the moment it's read, so the two
392
+ // forms never disagree about what a restore will do.
393
+ if (legacyScoped && seed.size > 0) {
394
+ void timeMachineStore.setExclusions(rightMeta.id, Array.from(seed));
305
395
  }
306
- const clearing = excluded.size === 0;
307
- void timeMachineStore.setScope(rightMeta.id, clearing ? null : selected);
308
- host.showToast(
309
- clearing
310
- ? "Scope cleared — full restore"
311
- : `Scope saved — this restore point now restores only ${selected.length} item${selected.length === 1 ? "" : "s"}`,
312
- "success"
313
- );
314
- }, [rightMeta, preview.sources, excluded, host]);
396
+ }, [rightMeta, preview.building, savedExcluded, legacySeed, legacyScoped]);
397
+
398
+ /**
399
+ * Auto-save. The checkboxes ARE the restore point's setting, so there is
400
+ * nothing to confirm — every edit persists as EXCLUSIONS (the items to
401
+ * leave alone), and every later restore of this point honors them, one-tap
402
+ * RESTORE and MCP included.
403
+ *
404
+ * Never an include list: unchecking one of ten keys must keep restoring the
405
+ * other nine AND anything that only starts differing tomorrow, which a list
406
+ * built from today's diff cannot do.
407
+ *
408
+ * Gated on a ref rather than on comparing to what's stored, because on the
409
+ * frames before the seed lands `excluded` is legitimately empty — writing
410
+ * that would erase the saved selection we are about to load.
411
+ */
412
+ useEffect(() => {
413
+ if (!rightMeta || !savedExcluded || !userEditedRef.current) return;
414
+ if (sameSelection(excluded, savedExcluded)) return;
415
+ const keys = Array.from(excluded);
416
+ void timeMachineStore.setExclusions(rightMeta.id, keys.length ? keys : null);
417
+ }, [excluded, rightMeta, savedExcluded]);
315
418
 
316
419
  const toggleItem = useCallback((key: string) => {
420
+ userEditedRef.current = true;
317
421
  setExcluded((prev) => {
318
422
  const next = new Set(prev);
319
423
  if (next.has(key)) next.delete(key);
@@ -324,6 +428,7 @@ export function RestorePreviewScreen({
324
428
 
325
429
  const toggleSource = useCallback(
326
430
  (source: PreviewSource, includeState: "all" | "some" | "none") => {
431
+ userEditedRef.current = true;
327
432
  setExcluded((prev) => {
328
433
  const next = new Set(prev);
329
434
  for (const item of source.items) {
@@ -345,16 +450,6 @@ export function RestorePreviewScreen({
345
450
  setCollapsed((c) => ({ ...c, [id]: !currentlyCollapsed }));
346
451
  }, []);
347
452
 
348
- const toggleAllCollapsed = useCallback(() => {
349
- setAllCollapsed((prev) => {
350
- const next = !prev;
351
- setCollapsed(
352
- Object.fromEntries(preview.sources.map((s) => [s.id, next]))
353
- );
354
- return next;
355
- });
356
- }, [preview.sources]);
357
-
358
453
  const totals = useMemo(() => {
359
454
  let applies = 0;
360
455
  let excludedCount = 0;
@@ -383,23 +478,27 @@ export function RestorePreviewScreen({
383
478
  hosted
384
479
  ? {
385
480
  title: "Restore Preview",
386
- subtitle: rightMeta?.name ?? "compare",
481
+ subtitle: rightMeta?.name ?? "preview",
387
482
  onBack,
388
- searchable: false, // the in-body "Filter keys…" field covers it
389
483
  }
390
484
  : null,
391
485
  // eslint-disable-next-line react-hooks/exhaustive-deps
392
486
  [hosted, rightMeta?.name]
393
487
  );
394
488
 
395
- const rows = useMemo<Row[]>(() => {
489
+ const { rows, blockedSources } = useMemo(() => {
396
490
  const q = search.trim().toLowerCase();
397
491
  const out: Row[] = [];
492
+ // Sources the restore would not touch at all get no card: "no changes"
493
+ // rows are noise on a screen about what WILL change.
494
+ /** Sources holding a won't-apply item, so the note can name where to look. */
495
+ const blocked: string[] = [];
398
496
  for (const src of preview.sources) {
399
497
  // Every detected source gets a card whose subtitle carries the
400
498
  // detection proof ("60 keys · 7 changed" / "2 slices · no changes"),
401
499
  // so clean sources need no expansion. Unchanged ITEMS never render.
402
500
  const relevant = src.items.some((i) => i.verdict !== "unchanged");
501
+ if (!relevant && !src.error) continue;
403
502
  const expandable = relevant || src.notes.length > 0 || !!src.error;
404
503
 
405
504
  const applicable = src.items.filter(
@@ -408,6 +507,7 @@ export function RestorePreviewScreen({
408
507
  const wontApplyCount = src.items.filter(
409
508
  (i) => i.verdict === "wont-apply"
410
509
  ).length;
510
+ if (wontApplyCount > 0) blocked.push(src.label);
411
511
  const noun =
412
512
  src.items.length === 1
413
513
  ? (SOURCE_NOUNS[src.id] ?? "items").replace(/ies$/, "y").replace(/s$/, "")
@@ -444,9 +544,10 @@ export function RestorePreviewScreen({
444
544
  if (q && !item.label.toLowerCase().includes(q)) return false;
445
545
  return true;
446
546
  });
447
- const isCollapsed = !expandable
448
- ? true
449
- : (collapsed[src.id] ?? (applicable.length === 0 && !src.error));
547
+ // Everything starts collapsed. The screen's job on open is "which
548
+ // sources would this touch, and how much" — expanding a source with
549
+ // 58 keys before you asked buries that under its own contents.
550
+ const isCollapsed = expandable ? (collapsed[src.id] ?? true) : true;
450
551
  out.push({
451
552
  kind: "source",
452
553
  source: src,
@@ -456,55 +557,97 @@ export function RestorePreviewScreen({
456
557
  counts,
457
558
  expandable,
458
559
  summary,
560
+ first: false,
561
+ last: false,
459
562
  });
460
563
  if (!isCollapsed) {
461
564
  for (const note of src.notes) {
462
- out.push({ kind: "note", id: `${src.id}:${note}`, text: note });
565
+ out.push({
566
+ kind: "note",
567
+ id: `${src.id}:${note}`,
568
+ text: note,
569
+ first: false,
570
+ last: false,
571
+ });
463
572
  }
464
573
  if (src.error) {
465
- out.push({ kind: "note", id: `${src.id}:err`, text: `⚠ ${src.error}` });
574
+ out.push({
575
+ kind: "note",
576
+ id: `${src.id}:err`,
577
+ text: `⚠ ${src.error}`,
578
+ first: false,
579
+ last: false,
580
+ });
466
581
  }
467
582
  for (const item of visible) {
583
+ const key = cKey(src.id, item.key);
468
584
  out.push({
469
585
  kind: "item",
470
586
  sourceId: src.id,
471
587
  item,
472
- excluded: excluded.has(cKey(src.id, item.key)),
588
+ excluded: excluded.has(key),
589
+ first: false,
590
+ last: false,
473
591
  });
592
+ if (openDiffs.has(key)) {
593
+ out.push({
594
+ kind: "diff",
595
+ sourceId: src.id,
596
+ item,
597
+ first: false,
598
+ last: false,
599
+ });
600
+ }
474
601
  }
475
602
  }
476
603
  }
477
- return out;
478
- }, [preview.sources, search, collapsed, excluded]);
604
+ if (rightMeta?.route) {
605
+ out.push({
606
+ kind: "route",
607
+ href: rightMeta.route.href,
608
+ on: willRestoreRoute(rightMeta),
609
+ first: false,
610
+ last: false,
611
+ });
612
+ }
613
+ // ONE card holds every source — the ends of the LIST round, and every
614
+ // other boundary is just a hairline. Per-source cards would read as
615
+ // separate groups, which they aren't: this is one list of what a restore
616
+ // would touch.
617
+ if (out.length > 0) {
618
+ out[0].first = true;
619
+ out[out.length - 1].last = true;
620
+ }
621
+ return { rows: out, blockedSources: blocked };
622
+ }, [preview.sources, search, collapsed, excluded, openDiffs, rightMeta]);
479
623
 
480
624
  const renderRow: ListRenderItem<Row> = useCallback(
481
625
  ({ item: row }) => {
482
626
  if (row.kind === "source") {
483
627
  const src = row.source;
484
628
  const { counts } = row;
485
- const clean =
486
- counts.added + counts.removed + counts.changed === 0 && !src.error;
487
- const IconComp = SOURCE_ICONS[src.id] ?? TimeMachineIcon;
488
629
  return (
630
+ <CardRow position={row}>
489
631
  <View style={styles.sourceCard}>
490
- {row.applicableCount > 0 && (
491
- <TouchableOpacity
492
- onPress={() => toggleSource(src, row.includeState)}
493
- hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
494
- testID={`tm-preview-source-toggle-${src.id}`}
495
- >
496
- <Checkbox state={row.includeState} />
497
- </TouchableOpacity>
498
- )}
632
+ {/* Always rendered, even when there is nothing to select: it is
633
+ what every row lines up against now that the icons are gone,
634
+ and an empty ring reads as "nothing selected here". */}
635
+ <TouchableOpacity
636
+ onPress={() => toggleSource(src, row.includeState)}
637
+ disabled={row.applicableCount === 0}
638
+ hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
639
+ testID={`tm-preview-source-toggle-${src.id}`}
640
+ >
641
+ <Checkbox
642
+ state={row.applicableCount === 0 ? "none" : row.includeState}
643
+ />
644
+ </TouchableOpacity>
499
645
  <TouchableOpacity
500
646
  style={styles.sourceCardTouch}
501
647
  onPress={() => row.expandable && toggleCollapse(src.id, row.collapsed)}
502
648
  disabled={!row.expandable}
503
649
  testID={`tm-preview-source-${src.id}`}
504
650
  >
505
- <View style={styles.sourceIconTile}>
506
- <IconComp size={18} />
507
- </View>
508
651
  <View style={styles.sourceNameBlock}>
509
652
  <Text style={styles.sourceName} numberOfLines={1}>
510
653
  {src.label}
@@ -515,53 +658,82 @@ export function RestorePreviewScreen({
515
658
  : row.summary}
516
659
  </Text>
517
660
  </View>
518
- {!clean && (
519
- <View style={styles.sourceBadges}>
520
- {counts.removed > 0 && (
521
- <Text style={[styles.sourceBadge, { color: buoyColors.error }]}>
522
- −{counts.removed}
523
- </Text>
524
- )}
525
- {counts.added > 0 && (
526
- <Text style={[styles.sourceBadge, { color: buoyColors.success }]}>
527
- +{counts.added}
528
- </Text>
529
- )}
530
- {counts.changed > 0 && (
531
- <Text style={[styles.sourceBadge, { color: buoyColors.info }]}>
532
- ≈{counts.changed}
533
- </Text>
534
- )}
535
- </View>
536
- )}
661
+ <View style={styles.sourceBadges}>
662
+ {counts.removed > 0 ? (
663
+ <CountPill glyph="−" count={counts.removed} color={tm.color.danger} />
664
+ ) : null}
665
+ {counts.added > 0 ? (
666
+ <CountPill glyph="+" count={counts.added} color={tm.color.accent} />
667
+ ) : null}
668
+ {counts.changed > 0 ? (
669
+ <CountPill glyph="≈" count={counts.changed} color={tm.color.info} />
670
+ ) : null}
671
+ </View>
537
672
  {row.expandable ? (
538
673
  row.collapsed ? (
539
- <ChevronDown size={16} color={buoyColors.textMuted} />
674
+ <DisclosureChevron />
540
675
  ) : (
541
- <ChevronUp size={16} color={buoyColors.textMuted} />
676
+ <ChevronUp size={16} color={tm.color.labelTertiary} />
542
677
  )
543
678
  ) : null}
544
679
  </TouchableOpacity>
545
680
  </View>
681
+ </CardRow>
546
682
  );
547
683
  }
548
684
  if (row.kind === "note") {
549
- return <Text style={styles.noteRow}>{row.text}</Text>;
685
+ return (
686
+ <CardRow position={row}>
687
+ <Text style={styles.noteRow}>{row.text}</Text>
688
+ </CardRow>
689
+ );
690
+ }
691
+ if (row.kind === "route") {
692
+ return (
693
+ <CardRow position={row}>
694
+ <TouchableOpacity
695
+ style={styles.routeRow}
696
+ onPress={toggleRoute}
697
+ accessibilityRole="switch"
698
+ accessibilityState={{ checked: row.on }}
699
+ accessibilityLabel={`Also return to ${row.href}`}
700
+ testID="tm-preview-route"
701
+ >
702
+ <Checkbox state={row.on ? "all" : "none"} />
703
+ <View style={styles.routeText}>
704
+ <Text style={styles.routeTitle}>Return to this screen</Text>
705
+ <Text style={styles.routeHref} numberOfLines={1}>
706
+ {row.href}
707
+ </Text>
708
+ </View>
709
+ </TouchableOpacity>
710
+ </CardRow>
711
+ );
712
+ }
713
+ if (row.kind === "diff") {
714
+ return (
715
+ <CardRow position={row}>
716
+ <ItemDiffPanel item={row.item} />
717
+ </CardRow>
718
+ );
550
719
  }
551
720
  return (
552
721
  <ItemRow
553
722
  row={row}
554
- onOpen={() => onOpenItem(row.sourceId, row.item)}
723
+ expanded={openDiffs.has(cKey(row.sourceId, row.item.key))}
724
+ onOpen={() => toggleDiff(cKey(row.sourceId, row.item.key))}
555
725
  onToggle={() => toggleItem(cKey(row.sourceId, row.item.key))}
556
726
  />
557
727
  );
558
728
  },
559
- [onOpenItem, toggleCollapse, toggleItem, toggleSource]
729
+ [openDiffs, toggleDiff, toggleCollapse, toggleItem, toggleSource, toggleRoute]
560
730
  );
561
731
 
562
732
  const keyExtractor = useCallback((row: Row) => {
563
733
  if (row.kind === "source") return `s:${row.source.id}`;
564
734
  if (row.kind === "note") return `n:${row.id}`;
735
+ if (row.kind === "route") return "route";
736
+ if (row.kind === "diff") return `d:${row.sourceId}:${row.item.key}`;
565
737
  return `i:${row.sourceId}:${row.item.key}`;
566
738
  }, []);
567
739
 
@@ -578,182 +750,21 @@ export function RestorePreviewScreen({
578
750
  </View>
579
751
  )}
580
752
 
581
- {/* Compare card */}
582
- <View style={styles.compareCard}>
583
- <TouchableOpacity
584
- style={styles.compareSide}
585
- onPress={() => onPickSide("left")}
586
- testID="tm-preview-pick-left"
587
- >
588
- <View style={styles.compareTagRow}>
589
- <View style={[styles.stateDot, { backgroundColor: buoyColors.info }]} />
590
- <Text style={styles.compareTag}>
591
- {liveLeft ? "CURRENT STATE" : "SNAPSHOT"}
592
- </Text>
593
- </View>
594
- <Text style={styles.compareName} numberOfLines={1}>
595
- {sideName(preview.left, snapshots)}
596
- </Text>
597
- {liveLeft ? (
598
- <View style={styles.compareMetaRow}>
599
- <Text style={styles.compareMeta}>
600
- built {timeAgo(preview.builtAt)} ago
601
- </Text>
602
- <TouchableOpacity
603
- onPress={onRefresh}
604
- disabled={preview.building}
605
- hitSlop={{ top: 8, bottom: 8, left: 6, right: 8 }}
606
- accessibilityLabel="Rebuild preview"
607
- testID="tm-preview-refresh"
608
- >
609
- <RefreshCw
610
- size={11}
611
- color={
612
- preview.building
613
- ? macOSColors.text.disabled
614
- : buoyColors.textSecondary
615
- }
616
- />
617
- </TouchableOpacity>
618
- </View>
619
- ) : (
620
- <Text style={styles.compareMeta}>snapshot</Text>
621
- )}
622
- </TouchableOpacity>
623
-
624
- <View style={styles.swapBadge}>
625
- <Text style={styles.swapGlyph}>⇄</Text>
626
- </View>
627
-
628
- <TouchableOpacity
629
- style={[styles.compareSide, styles.compareSideRight]}
630
- onPress={() => onPickSide("right")}
631
- testID="tm-preview-pick-right"
632
- >
633
- <View style={styles.compareTagRow}>
634
- <Text style={styles.compareTag}>
635
- {isBaseline ? "BASELINE" : "SNAPSHOT"}
636
- </Text>
637
- <View
638
- style={[styles.stateDot, { backgroundColor: buoyColors.success }]}
753
+ {rightMeta ? (
754
+ <>
755
+ <SectionLabel>Restore Point</SectionLabel>
756
+ <Group>
757
+ <RestorePointNameRow
758
+ name={rightMeta.name}
759
+ onRename={(next) =>
760
+ void timeMachineStore.rename(rightMeta.id, next)
761
+ }
639
762
  />
640
- </View>
641
- <Text style={[styles.compareName, styles.compareNameRight]} numberOfLines={1}>
642
- {sideName(preview.right, snapshots)}
643
- </Text>
644
- <Text style={[styles.compareMeta, styles.compareMetaRight]}>
645
- {rightMeta ? `${timeAgo(rightMeta.createdAt)} ago` : ""}
646
- </Text>
647
- </TouchableOpacity>
648
- </View>
763
+ </Group>
764
+ </>
765
+ ) : null}
649
766
 
650
- {/* Changes to restore + summary chip */}
651
- <View style={styles.changesRow}>
652
- <View style={styles.changesTitleRow}>
653
- <Text style={styles.changesTitle}>Changes to restore</Text>
654
- {totals.applies + totals.excludedCount > 1 && (
655
- <View style={styles.selectLinks}>
656
- <TouchableOpacity
657
- onPress={selectAll}
658
- hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
659
- testID="tm-preview-select-all"
660
- >
661
- <Text
662
- style={[
663
- styles.selectLink,
664
- totals.excludedCount === 0 && styles.selectLinkActive,
665
- ]}
666
- >
667
- all
668
- </Text>
669
- </TouchableOpacity>
670
- <Text style={styles.selectLinkSep}>·</Text>
671
- <TouchableOpacity
672
- onPress={selectNone}
673
- hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
674
- testID="tm-preview-select-none"
675
- >
676
- <Text
677
- style={[
678
- styles.selectLink,
679
- totals.applies === 0 &&
680
- totals.excludedCount > 0 &&
681
- styles.selectLinkActive,
682
- ]}
683
- >
684
- none
685
- </Text>
686
- </TouchableOpacity>
687
- {liveLeft && rightMeta && (
688
- <>
689
- <Text style={styles.selectLinkSep}>·</Text>
690
- <TouchableOpacity
691
- onPress={saveScope}
692
- hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
693
- testID="tm-preview-save-scope"
694
- >
695
- <Text
696
- style={[
697
- styles.selectLink,
698
- !!rightMeta.scope?.length && styles.selectLinkActive,
699
- ]}
700
- >
701
- save scope
702
- </Text>
703
- </TouchableOpacity>
704
- </>
705
- )}
706
- </View>
707
- )}
708
- </View>
709
- {preview.building ? (
710
- <ActivityIndicator size="small" color={buoyColors.primary} />
711
- ) : (
712
- <View style={styles.changesChip}>
713
- <Text style={styles.changesChipText} numberOfLines={1}>
714
- {totals.applies === 0
715
- ? totals.excludedCount > 0
716
- ? "everything excluded"
717
- : "no changes"
718
- : totals.perSource.join(" · ")}
719
- {totals.excludedCount > 0 && totals.applies > 0
720
- ? ` · ${totals.excludedCount} excluded`
721
- : ""}
722
- </Text>
723
- </View>
724
- )}
725
- </View>
726
- {totals.wontApply > 0 && (
727
- <Text style={styles.wontApplyLine}>
728
- ⊘ {totals.wontApply} item{totals.wontApply === 1 ? "" : "s"} won't
729
- apply — shown inline with the reason
730
- </Text>
731
- )}
732
-
733
- {/* Search + expand/collapse all */}
734
- <View style={styles.searchRow}>
735
- <View style={styles.searchField}>
736
- <Search size={14} color={buoyColors.textMuted} />
737
- <TextInput
738
- style={styles.searchInput}
739
- value={localSearch}
740
- onChangeText={setLocalSearch}
741
- placeholder="Filter keys…"
742
- placeholderTextColor={buoyColors.textMuted}
743
- autoCorrect={false}
744
- autoCapitalize="none"
745
- testID="tm-preview-search"
746
- />
747
- </View>
748
- <TouchableOpacity
749
- style={styles.collapseAllButton}
750
- onPress={toggleAllCollapsed}
751
- accessibilityLabel={allCollapsed ? "Expand all" : "Collapse all"}
752
- testID="tm-preview-collapse-all"
753
- >
754
- <Filter size={15} color={buoyColors.textSecondary} />
755
- </TouchableOpacity>
756
- </View>
767
+ <SectionLabel>Changes to Restore</SectionLabel>
757
768
  </View>
758
769
  );
759
770
 
@@ -767,7 +778,13 @@ export function RestorePreviewScreen({
767
778
  keyExtractor={keyExtractor}
768
779
  ListHeaderComponent={header}
769
780
  ListEmptyComponent={
770
- preview.building ? null : (
781
+ preview.building ? (
782
+ <ActivityIndicator
783
+ style={styles.buildingSpinner}
784
+ size="small"
785
+ color={tm.color.accent}
786
+ />
787
+ ) : (
771
788
  <Text style={styles.emptyText}>
772
789
  {search
773
790
  ? "Nothing matches this filter."
@@ -775,6 +792,23 @@ export function RestorePreviewScreen({
775
792
  </Text>
776
793
  )
777
794
  }
795
+ ListFooterComponent={
796
+ <View>
797
+ {totals.wontApply > 0 ? (
798
+ <View style={styles.warnNote}>
799
+ <AlertTriangle size={12} color={tm.color.warning} />
800
+ <Text style={styles.warnNoteText}>
801
+ {totals.wontApply} item{totals.wontApply === 1 ? "" : "s"}{" "}
802
+ won't apply — expand{" "}
803
+ {blockedSources.length > 0
804
+ ? blockedSources.join(" and ")
805
+ : "the source"}{" "}
806
+ to see the reason.
807
+ </Text>
808
+ </View>
809
+ ) : null}
810
+ </View>
811
+ }
778
812
  removeClippedSubviews
779
813
  initialNumToRender={20}
780
814
  windowSize={7}
@@ -783,7 +817,7 @@ export function RestorePreviewScreen({
783
817
 
784
818
  {/* Sticky Restore CTA */}
785
819
  {liveLeft && rightMeta && (
786
- <View style={styles.footer}>
820
+ <View style={[styles.footer, { paddingBottom: insets.bottom }]}>
787
821
  <TouchableOpacity
788
822
  style={[
789
823
  styles.cta,
@@ -796,8 +830,10 @@ export function RestorePreviewScreen({
796
830
  disabled={isBaseline ? busy || preview.building : ctaDisabled}
797
831
  testID="tm-preview-restore"
798
832
  >
799
- <RefreshCw size={16} color="#0A0A0A" />
800
- <Text style={styles.ctaText}>
833
+ <RefreshCw size={16} color={isBaseline ? tm.color.onDanger : tm.color.onAccent} />
834
+ <Text
835
+ style={[styles.ctaText, isBaseline ? styles.ctaTextDanger : null]}
836
+ >
801
837
  {isBaseline ? "Wipe & Reload" : "Restore State"}
802
838
  </Text>
803
839
  </TouchableOpacity>
@@ -817,161 +853,125 @@ export function RestorePreviewScreen({
817
853
  const HIT = { top: 10, bottom: 10, left: 10, right: 10 };
818
854
 
819
855
  const styles = StyleSheet.create({
820
- container: { flex: 1 },
856
+ container: { flex: 1, backgroundColor: tm.color.sheet },
821
857
  fallbackHeader: {
822
858
  flexDirection: "row",
823
859
  alignItems: "center",
824
- paddingVertical: 8,
860
+ paddingHorizontal: tm.gutter,
861
+ paddingVertical: 10,
825
862
  gap: 10,
826
863
  },
827
- backText: { color: buoyColors.primary, fontSize: 14 },
864
+ backText: { color: tm.color.accent, fontSize: tm.font.control },
828
865
  screenTitle: {
829
- color: buoyColors.text,
830
- fontSize: 13,
866
+ color: tm.color.label,
867
+ fontSize: 20,
831
868
  fontWeight: "700",
869
+ letterSpacing: tm.tracking.row,
832
870
  flex: 1,
833
871
  },
834
- toolbarButton: { padding: 4, justifyContent: "center", alignItems: "center" },
835
- dimmed: { opacity: 0.55 },
836
- listContent: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 12 },
872
+ dimmed: { opacity: 0.5 },
873
+ listContent: { paddingTop: 14, paddingBottom: 12 },
837
874
 
838
- // Compare card
839
- compareCard: {
840
- flexDirection: "row",
841
- alignItems: "center",
842
- backgroundColor: buoyColors.card,
843
- borderColor: buoyColors.border,
844
- borderWidth: 1,
845
- borderRadius: 14,
846
- padding: 14,
847
- gap: 10,
848
- },
849
- compareSide: { flex: 1, minWidth: 0, gap: 3 },
850
- compareSideRight: { alignItems: "flex-end" },
851
- compareTagRow: { flexDirection: "row", alignItems: "center", gap: 5 },
852
- stateDot: { width: 6, height: 6, borderRadius: 3 },
853
- compareTag: {
854
- color: buoyColors.textSecondary,
855
- fontSize: 10,
856
- fontWeight: "700",
857
- letterSpacing: 0.6,
858
- },
859
- compareName: { color: buoyColors.text, fontSize: 15, fontWeight: "700" },
860
- compareNameRight: { textAlign: "right" },
861
- compareMeta: { color: buoyColors.textMuted, fontSize: 10 },
862
- compareMetaRight: { textAlign: "right" },
863
- compareMetaRow: { flexDirection: "row", alignItems: "center", gap: 6 },
864
- swapBadge: {
865
- width: 34,
866
- height: 34,
867
- borderRadius: 17,
868
- backgroundColor: buoyColors.hover,
869
- borderColor: buoyColors.border,
870
- borderWidth: 1,
871
- alignItems: "center",
872
- justifyContent: "center",
875
+ // ── The grouped card each list row paints its own share of ──────────────
876
+ cardRow: {
877
+ marginHorizontal: tm.gutter,
878
+ backgroundColor: tm.color.group,
873
879
  },
874
- swapGlyph: { color: buoyColors.textSecondary, fontSize: 14 },
875
-
876
- // Changes row
877
- changesRow: {
878
- flexDirection: "row",
879
- alignItems: "center",
880
- justifyContent: "space-between",
881
- marginTop: 14,
882
- gap: 10,
880
+ cardRowFirst: {
881
+ borderTopLeftRadius: tm.radius.group,
882
+ borderTopRightRadius: tm.radius.group,
883
883
  },
884
- changesTitle: { color: buoyColors.text, fontSize: 14, fontWeight: "700" },
885
- changesTitleRow: {
886
- flexDirection: "row",
887
- alignItems: "baseline",
888
- gap: 8,
884
+ cardRowLast: {
885
+ borderBottomLeftRadius: tm.radius.group,
886
+ borderBottomRightRadius: tm.radius.group,
887
+ marginBottom: 10,
889
888
  },
890
- selectLinks: { flexDirection: "row", alignItems: "baseline", gap: 4 },
891
- selectLink: { color: buoyColors.textMuted, fontSize: 12 },
892
- selectLinkActive: { color: buoyColors.primary, fontWeight: "600" },
893
- selectLinkSep: { color: buoyColors.textMuted, fontSize: 12 },
894
- changesChip: {
895
- backgroundColor: buoyColors.card,
896
- borderColor: buoyColors.border,
897
- borderWidth: 1,
898
- borderRadius: 8,
899
- paddingHorizontal: 10,
900
- paddingVertical: 5,
901
- maxWidth: "60%",
889
+ cardSeparator: {
890
+ height: StyleSheet.hairlineWidth,
891
+ marginLeft: 51,
892
+ backgroundColor: tm.color.separator,
902
893
  },
903
- changesChipText: { color: buoyColors.textSecondary, fontSize: 11 },
904
- wontApplyLine: { color: buoyColors.warning, fontSize: 11, marginTop: 6 },
905
894
 
906
- // Search row
907
- searchRow: {
895
+ // ── Selection card ──────────────────────────────────────────────────────
896
+
897
+ // ── The "won't apply" heads-up under the card ───────────────────────────
898
+ warnNote: {
908
899
  flexDirection: "row",
909
- alignItems: "center",
910
- gap: 8,
911
- marginTop: 10,
912
- marginBottom: 6,
900
+ alignItems: "flex-start",
901
+ gap: 6,
902
+ paddingHorizontal: tm.gutter + tm.textInset,
903
+ paddingTop: 8,
913
904
  },
914
- searchField: {
905
+ warnNoteText: {
915
906
  flex: 1,
916
- flexDirection: "row",
917
- alignItems: "center",
918
- gap: 8,
919
- backgroundColor: buoyColors.card,
920
- borderColor: buoyColors.border,
921
- borderWidth: 1,
922
- borderRadius: 10,
923
- paddingHorizontal: 10,
907
+ color: tm.color.labelSecondary,
908
+ fontSize: tm.font.footnote,
909
+ lineHeight: tm.font.footnote * 1.35,
924
910
  },
925
- searchInput: {
926
- flex: 1,
927
- color: buoyColors.text,
928
- fontSize: 12,
929
- paddingVertical: 8,
911
+
912
+ // ── Count pills on a source row ─────────────────────────────────────────
913
+ countPill: {
914
+ borderRadius: tm.radius.pill,
915
+ paddingHorizontal: 9,
916
+ paddingVertical: 3,
930
917
  },
931
- collapseAllButton: {
932
- width: 36,
933
- height: 36,
934
- borderRadius: 10,
935
- backgroundColor: buoyColors.card,
936
- borderColor: buoyColors.border,
937
- borderWidth: 1,
938
- alignItems: "center",
939
- justifyContent: "center",
918
+ countPillText: {
919
+ fontFamily: TM_MONO,
920
+ fontSize: tm.font.footnote,
921
+ fontWeight: "600",
940
922
  },
941
923
 
942
- // Checkbox
924
+ // ── Include checkbox ────────────────────────────────────────────────────
943
925
  checkbox: {
944
- width: 18,
945
- height: 18,
946
- borderRadius: 4,
947
- borderWidth: 1.5,
948
- borderColor: buoyColors.textMuted,
926
+ width: 23,
927
+ height: 23,
928
+ borderRadius: tm.radius.pill,
929
+ borderWidth: 1.7,
930
+ borderColor: tm.color.labelTertiary,
949
931
  alignItems: "center",
950
932
  justifyContent: "center",
951
933
  },
952
934
  checkboxChecked: {
953
- borderColor: buoyColors.primary,
954
- backgroundColor: `${buoyColors.primary}26`,
935
+ borderColor: tm.color.accent,
936
+ backgroundColor: tm.color.accent,
955
937
  },
956
938
  checkboxGlyph: {
957
- color: buoyColors.primary,
958
- fontSize: 11,
939
+ color: tm.color.onAccent,
940
+ fontSize: 13,
959
941
  fontWeight: "700",
960
- lineHeight: 13,
942
+ lineHeight: 15,
943
+ },
944
+
945
+ // ── Route row ───────────────────────────────────────────────────────────
946
+ routeRow: {
947
+ flexDirection: "row",
948
+ alignItems: "center",
949
+ minHeight: 58,
950
+ paddingHorizontal: tm.textInset,
951
+ paddingVertical: 9,
952
+ gap: 12,
953
+ },
954
+ routeText: { flex: 1, minWidth: 0, gap: 2 },
955
+ routeTitle: {
956
+ color: tm.color.label,
957
+ fontSize: tm.font.row,
958
+ fontWeight: "600",
959
+ letterSpacing: tm.tracking.row,
960
+ },
961
+ routeHref: {
962
+ color: tm.color.labelSecondary,
963
+ fontFamily: TM_MONO,
964
+ fontSize: tm.font.mono,
961
965
  },
962
966
 
963
- // Source cards
967
+ // ── Source header row (first row of its card) ───────────────────────────
964
968
  sourceCard: {
965
969
  flexDirection: "row",
966
970
  alignItems: "center",
967
- backgroundColor: buoyColors.card,
968
- borderColor: buoyColors.border,
969
- borderWidth: 1,
970
- borderRadius: 12,
971
- paddingHorizontal: 12,
972
- paddingVertical: 10,
973
- marginTop: 8,
974
- gap: 10,
971
+ minHeight: 58,
972
+ paddingHorizontal: tm.textInset,
973
+ paddingVertical: 9,
974
+ gap: 12,
975
975
  },
976
976
  sourceCardTouch: {
977
977
  flex: 1,
@@ -980,110 +980,110 @@ const styles = StyleSheet.create({
980
980
  gap: 12,
981
981
  minWidth: 0,
982
982
  },
983
- sourceIconTile: {
984
- width: 36,
985
- height: 36,
986
- borderRadius: 10,
987
- backgroundColor: buoyColors.hover,
988
- alignItems: "center",
989
- justifyContent: "center",
990
- },
991
983
  sourceNameBlock: { flex: 1, minWidth: 0, gap: 2 },
992
984
  sourceName: {
993
- color: buoyColors.text,
994
- fontSize: 15,
995
- fontWeight: "700",
985
+ color: tm.color.label,
986
+ fontSize: tm.font.row,
987
+ fontWeight: "600",
988
+ letterSpacing: tm.tracking.row,
996
989
  },
997
990
  sourceSummary: {
998
- color: buoyColors.textMuted,
999
- fontSize: 11,
1000
- fontVariant: ["tabular-nums"],
1001
- },
1002
- sourceBadges: { flexDirection: "row", gap: 8 },
1003
- sourceBadge: {
1004
- fontSize: 13,
1005
- fontWeight: "700",
991
+ color: tm.color.labelSecondary,
992
+ fontSize: tm.font.footnote,
1006
993
  fontVariant: ["tabular-nums"],
1007
994
  },
995
+ sourceBadges: { flexDirection: "row", gap: 6 },
1008
996
 
1009
997
  noteRow: {
1010
- color: buoyColors.textMuted,
1011
- fontSize: 10,
1012
- paddingLeft: 24,
1013
- paddingTop: 6,
998
+ color: tm.color.labelTertiary,
999
+ fontSize: tm.font.footnote,
1000
+ lineHeight: tm.font.footnote * 1.35,
1001
+ paddingHorizontal: tm.textInset,
1002
+ paddingVertical: 9,
1014
1003
  },
1015
1004
 
1016
- // Item rows (nested under a source card)
1005
+ // ── Item rows (further rows of the same card) ───────────────────────────
1017
1006
  itemRow: {
1018
1007
  flexDirection: "row",
1019
1008
  alignItems: "center",
1020
- backgroundColor: buoyColors.hover,
1021
- borderColor: buoyColors.border,
1022
- borderWidth: 1,
1023
- borderRadius: 10,
1024
- paddingHorizontal: 10,
1025
- paddingVertical: 9,
1026
- marginTop: 6,
1027
- marginLeft: 14,
1028
- gap: 10,
1009
+ paddingLeft: tm.textInset,
1010
+ paddingRight: tm.textInset,
1011
+ paddingVertical: 10,
1012
+ gap: 12,
1029
1013
  },
1030
1014
  itemBodyTouch: {
1031
1015
  flex: 1,
1032
1016
  flexDirection: "row",
1033
1017
  alignItems: "center",
1034
- gap: 8,
1018
+ gap: 10,
1035
1019
  minWidth: 0,
1036
1020
  },
1037
- itemGlyph: { fontSize: 13, fontWeight: "700", width: 14, textAlign: "center" },
1021
+ itemGlyph: { fontSize: 14, fontWeight: "700", width: 14, textAlign: "center" },
1038
1022
  itemBody: { flex: 1, minWidth: 0 },
1039
- itemLabel: { color: buoyColors.text, fontSize: 12 },
1023
+ itemLabel: {
1024
+ color: tm.color.label,
1025
+ fontSize: tm.font.control,
1026
+ letterSpacing: tm.tracking.control,
1027
+ },
1040
1028
  itemPreview: {
1041
- color: buoyColors.textMuted,
1042
- fontSize: 10,
1043
- marginTop: 2,
1044
- fontVariant: ["tabular-nums"],
1029
+ color: tm.color.labelSecondary,
1030
+ fontFamily: TM_MONO,
1031
+ fontSize: tm.font.mono,
1032
+ marginTop: 3,
1045
1033
  },
1046
1034
  itemPreviewRemoved: { textDecorationLine: "line-through" },
1047
- itemReason: { color: buoyColors.warning, fontSize: 10, marginTop: 2 },
1035
+ itemReason: {
1036
+ color: tm.color.warning,
1037
+ fontSize: tm.font.mono,
1038
+ marginTop: 3,
1039
+ },
1048
1040
  itemExcluded: {
1049
- color: buoyColors.textMuted,
1050
- fontSize: 10,
1051
- marginTop: 2,
1041
+ color: tm.color.labelTertiary,
1042
+ fontSize: tm.font.mono,
1043
+ marginTop: 3,
1052
1044
  fontStyle: "italic",
1053
1045
  },
1054
- itemChevron: { color: buoyColors.textMuted, fontSize: 16 },
1046
+ buildingSpinner: {
1047
+ marginTop: 28,
1048
+ },
1055
1049
  emptyText: {
1056
- color: buoyColors.textMuted,
1057
- fontSize: 12,
1050
+ color: tm.color.labelSecondary,
1051
+ fontSize: tm.font.footnote,
1058
1052
  textAlign: "center",
1059
- marginTop: 24,
1053
+ marginTop: 28,
1060
1054
  },
1061
1055
 
1062
- // Footer CTA
1056
+ // ── Sticky restore CTA ──────────────────────────────────────────────────
1063
1057
  footer: {
1064
- paddingHorizontal: 12,
1065
- paddingTop: 8,
1066
- paddingBottom: 12,
1067
- gap: 6,
1068
- borderTopColor: buoyColors.border,
1069
- borderTopWidth: 1,
1070
- backgroundColor: buoyColors.base,
1058
+ paddingHorizontal: tm.gutter,
1059
+ paddingTop: 10,
1060
+ gap: 8,
1061
+ borderTopColor: tm.color.separator,
1062
+ borderTopWidth: StyleSheet.hairlineWidth,
1063
+ backgroundColor: tm.color.sheet,
1071
1064
  },
1072
1065
  cta: {
1073
1066
  flexDirection: "row",
1074
1067
  alignItems: "center",
1075
1068
  justifyContent: "center",
1076
1069
  gap: 8,
1077
- backgroundColor: buoyColors.primary,
1078
- borderRadius: 24,
1079
- paddingVertical: 13,
1070
+ height: 50,
1071
+ backgroundColor: tm.color.accent,
1072
+ borderRadius: tm.radius.group,
1080
1073
  },
1081
- ctaDanger: { backgroundColor: buoyColors.error },
1074
+ ctaDanger: { backgroundColor: tm.color.danger },
1075
+ ctaTextDanger: { color: tm.color.onDanger },
1082
1076
  ctaDisabled: { opacity: 0.4 },
1083
- ctaText: { color: "#0A0A0A", fontSize: 14, fontWeight: "700" },
1077
+ ctaText: {
1078
+ color: tm.color.onAccent,
1079
+ fontSize: tm.font.row,
1080
+ fontWeight: "600",
1081
+ letterSpacing: tm.tracking.row,
1082
+ },
1084
1083
  ctaCaption: {
1085
- color: buoyColors.textMuted,
1086
- fontSize: 10,
1084
+ color: tm.color.labelSecondary,
1085
+ fontSize: tm.font.footnote,
1086
+ lineHeight: tm.font.footnote * 1.35,
1087
1087
  textAlign: "center",
1088
1088
  },
1089
1089
  });