@buoy-gg/time-machine 7.0.17 → 7.0.19
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.
- package/lib/commonjs/components/HeaderReloadButton.js +61 -0
- package/lib/commonjs/components/HeaderReloadButton.js.map +1 -0
- package/lib/commonjs/components/TimeMachineBody.js +343 -127
- package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
- package/lib/commonjs/components/TimeMachineModal.js +1 -0
- package/lib/commonjs/components/TimeMachineModal.js.map +1 -1
- package/lib/commonjs/components/preview/ItemDetailScreen.js +189 -0
- package/lib/commonjs/components/preview/ItemDetailScreen.js.map +1 -0
- package/lib/commonjs/components/preview/RestorePreviewScreen.js +1056 -0
- package/lib/commonjs/components/preview/RestorePreviewScreen.js.map +1 -0
- package/lib/commonjs/components/preview/verdictMeta.js +58 -0
- package/lib/commonjs/components/preview/verdictMeta.js.map +1 -0
- package/lib/commonjs/preset.js +11 -2
- package/lib/commonjs/preset.js.map +1 -1
- package/lib/commonjs/store/snapshotVault.js +19 -0
- package/lib/commonjs/store/snapshotVault.js.map +1 -1
- package/lib/commonjs/store/timeMachineStore.js +298 -7
- package/lib/commonjs/store/timeMachineStore.js.map +1 -1
- package/lib/commonjs/sync/timeMachineSyncAdapter.js +62 -2
- package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/module/components/HeaderReloadButton.js +56 -0
- package/lib/module/components/HeaderReloadButton.js.map +1 -0
- package/lib/module/components/TimeMachineBody.js +345 -129
- package/lib/module/components/TimeMachineBody.js.map +1 -1
- package/lib/module/components/TimeMachineModal.js +1 -0
- package/lib/module/components/TimeMachineModal.js.map +1 -1
- package/lib/module/components/preview/ItemDetailScreen.js +184 -0
- package/lib/module/components/preview/ItemDetailScreen.js.map +1 -0
- package/lib/module/components/preview/RestorePreviewScreen.js +1051 -0
- package/lib/module/components/preview/RestorePreviewScreen.js.map +1 -0
- package/lib/module/components/preview/verdictMeta.js +53 -0
- package/lib/module/components/preview/verdictMeta.js.map +1 -0
- package/lib/module/preset.js +11 -2
- package/lib/module/preset.js.map +1 -1
- package/lib/module/store/snapshotVault.js +18 -0
- package/lib/module/store/snapshotVault.js.map +1 -1
- package/lib/module/store/timeMachineStore.js +299 -8
- package/lib/module/store/timeMachineStore.js.map +1 -1
- package/lib/module/sync/timeMachineSyncAdapter.js +62 -2
- package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
- package/lib/typescript/components/HeaderReloadButton.d.ts +2 -0
- package/lib/typescript/components/HeaderReloadButton.d.ts.map +1 -0
- package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
- package/lib/typescript/components/TimeMachineModal.d.ts.map +1 -1
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts +11 -0
- package/lib/typescript/components/preview/ItemDetailScreen.d.ts.map +1 -0
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts +15 -0
- package/lib/typescript/components/preview/RestorePreviewScreen.d.ts.map +1 -0
- package/lib/typescript/components/preview/verdictMeta.d.ts +10 -0
- package/lib/typescript/components/preview/verdictMeta.d.ts.map +1 -0
- package/lib/typescript/preset.d.ts +15 -3
- package/lib/typescript/preset.d.ts.map +1 -1
- package/lib/typescript/store/snapshotVault.d.ts +7 -0
- package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
- package/lib/typescript/store/timeMachineStore.d.ts +49 -2
- package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +36 -0
- package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
- package/package.json +19 -7
- package/src/components/HeaderReloadButton.tsx +45 -0
- package/src/components/TimeMachineBody.tsx +416 -137
- package/src/components/TimeMachineModal.tsx +1 -0
- package/src/components/preview/ItemDetailScreen.tsx +172 -0
- package/src/components/preview/RestorePreviewScreen.tsx +1089 -0
- package/src/components/preview/verdictMeta.ts +40 -0
- package/src/preset.tsx +11 -2
- package/src/store/snapshotVault.ts +28 -0
- package/src/store/timeMachineStore.ts +330 -7
- package/src/sync/timeMachineSyncAdapter.ts +63 -3
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore Preview — "what will restoring this snapshot actually change?"
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Layout follows the Time Machine mockup: source sections are card
|
|
9
|
+
* dropdowns with tool-icon tiles, a "N keys · M changed" detection subtitle,
|
|
10
|
+
* ±≈ badges, a search field with an expand/collapse-all button, and a bottom
|
|
11
|
+
* "Restore State" pill. Only rows a restore would touch (or can't touch)
|
|
12
|
+
* render — unchanged items never do. Every applicable item keeps its
|
|
13
|
+
* INCLUDE checkbox (source headers toggle their whole section) — unchecked
|
|
14
|
+
* items are passed to restore as exclusions and left untouched.
|
|
15
|
+
*/
|
|
16
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
import {
|
|
18
|
+
ActivityIndicator,
|
|
19
|
+
FlatList,
|
|
20
|
+
StyleSheet,
|
|
21
|
+
Text,
|
|
22
|
+
TextInput,
|
|
23
|
+
TouchableOpacity,
|
|
24
|
+
View,
|
|
25
|
+
type ListRenderItem,
|
|
26
|
+
} from "react-native";
|
|
27
|
+
import {
|
|
28
|
+
ChevronDown,
|
|
29
|
+
ChevronUp,
|
|
30
|
+
Filter,
|
|
31
|
+
RefreshCw,
|
|
32
|
+
Search,
|
|
33
|
+
buoyColors,
|
|
34
|
+
macOSColors,
|
|
35
|
+
} 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
|
+
import type { StateSnapshotPreviewItem } from "@buoy-gg/shared-ui";
|
|
45
|
+
import { useToolHeader, useToolHost } from "@buoy-gg/core";
|
|
46
|
+
import type {
|
|
47
|
+
PreviewSideRef,
|
|
48
|
+
PreviewSource,
|
|
49
|
+
PreviewState,
|
|
50
|
+
} from "../../store/timeMachineStore";
|
|
51
|
+
import { timeMachineStore } from "../../store/timeMachineStore";
|
|
52
|
+
import type { SnapshotMeta } from "../../store/snapshotVault";
|
|
53
|
+
import { VERDICT_META } from "./verdictMeta";
|
|
54
|
+
|
|
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
|
+
const SOURCE_NOUNS: Record<string, string> = {
|
|
67
|
+
storage: "keys",
|
|
68
|
+
redux: "slices",
|
|
69
|
+
zustand: "stores",
|
|
70
|
+
jotai: "atoms",
|
|
71
|
+
query: "queries",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
interface RowSourceHeader {
|
|
75
|
+
kind: "source";
|
|
76
|
+
source: PreviewSource;
|
|
77
|
+
collapsed: boolean;
|
|
78
|
+
includeState: "all" | "some" | "none";
|
|
79
|
+
applicableCount: number;
|
|
80
|
+
counts: { added: number; removed: number; changed: number };
|
|
81
|
+
/** False when the card has nothing to expand (a clean "No changes" source). */
|
|
82
|
+
expandable: boolean;
|
|
83
|
+
/** "60 keys · 7 changed" — detection proof without expanding. */
|
|
84
|
+
summary: string;
|
|
85
|
+
}
|
|
86
|
+
interface RowItem {
|
|
87
|
+
kind: "item";
|
|
88
|
+
sourceId: string;
|
|
89
|
+
item: StateSnapshotPreviewItem;
|
|
90
|
+
excluded: boolean;
|
|
91
|
+
}
|
|
92
|
+
interface RowNote {
|
|
93
|
+
kind: "note";
|
|
94
|
+
id: string;
|
|
95
|
+
text: string;
|
|
96
|
+
}
|
|
97
|
+
type Row = RowSourceHeader | RowItem | RowNote;
|
|
98
|
+
|
|
99
|
+
/** Compound exclusion key — what timeMachineStore.restore expects. */
|
|
100
|
+
function cKey(sourceId: string, itemKey: string): string {
|
|
101
|
+
return `${sourceId}::${itemKey}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shortPreview(value: unknown): string {
|
|
105
|
+
if (value === undefined) return "∅";
|
|
106
|
+
try {
|
|
107
|
+
const s = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
|
|
108
|
+
if (!s) return String(value);
|
|
109
|
+
return s.length > 46 ? `${s.slice(0, 43)}…` : s;
|
|
110
|
+
} catch {
|
|
111
|
+
return "[complex]";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sideName(ref: PreviewSideRef, snapshots: SnapshotMeta[]): string {
|
|
116
|
+
if (ref === "current") return "Current state";
|
|
117
|
+
return (
|
|
118
|
+
snapshots.find((s) => s.id === ref.snapshotId)?.name ?? ref.snapshotId
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
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`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Checkbox({ state }: { state: "all" | "some" | "none" }) {
|
|
132
|
+
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>}
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ItemRow = memo(function ItemRow({
|
|
143
|
+
row,
|
|
144
|
+
onOpen,
|
|
145
|
+
onToggle,
|
|
146
|
+
}: {
|
|
147
|
+
row: RowItem;
|
|
148
|
+
/** Zero-arg by design — press plumbing may invoke handlers with a
|
|
149
|
+
* synthetic event; the row is bound at the call site. */
|
|
150
|
+
onOpen: () => void;
|
|
151
|
+
onToggle: () => void;
|
|
152
|
+
}) {
|
|
153
|
+
const meta = VERDICT_META[row.item.verdict];
|
|
154
|
+
const dimmed = !meta.applies || row.excluded;
|
|
155
|
+
const openable =
|
|
156
|
+
row.item.verdict === "changed" ||
|
|
157
|
+
row.item.verdict === "added" ||
|
|
158
|
+
row.item.verdict === "removed";
|
|
159
|
+
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
|
+
)}
|
|
170
|
+
<TouchableOpacity
|
|
171
|
+
style={styles.itemBodyTouch}
|
|
172
|
+
onPress={() => openable && onOpen()}
|
|
173
|
+
disabled={!openable}
|
|
174
|
+
testID={`tm-preview-item-${row.sourceId}-${row.item.key}`}
|
|
175
|
+
>
|
|
176
|
+
<Text style={[styles.itemGlyph, { color: meta.color }]}>
|
|
177
|
+
{meta.glyph}
|
|
178
|
+
</Text>
|
|
179
|
+
<View style={styles.itemBody}>
|
|
180
|
+
<Text style={styles.itemLabel} numberOfLines={1}>
|
|
181
|
+
{row.item.label}
|
|
182
|
+
</Text>
|
|
183
|
+
{row.item.verdict === "changed" && (
|
|
184
|
+
<Text style={styles.itemPreview} numberOfLines={1}>
|
|
185
|
+
{shortPreview(row.item.oldValue)} →{" "}
|
|
186
|
+
{shortPreview(row.item.newValue)}
|
|
187
|
+
</Text>
|
|
188
|
+
)}
|
|
189
|
+
{row.item.verdict === "added" && (
|
|
190
|
+
<Text style={styles.itemPreview} numberOfLines={1}>
|
|
191
|
+
{shortPreview(row.item.newValue)}
|
|
192
|
+
</Text>
|
|
193
|
+
)}
|
|
194
|
+
{row.item.verdict === "removed" && (
|
|
195
|
+
<Text
|
|
196
|
+
style={[styles.itemPreview, styles.itemPreviewRemoved]}
|
|
197
|
+
numberOfLines={1}
|
|
198
|
+
>
|
|
199
|
+
{shortPreview(row.item.oldValue)}
|
|
200
|
+
</Text>
|
|
201
|
+
)}
|
|
202
|
+
{row.excluded ? (
|
|
203
|
+
<Text style={styles.itemExcluded}>
|
|
204
|
+
excluded — restore leaves this untouched
|
|
205
|
+
</Text>
|
|
206
|
+
) : (
|
|
207
|
+
row.item.reason && (
|
|
208
|
+
<Text style={styles.itemReason} numberOfLines={2}>
|
|
209
|
+
{row.item.reason}
|
|
210
|
+
</Text>
|
|
211
|
+
)
|
|
212
|
+
)}
|
|
213
|
+
</View>
|
|
214
|
+
{openable && <Text style={styles.itemChevron}>›</Text>}
|
|
215
|
+
</TouchableOpacity>
|
|
216
|
+
</View>
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
export interface RestorePreviewScreenProps {
|
|
221
|
+
preview: PreviewState;
|
|
222
|
+
snapshots: SnapshotMeta[];
|
|
223
|
+
busy: boolean;
|
|
224
|
+
onBack: () => void;
|
|
225
|
+
onOpenItem: (sourceId: string, item: StateSnapshotPreviewItem) => void;
|
|
226
|
+
onRestore: (mode: "live" | "reload", excludeKeys: string[]) => void;
|
|
227
|
+
onPickSide: (position: "left" | "right") => void;
|
|
228
|
+
onRefresh: () => void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function RestorePreviewScreen({
|
|
232
|
+
preview,
|
|
233
|
+
snapshots,
|
|
234
|
+
busy,
|
|
235
|
+
onBack,
|
|
236
|
+
onOpenItem,
|
|
237
|
+
onRestore,
|
|
238
|
+
onPickSide,
|
|
239
|
+
onRefresh,
|
|
240
|
+
}: RestorePreviewScreenProps) {
|
|
241
|
+
const host = useToolHost();
|
|
242
|
+
const hosted = host.instanceId !== undefined;
|
|
243
|
+
const [localSearch, setLocalSearch] = useState("");
|
|
244
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
245
|
+
const [allCollapsed, setAllCollapsed] = useState(false);
|
|
246
|
+
const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
|
|
247
|
+
const search = localSearch;
|
|
248
|
+
|
|
249
|
+
const rightMeta =
|
|
250
|
+
preview.right === "current"
|
|
251
|
+
? undefined
|
|
252
|
+
: snapshots.find(
|
|
253
|
+
(s) => s.id === (preview.right as { snapshotId: string }).snapshotId
|
|
254
|
+
);
|
|
255
|
+
const isBaseline = rightMeta?.baseline === true;
|
|
256
|
+
const liveLeft = preview.left === "current";
|
|
257
|
+
|
|
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]);
|
|
270
|
+
|
|
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;
|
|
279
|
+
const scopeSet = new Set(rightMeta.scope);
|
|
280
|
+
const next = new Set<string>();
|
|
281
|
+
for (const src of preview.sources) {
|
|
282
|
+
for (const item of src.items) {
|
|
283
|
+
const key = cKey(src.id, item.key);
|
|
284
|
+
if (VERDICT_META[item.verdict].applies && !scopeSet.has(key)) {
|
|
285
|
+
next.add(key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
setExcluded(next);
|
|
290
|
+
}, [rightMeta, preview.building, preview.sources]);
|
|
291
|
+
|
|
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
|
+
}
|
|
305
|
+
}
|
|
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]);
|
|
315
|
+
|
|
316
|
+
const toggleItem = useCallback((key: string) => {
|
|
317
|
+
setExcluded((prev) => {
|
|
318
|
+
const next = new Set(prev);
|
|
319
|
+
if (next.has(key)) next.delete(key);
|
|
320
|
+
else next.add(key);
|
|
321
|
+
return next;
|
|
322
|
+
});
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const toggleSource = useCallback(
|
|
326
|
+
(source: PreviewSource, includeState: "all" | "some" | "none") => {
|
|
327
|
+
setExcluded((prev) => {
|
|
328
|
+
const next = new Set(prev);
|
|
329
|
+
for (const item of source.items) {
|
|
330
|
+
if (!VERDICT_META[item.verdict].applies) continue;
|
|
331
|
+
const key = cKey(source.id, item.key);
|
|
332
|
+
if (includeState === "all") next.add(key);
|
|
333
|
+
else next.delete(key);
|
|
334
|
+
}
|
|
335
|
+
return next;
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
[]
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Takes the EFFECTIVE collapsed state — the map is sparse (defaults come
|
|
342
|
+
// from the ?? fallback), so `!map[id]` would no-op the first tap on a
|
|
343
|
+
// default-collapsed card.
|
|
344
|
+
const toggleCollapse = useCallback((id: string, currentlyCollapsed: boolean) => {
|
|
345
|
+
setCollapsed((c) => ({ ...c, [id]: !currentlyCollapsed }));
|
|
346
|
+
}, []);
|
|
347
|
+
|
|
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
|
+
const totals = useMemo(() => {
|
|
359
|
+
let applies = 0;
|
|
360
|
+
let excludedCount = 0;
|
|
361
|
+
let wontApply = 0;
|
|
362
|
+
const perSource: string[] = [];
|
|
363
|
+
for (const src of preview.sources) {
|
|
364
|
+
let n = 0;
|
|
365
|
+
for (const item of src.items) {
|
|
366
|
+
const m = VERDICT_META[item.verdict];
|
|
367
|
+
if (m.applies) {
|
|
368
|
+
if (excluded.has(cKey(src.id, item.key))) excludedCount += 1;
|
|
369
|
+
else {
|
|
370
|
+
applies += 1;
|
|
371
|
+
n += 1;
|
|
372
|
+
}
|
|
373
|
+
} else if (item.verdict === "wont-apply") wontApply += 1;
|
|
374
|
+
}
|
|
375
|
+
if (n > 0) perSource.push(`${n} ${src.label.toLowerCase()}`);
|
|
376
|
+
}
|
|
377
|
+
return { applies, excludedCount, wontApply, perSource };
|
|
378
|
+
}, [preview.sources, excluded]);
|
|
379
|
+
|
|
380
|
+
// Hosted: back + identity in the shared modal header. Restore lives in the
|
|
381
|
+
// bottom CTA; the preset's labeled RELOAD APP action stays available.
|
|
382
|
+
useToolHeader(
|
|
383
|
+
hosted
|
|
384
|
+
? {
|
|
385
|
+
title: "Restore Preview",
|
|
386
|
+
subtitle: rightMeta?.name ?? "compare",
|
|
387
|
+
onBack,
|
|
388
|
+
searchable: false, // the in-body "Filter keys…" field covers it
|
|
389
|
+
}
|
|
390
|
+
: null,
|
|
391
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
392
|
+
[hosted, rightMeta?.name]
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const rows = useMemo<Row[]>(() => {
|
|
396
|
+
const q = search.trim().toLowerCase();
|
|
397
|
+
const out: Row[] = [];
|
|
398
|
+
for (const src of preview.sources) {
|
|
399
|
+
// Every detected source gets a card whose subtitle carries the
|
|
400
|
+
// detection proof ("60 keys · 7 changed" / "2 slices · no changes"),
|
|
401
|
+
// so clean sources need no expansion. Unchanged ITEMS never render.
|
|
402
|
+
const relevant = src.items.some((i) => i.verdict !== "unchanged");
|
|
403
|
+
const expandable = relevant || src.notes.length > 0 || !!src.error;
|
|
404
|
+
|
|
405
|
+
const applicable = src.items.filter(
|
|
406
|
+
(i) => VERDICT_META[i.verdict].applies
|
|
407
|
+
);
|
|
408
|
+
const wontApplyCount = src.items.filter(
|
|
409
|
+
(i) => i.verdict === "wont-apply"
|
|
410
|
+
).length;
|
|
411
|
+
const noun =
|
|
412
|
+
src.items.length === 1
|
|
413
|
+
? (SOURCE_NOUNS[src.id] ?? "items").replace(/ies$/, "y").replace(/s$/, "")
|
|
414
|
+
: SOURCE_NOUNS[src.id] ?? "items";
|
|
415
|
+
const summary =
|
|
416
|
+
`${src.items.length} ${noun} · ` +
|
|
417
|
+
(applicable.length > 0
|
|
418
|
+
? `${applicable.length} changed`
|
|
419
|
+
: wontApplyCount > 0
|
|
420
|
+
? `${wontApplyCount} won't apply`
|
|
421
|
+
: "no changes");
|
|
422
|
+
const excludedInSource = applicable.filter((i) =>
|
|
423
|
+
excluded.has(cKey(src.id, i.key))
|
|
424
|
+
).length;
|
|
425
|
+
const includeState: RowSourceHeader["includeState"] =
|
|
426
|
+
applicable.length === 0 || excludedInSource === 0
|
|
427
|
+
? "all"
|
|
428
|
+
: excludedInSource === applicable.length
|
|
429
|
+
? "none"
|
|
430
|
+
: "some";
|
|
431
|
+
const counts = { added: 0, removed: 0, changed: 0 };
|
|
432
|
+
for (const i of src.items) {
|
|
433
|
+
if (excluded.has(cKey(src.id, i.key))) continue;
|
|
434
|
+
if (i.verdict === "added") counts.added += 1;
|
|
435
|
+
else if (i.verdict === "removed") counts.removed += 1;
|
|
436
|
+
else if (i.verdict === "changed" || i.verdict === "too-large")
|
|
437
|
+
counts.changed += 1;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const visible = src.items.filter((item) => {
|
|
441
|
+
// Unchanged items never render — the dropdowns show only what a
|
|
442
|
+
// restore would touch (changed) or can't touch (won't-apply).
|
|
443
|
+
if (item.verdict === "unchanged") return false;
|
|
444
|
+
if (q && !item.label.toLowerCase().includes(q)) return false;
|
|
445
|
+
return true;
|
|
446
|
+
});
|
|
447
|
+
const isCollapsed = !expandable
|
|
448
|
+
? true
|
|
449
|
+
: (collapsed[src.id] ?? (applicable.length === 0 && !src.error));
|
|
450
|
+
out.push({
|
|
451
|
+
kind: "source",
|
|
452
|
+
source: src,
|
|
453
|
+
collapsed: isCollapsed,
|
|
454
|
+
includeState,
|
|
455
|
+
applicableCount: applicable.length,
|
|
456
|
+
counts,
|
|
457
|
+
expandable,
|
|
458
|
+
summary,
|
|
459
|
+
});
|
|
460
|
+
if (!isCollapsed) {
|
|
461
|
+
for (const note of src.notes) {
|
|
462
|
+
out.push({ kind: "note", id: `${src.id}:${note}`, text: note });
|
|
463
|
+
}
|
|
464
|
+
if (src.error) {
|
|
465
|
+
out.push({ kind: "note", id: `${src.id}:err`, text: `⚠ ${src.error}` });
|
|
466
|
+
}
|
|
467
|
+
for (const item of visible) {
|
|
468
|
+
out.push({
|
|
469
|
+
kind: "item",
|
|
470
|
+
sourceId: src.id,
|
|
471
|
+
item,
|
|
472
|
+
excluded: excluded.has(cKey(src.id, item.key)),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}, [preview.sources, search, collapsed, excluded]);
|
|
479
|
+
|
|
480
|
+
const renderRow: ListRenderItem<Row> = useCallback(
|
|
481
|
+
({ item: row }) => {
|
|
482
|
+
if (row.kind === "source") {
|
|
483
|
+
const src = row.source;
|
|
484
|
+
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
|
+
return (
|
|
489
|
+
<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
|
+
)}
|
|
499
|
+
<TouchableOpacity
|
|
500
|
+
style={styles.sourceCardTouch}
|
|
501
|
+
onPress={() => row.expandable && toggleCollapse(src.id, row.collapsed)}
|
|
502
|
+
disabled={!row.expandable}
|
|
503
|
+
testID={`tm-preview-source-${src.id}`}
|
|
504
|
+
>
|
|
505
|
+
<View style={styles.sourceIconTile}>
|
|
506
|
+
<IconComp size={18} />
|
|
507
|
+
</View>
|
|
508
|
+
<View style={styles.sourceNameBlock}>
|
|
509
|
+
<Text style={styles.sourceName} numberOfLines={1}>
|
|
510
|
+
{src.label}
|
|
511
|
+
</Text>
|
|
512
|
+
<Text style={styles.sourceSummary} numberOfLines={1}>
|
|
513
|
+
{row.includeState === "none" && row.applicableCount > 0
|
|
514
|
+
? `${row.summary} · excluded`
|
|
515
|
+
: row.summary}
|
|
516
|
+
</Text>
|
|
517
|
+
</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
|
+
)}
|
|
537
|
+
{row.expandable ? (
|
|
538
|
+
row.collapsed ? (
|
|
539
|
+
<ChevronDown size={16} color={buoyColors.textMuted} />
|
|
540
|
+
) : (
|
|
541
|
+
<ChevronUp size={16} color={buoyColors.textMuted} />
|
|
542
|
+
)
|
|
543
|
+
) : null}
|
|
544
|
+
</TouchableOpacity>
|
|
545
|
+
</View>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (row.kind === "note") {
|
|
549
|
+
return <Text style={styles.noteRow}>{row.text}</Text>;
|
|
550
|
+
}
|
|
551
|
+
return (
|
|
552
|
+
<ItemRow
|
|
553
|
+
row={row}
|
|
554
|
+
onOpen={() => onOpenItem(row.sourceId, row.item)}
|
|
555
|
+
onToggle={() => toggleItem(cKey(row.sourceId, row.item.key))}
|
|
556
|
+
/>
|
|
557
|
+
);
|
|
558
|
+
},
|
|
559
|
+
[onOpenItem, toggleCollapse, toggleItem, toggleSource]
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const keyExtractor = useCallback((row: Row) => {
|
|
563
|
+
if (row.kind === "source") return `s:${row.source.id}`;
|
|
564
|
+
if (row.kind === "note") return `n:${row.id}`;
|
|
565
|
+
return `i:${row.sourceId}:${row.item.key}`;
|
|
566
|
+
}, []);
|
|
567
|
+
|
|
568
|
+
const header = (
|
|
569
|
+
<View>
|
|
570
|
+
{!hosted && (
|
|
571
|
+
<View style={styles.fallbackHeader}>
|
|
572
|
+
<TouchableOpacity onPress={onBack} hitSlop={HIT} testID="tm-preview-back">
|
|
573
|
+
<Text style={styles.backText}>‹ Back</Text>
|
|
574
|
+
</TouchableOpacity>
|
|
575
|
+
<Text style={styles.screenTitle} numberOfLines={1}>
|
|
576
|
+
Restore Preview
|
|
577
|
+
</Text>
|
|
578
|
+
</View>
|
|
579
|
+
)}
|
|
580
|
+
|
|
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 }]}
|
|
639
|
+
/>
|
|
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>
|
|
649
|
+
|
|
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>
|
|
757
|
+
</View>
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const ctaDisabled = busy || preview.building || totals.applies === 0;
|
|
761
|
+
|
|
762
|
+
return (
|
|
763
|
+
<View style={styles.container}>
|
|
764
|
+
<FlatList
|
|
765
|
+
data={rows}
|
|
766
|
+
renderItem={renderRow}
|
|
767
|
+
keyExtractor={keyExtractor}
|
|
768
|
+
ListHeaderComponent={header}
|
|
769
|
+
ListEmptyComponent={
|
|
770
|
+
preview.building ? null : (
|
|
771
|
+
<Text style={styles.emptyText}>
|
|
772
|
+
{search
|
|
773
|
+
? "Nothing matches this filter."
|
|
774
|
+
: "No changes — restoring would change nothing."}
|
|
775
|
+
</Text>
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
removeClippedSubviews
|
|
779
|
+
initialNumToRender={20}
|
|
780
|
+
windowSize={7}
|
|
781
|
+
contentContainerStyle={styles.listContent}
|
|
782
|
+
/>
|
|
783
|
+
|
|
784
|
+
{/* Sticky Restore CTA */}
|
|
785
|
+
{liveLeft && rightMeta && (
|
|
786
|
+
<View style={styles.footer}>
|
|
787
|
+
<TouchableOpacity
|
|
788
|
+
style={[
|
|
789
|
+
styles.cta,
|
|
790
|
+
isBaseline && styles.ctaDanger,
|
|
791
|
+
ctaDisabled && styles.ctaDisabled,
|
|
792
|
+
]}
|
|
793
|
+
onPress={() =>
|
|
794
|
+
onRestore(isBaseline ? "reload" : "live", Array.from(excluded))
|
|
795
|
+
}
|
|
796
|
+
disabled={isBaseline ? busy || preview.building : ctaDisabled}
|
|
797
|
+
testID="tm-preview-restore"
|
|
798
|
+
>
|
|
799
|
+
<RefreshCw size={16} color="#0A0A0A" />
|
|
800
|
+
<Text style={styles.ctaText}>
|
|
801
|
+
{isBaseline ? "Wipe & Reload" : "Restore State"}
|
|
802
|
+
</Text>
|
|
803
|
+
</TouchableOpacity>
|
|
804
|
+
<Text style={styles.ctaCaption}>
|
|
805
|
+
{isBaseline
|
|
806
|
+
? "This wipes all app storage and reloads the app."
|
|
807
|
+
: totals.excludedCount > 0
|
|
808
|
+
? `Restores ${totals.applies} of ${totals.applies + totals.excludedCount} change${totals.applies + totals.excludedCount === 1 ? "" : "s"} — everything else stays as it is.`
|
|
809
|
+
: "This will replace the current state with the snapshot."}
|
|
810
|
+
</Text>
|
|
811
|
+
</View>
|
|
812
|
+
)}
|
|
813
|
+
</View>
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const HIT = { top: 10, bottom: 10, left: 10, right: 10 };
|
|
818
|
+
|
|
819
|
+
const styles = StyleSheet.create({
|
|
820
|
+
container: { flex: 1 },
|
|
821
|
+
fallbackHeader: {
|
|
822
|
+
flexDirection: "row",
|
|
823
|
+
alignItems: "center",
|
|
824
|
+
paddingVertical: 8,
|
|
825
|
+
gap: 10,
|
|
826
|
+
},
|
|
827
|
+
backText: { color: buoyColors.primary, fontSize: 14 },
|
|
828
|
+
screenTitle: {
|
|
829
|
+
color: buoyColors.text,
|
|
830
|
+
fontSize: 13,
|
|
831
|
+
fontWeight: "700",
|
|
832
|
+
flex: 1,
|
|
833
|
+
},
|
|
834
|
+
toolbarButton: { padding: 4, justifyContent: "center", alignItems: "center" },
|
|
835
|
+
dimmed: { opacity: 0.55 },
|
|
836
|
+
listContent: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 12 },
|
|
837
|
+
|
|
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",
|
|
873
|
+
},
|
|
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,
|
|
883
|
+
},
|
|
884
|
+
changesTitle: { color: buoyColors.text, fontSize: 14, fontWeight: "700" },
|
|
885
|
+
changesTitleRow: {
|
|
886
|
+
flexDirection: "row",
|
|
887
|
+
alignItems: "baseline",
|
|
888
|
+
gap: 8,
|
|
889
|
+
},
|
|
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%",
|
|
902
|
+
},
|
|
903
|
+
changesChipText: { color: buoyColors.textSecondary, fontSize: 11 },
|
|
904
|
+
wontApplyLine: { color: buoyColors.warning, fontSize: 11, marginTop: 6 },
|
|
905
|
+
|
|
906
|
+
// Search row
|
|
907
|
+
searchRow: {
|
|
908
|
+
flexDirection: "row",
|
|
909
|
+
alignItems: "center",
|
|
910
|
+
gap: 8,
|
|
911
|
+
marginTop: 10,
|
|
912
|
+
marginBottom: 6,
|
|
913
|
+
},
|
|
914
|
+
searchField: {
|
|
915
|
+
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,
|
|
924
|
+
},
|
|
925
|
+
searchInput: {
|
|
926
|
+
flex: 1,
|
|
927
|
+
color: buoyColors.text,
|
|
928
|
+
fontSize: 12,
|
|
929
|
+
paddingVertical: 8,
|
|
930
|
+
},
|
|
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",
|
|
940
|
+
},
|
|
941
|
+
|
|
942
|
+
// Checkbox
|
|
943
|
+
checkbox: {
|
|
944
|
+
width: 18,
|
|
945
|
+
height: 18,
|
|
946
|
+
borderRadius: 4,
|
|
947
|
+
borderWidth: 1.5,
|
|
948
|
+
borderColor: buoyColors.textMuted,
|
|
949
|
+
alignItems: "center",
|
|
950
|
+
justifyContent: "center",
|
|
951
|
+
},
|
|
952
|
+
checkboxChecked: {
|
|
953
|
+
borderColor: buoyColors.primary,
|
|
954
|
+
backgroundColor: `${buoyColors.primary}26`,
|
|
955
|
+
},
|
|
956
|
+
checkboxGlyph: {
|
|
957
|
+
color: buoyColors.primary,
|
|
958
|
+
fontSize: 11,
|
|
959
|
+
fontWeight: "700",
|
|
960
|
+
lineHeight: 13,
|
|
961
|
+
},
|
|
962
|
+
|
|
963
|
+
// Source cards
|
|
964
|
+
sourceCard: {
|
|
965
|
+
flexDirection: "row",
|
|
966
|
+
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,
|
|
975
|
+
},
|
|
976
|
+
sourceCardTouch: {
|
|
977
|
+
flex: 1,
|
|
978
|
+
flexDirection: "row",
|
|
979
|
+
alignItems: "center",
|
|
980
|
+
gap: 12,
|
|
981
|
+
minWidth: 0,
|
|
982
|
+
},
|
|
983
|
+
sourceIconTile: {
|
|
984
|
+
width: 36,
|
|
985
|
+
height: 36,
|
|
986
|
+
borderRadius: 10,
|
|
987
|
+
backgroundColor: buoyColors.hover,
|
|
988
|
+
alignItems: "center",
|
|
989
|
+
justifyContent: "center",
|
|
990
|
+
},
|
|
991
|
+
sourceNameBlock: { flex: 1, minWidth: 0, gap: 2 },
|
|
992
|
+
sourceName: {
|
|
993
|
+
color: buoyColors.text,
|
|
994
|
+
fontSize: 15,
|
|
995
|
+
fontWeight: "700",
|
|
996
|
+
},
|
|
997
|
+
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",
|
|
1006
|
+
fontVariant: ["tabular-nums"],
|
|
1007
|
+
},
|
|
1008
|
+
|
|
1009
|
+
noteRow: {
|
|
1010
|
+
color: buoyColors.textMuted,
|
|
1011
|
+
fontSize: 10,
|
|
1012
|
+
paddingLeft: 24,
|
|
1013
|
+
paddingTop: 6,
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
// Item rows (nested under a source card)
|
|
1017
|
+
itemRow: {
|
|
1018
|
+
flexDirection: "row",
|
|
1019
|
+
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,
|
|
1029
|
+
},
|
|
1030
|
+
itemBodyTouch: {
|
|
1031
|
+
flex: 1,
|
|
1032
|
+
flexDirection: "row",
|
|
1033
|
+
alignItems: "center",
|
|
1034
|
+
gap: 8,
|
|
1035
|
+
minWidth: 0,
|
|
1036
|
+
},
|
|
1037
|
+
itemGlyph: { fontSize: 13, fontWeight: "700", width: 14, textAlign: "center" },
|
|
1038
|
+
itemBody: { flex: 1, minWidth: 0 },
|
|
1039
|
+
itemLabel: { color: buoyColors.text, fontSize: 12 },
|
|
1040
|
+
itemPreview: {
|
|
1041
|
+
color: buoyColors.textMuted,
|
|
1042
|
+
fontSize: 10,
|
|
1043
|
+
marginTop: 2,
|
|
1044
|
+
fontVariant: ["tabular-nums"],
|
|
1045
|
+
},
|
|
1046
|
+
itemPreviewRemoved: { textDecorationLine: "line-through" },
|
|
1047
|
+
itemReason: { color: buoyColors.warning, fontSize: 10, marginTop: 2 },
|
|
1048
|
+
itemExcluded: {
|
|
1049
|
+
color: buoyColors.textMuted,
|
|
1050
|
+
fontSize: 10,
|
|
1051
|
+
marginTop: 2,
|
|
1052
|
+
fontStyle: "italic",
|
|
1053
|
+
},
|
|
1054
|
+
itemChevron: { color: buoyColors.textMuted, fontSize: 16 },
|
|
1055
|
+
emptyText: {
|
|
1056
|
+
color: buoyColors.textMuted,
|
|
1057
|
+
fontSize: 12,
|
|
1058
|
+
textAlign: "center",
|
|
1059
|
+
marginTop: 24,
|
|
1060
|
+
},
|
|
1061
|
+
|
|
1062
|
+
// Footer CTA
|
|
1063
|
+
footer: {
|
|
1064
|
+
paddingHorizontal: 12,
|
|
1065
|
+
paddingTop: 8,
|
|
1066
|
+
paddingBottom: 12,
|
|
1067
|
+
gap: 6,
|
|
1068
|
+
borderTopColor: buoyColors.border,
|
|
1069
|
+
borderTopWidth: 1,
|
|
1070
|
+
backgroundColor: buoyColors.base,
|
|
1071
|
+
},
|
|
1072
|
+
cta: {
|
|
1073
|
+
flexDirection: "row",
|
|
1074
|
+
alignItems: "center",
|
|
1075
|
+
justifyContent: "center",
|
|
1076
|
+
gap: 8,
|
|
1077
|
+
backgroundColor: buoyColors.primary,
|
|
1078
|
+
borderRadius: 24,
|
|
1079
|
+
paddingVertical: 13,
|
|
1080
|
+
},
|
|
1081
|
+
ctaDanger: { backgroundColor: buoyColors.error },
|
|
1082
|
+
ctaDisabled: { opacity: 0.4 },
|
|
1083
|
+
ctaText: { color: "#0A0A0A", fontSize: 14, fontWeight: "700" },
|
|
1084
|
+
ctaCaption: {
|
|
1085
|
+
color: buoyColors.textMuted,
|
|
1086
|
+
fontSize: 10,
|
|
1087
|
+
textAlign: "center",
|
|
1088
|
+
},
|
|
1089
|
+
});
|