@buoy-gg/assets 7.0.28 → 7.0.30

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 (41) hide show
  1. package/lib/commonjs/components/AssetRow.js +13 -4
  2. package/lib/commonjs/components/AssetRow.js.map +1 -1
  3. package/lib/commonjs/components/AssetsModal.js +2 -1
  4. package/lib/commonjs/components/AssetsModal.js.map +1 -1
  5. package/lib/commonjs/utils/testIDs.js +114 -0
  6. package/lib/commonjs/utils/testIDs.js.map +1 -0
  7. package/lib/module/components/AssetRow.js +13 -4
  8. package/lib/module/components/AssetRow.js.map +1 -1
  9. package/lib/module/components/AssetsModal.js +2 -1
  10. package/lib/module/components/AssetsModal.js.map +1 -1
  11. package/lib/module/utils/testIDs.js +103 -0
  12. package/lib/module/utils/testIDs.js.map +1 -0
  13. package/lib/typescript/components/AssetRow.d.ts.map +1 -1
  14. package/lib/typescript/utils/testIDs.d.ts +75 -0
  15. package/lib/typescript/utils/testIDs.d.ts.map +1 -0
  16. package/package.json +5 -6
  17. package/src/capture/expo.ts +0 -45
  18. package/src/capture/install.ts +0 -73
  19. package/src/capture/measure.ts +0 -167
  20. package/src/capture/metroGraph.ts +0 -146
  21. package/src/capture/registry.ts +0 -137
  22. package/src/components/AssetDetail.tsx +0 -207
  23. package/src/components/AssetRow.tsx +0 -243
  24. package/src/components/AssetsFilterBar.tsx +0 -99
  25. package/src/components/AssetsHeader.tsx +0 -75
  26. package/src/components/AssetsIcon.tsx +0 -9
  27. package/src/components/AssetsListControls.tsx +0 -101
  28. package/src/components/AssetsModal.tsx +0 -370
  29. package/src/components/BaselineBar.tsx +0 -98
  30. package/src/export.ts +0 -110
  31. package/src/format.ts +0 -53
  32. package/src/hooks/useAssetRecords.ts +0 -26
  33. package/src/index.tsx +0 -87
  34. package/src/preset.tsx +0 -35
  35. package/src/remote.ts +0 -65
  36. package/src/state.ts +0 -84
  37. package/src/store/assetStore.ts +0 -209
  38. package/src/store/baseline.ts +0 -152
  39. package/src/store/insights.ts +0 -145
  40. package/src/sync/assetsSyncAdapter.ts +0 -151
  41. package/src/types.ts +0 -160
@@ -1,75 +0,0 @@
1
- /**
2
- * AssetsHeader — shared ModalHeader wiring, matching the Images/Console
3
- * header pattern. List mode shows inventory totals plus the shared
4
- * ToolbarCopyButton (full markdown report); detail mode shows back
5
- * navigation and the single-asset report.
6
- */
7
-
8
- import {
9
- ModalHeader,
10
- ToolbarClearButton,
11
- ToolbarCopyButton,
12
- } from "@buoy-gg/shared-ui";
13
- import { formatBytes } from "../format";
14
- import type { AssetsStats } from "../types";
15
-
16
- interface AssetsHeaderProps {
17
- stats: AssetsStats;
18
- /** Size measurement in progress — suppress the (partial) byte total. */
19
- measuring: boolean;
20
- detailOpen: boolean;
21
- detailIndex?: number;
22
- detailTotal?: number;
23
- onCloseDetail: () => void;
24
- onClear: () => void;
25
- getReport: () => string;
26
- onBack?: () => void;
27
- }
28
-
29
- export function AssetsHeader({
30
- stats,
31
- measuring,
32
- detailOpen,
33
- detailIndex = -1,
34
- detailTotal = 0,
35
- onCloseDetail,
36
- onClear,
37
- getReport,
38
- onBack,
39
- }: AssetsHeaderProps) {
40
- if (detailOpen) {
41
- const counter =
42
- detailIndex >= 0 && detailTotal > 0
43
- ? `${detailIndex + 1} of ${detailTotal}`
44
- : undefined;
45
- return (
46
- <ModalHeader>
47
- <ModalHeader.Navigation onBack={onCloseDetail} />
48
- <ModalHeader.Content title="Asset detail" subtitle={counter} />
49
- <ModalHeader.Actions>
50
- <ToolbarCopyButton value={getReport} />
51
- </ModalHeader.Actions>
52
- </ModalHeader>
53
- );
54
- }
55
-
56
- const subtitleParts = [`${stats.total} assets`];
57
- // A mid-measure byte total reads as nonsense ("653 B+") — wait it out.
58
- if (stats.totalBytes > 0 && !measuring) {
59
- const partial = stats.measuredCount < stats.total ? "+" : "";
60
- subtitleParts.push(`${formatBytes(stats.totalBytes)}${partial}`);
61
- }
62
- if (stats.neverLoaded > 0) subtitleParts.push(`${stats.neverLoaded} never loaded`);
63
- if (stats.fontFamilies > 0) subtitleParts.push(`${stats.fontFamilies} fonts`);
64
-
65
- return (
66
- <ModalHeader>
67
- {onBack ? <ModalHeader.Navigation onBack={onBack} /> : null}
68
- <ModalHeader.Content title="Assets" subtitle={subtitleParts.join(" · ")} />
69
- <ModalHeader.Actions>
70
- <ToolbarCopyButton value={getReport} />
71
- <ToolbarClearButton onPress={onClear} />
72
- </ModalHeader.Actions>
73
- </ModalHeader>
74
- );
75
- }
@@ -1,9 +0,0 @@
1
- /**
2
- * AssetsIcon — re-exported from the Buoy Icon Format.
3
- *
4
- * The artwork lives in shared/icons/assets.json and is generated into
5
- * @buoy-gg/floating-tools-core, so React Native, the desktop dashboard and
6
- * Flutter all render the same drawing. Edit the JSON, then `pnpm icons`.
7
- */
8
-
9
- export { AssetsIcon, ASSETS_ICON_COLOR } from "@buoy-gg/shared-ui";
@@ -1,101 +0,0 @@
1
- /**
2
- * AssetsListControls — one compact row: name search, sort cycle (SIZE →
3
- * NAME → TYPE), and — when no baseline exists yet — the BASE button that
4
- * saves one. Once a baseline exists the BaselineBar takes over the diff UI,
5
- * so this row never wastes space on a hint sentence.
6
- */
7
-
8
- import { StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
9
- import { night } from "@buoy-gg/shared-ui";
10
- import { saveBaseline } from "../store/baseline";
11
-
12
- export type AssetSort = "size" | "name" | "type";
13
-
14
- const NEXT_SORT: Record<AssetSort, AssetSort> = {
15
- size: "name",
16
- name: "type",
17
- type: "size",
18
- };
19
-
20
- interface AssetsListControlsProps {
21
- query: string;
22
- onQueryChange: (query: string) => void;
23
- sort: AssetSort;
24
- onSortChange: (sort: AssetSort) => void;
25
- /** Show the save-baseline button (no baseline saved yet). */
26
- showBaselineButton: boolean;
27
- }
28
-
29
- export function AssetsListControls({
30
- query,
31
- onQueryChange,
32
- sort,
33
- onSortChange,
34
- showBaselineButton,
35
- }: AssetsListControlsProps) {
36
- return (
37
- <View style={styles.row}>
38
- <TextInput
39
- style={styles.input}
40
- value={query}
41
- onChangeText={onQueryChange}
42
- placeholder="Search assets…"
43
- placeholderTextColor={night.color.placeholder}
44
- autoCapitalize="none"
45
- autoCorrect={false}
46
- testID="buoy-assets-search"
47
- />
48
- <TouchableOpacity
49
- style={styles.button}
50
- onPress={() => onSortChange(NEXT_SORT[sort])}
51
- testID="buoy-assets-sort"
52
- >
53
- <Text style={styles.buttonText}>↓ {sort.toUpperCase()}</Text>
54
- </TouchableOpacity>
55
- {showBaselineButton ? (
56
- <TouchableOpacity
57
- style={styles.button}
58
- onPress={() => void saveBaseline()}
59
- testID="buoy-assets-save-baseline"
60
- >
61
- <Text style={styles.buttonText}>BASE</Text>
62
- </TouchableOpacity>
63
- ) : null}
64
- </View>
65
- );
66
- }
67
-
68
- const styles = StyleSheet.create({
69
- row: {
70
- flexDirection: "row",
71
- alignItems: "center",
72
- gap: 8,
73
- paddingHorizontal: 10,
74
- paddingBottom: 8,
75
- },
76
- input: {
77
- flex: 1,
78
- backgroundColor: night.color.surfaceElevated,
79
- borderWidth: 1,
80
- borderColor: night.color.border,
81
- borderRadius: 8,
82
- paddingHorizontal: 10,
83
- paddingVertical: 6,
84
- color: night.color.text,
85
- fontSize: 12,
86
- },
87
- button: {
88
- backgroundColor: night.color.surface,
89
- borderWidth: 1,
90
- borderColor: night.color.border,
91
- borderRadius: 8,
92
- paddingHorizontal: 10,
93
- paddingVertical: 7,
94
- },
95
- buttonText: {
96
- color: night.color.textSecondary,
97
- fontSize: 10,
98
- fontWeight: "700",
99
- letterSpacing: 0.5,
100
- },
101
- });
@@ -1,370 +0,0 @@
1
- /**
2
- * AssetsModal — bottom-sheet wrapper rendered by FloatingDevTools when the
3
- * Assets tool is opened. Inventory list (largest first) → tap for detail.
4
- * Opening triggers a full rescan (registry walk + Metro graph + size
5
- * measurement); merely installing the package never hits the network.
6
- */
7
-
8
- import { useEffect, useState } from "react";
9
- import { FlatList, StyleSheet, Text, View } from "react-native";
10
- import { EventStepperFooter, JsModal, macOSColors, night } from "@buoy-gg/shared-ui";
11
- import type { ModalState } from "@buoy-gg/shared-ui";
12
- import { getScanStatus, installAssetCapture, rescan } from "../capture/install";
13
- import { formatAssetMarkdown, formatInventoryMarkdown } from "../export";
14
- import { useAssetRecords } from "../hooks/useAssetRecords";
15
- import { remoteAssetsDispatch } from "../remote";
16
- import { clearRecords, estDecodedBytes, getRecord } from "../store/assetStore";
17
- import { getDiff, loadBaseline } from "../store/baseline";
18
- import { AssetDetail } from "./AssetDetail";
19
- import { AssetRow } from "./AssetRow";
20
- import { AssetsFilterBar, type AssetFilter } from "./AssetsFilterBar";
21
- import { AssetsHeader } from "./AssetsHeader";
22
- import { AssetsListControls, type AssetSort } from "./AssetsListControls";
23
- import { BaselineBar } from "./BaselineBar";
24
- import type { AssetRecord } from "../types";
25
-
26
- interface AssetsModalProps {
27
- visible: boolean;
28
- onClose: () => void;
29
- onBack?: () => void;
30
- onMinimize?: (modalState: ModalState) => void;
31
- persistenceKey?: string;
32
- /** Render as a plain header-bar + body stack instead of a JsModal (desktop). */
33
- inline?: boolean;
34
- }
35
-
36
- function ScanNotice() {
37
- const status = getScanStatus();
38
- const notices: string[] = [];
39
- if (!status.registryAvailable) {
40
- notices.push(
41
- "Asset registry unavailable — this environment doesn't expose @react-native/assets-registry."
42
- );
43
- } else if (!status.graphSupported && status.graphError) {
44
- notices.push(
45
- "No Metro dev server — showing runtime-registered assets only. The full bundle inventory and byte sizes need a dev build."
46
- );
47
- }
48
- if (status.measuring) notices.push("Measuring sizes from the dev server…");
49
- if (notices.length === 0) return null;
50
- return (
51
- <View style={styles.notice}>
52
- {notices.map((n) => (
53
- <Text key={n} style={styles.noticeText}>
54
- {n}
55
- </Text>
56
- ))}
57
- </View>
58
- );
59
- }
60
-
61
- function FontFamilies() {
62
- const families = getScanStatus().fontFamilies;
63
- if (!families.length) return null;
64
- return (
65
- <View style={styles.fontsBox}>
66
- <Text style={styles.fontsTitle}>LOADED FONT FAMILIES ({families.length})</Text>
67
- <View style={styles.fontsWrap}>
68
- {families.map((family) => (
69
- <Text key={family} style={styles.fontChip}>
70
- {family}
71
- </Text>
72
- ))}
73
- </View>
74
- </View>
75
- );
76
- }
77
-
78
- function EmptyState() {
79
- return (
80
- <View style={styles.empty}>
81
- <Text style={styles.emptyTitle}>No assets found yet</Text>
82
- <Text style={styles.emptyText}>
83
- Every bundled asset — images, fonts, video, audio — appears here with
84
- sizes, scale coverage, and never-loaded detection. Try Rescan after
85
- navigating around the app.
86
- </Text>
87
- </View>
88
- );
89
- }
90
-
91
- /** Largest first — measured bytes, falling back to decoded estimate. */
92
- function bySizeDesc(a: AssetRecord, b: AssetRecord): number {
93
- return (b.sizeBytes ?? estDecodedBytes(b)) - (a.sizeBytes ?? estDecodedBytes(a));
94
- }
95
-
96
- const COMPARATORS: Record<AssetSort, (a: AssetRecord, b: AssetRecord) => number> = {
97
- size: bySizeDesc,
98
- name: (a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type),
99
- type: (a, b) => a.type.localeCompare(b.type) || bySizeDesc(a, b),
100
- };
101
-
102
- export function AssetsModal({
103
- visible,
104
- onClose,
105
- onBack,
106
- onMinimize,
107
- persistenceKey,
108
- inline = false,
109
- }: AssetsModalProps) {
110
- // Safety net: the index import normally installed this already.
111
- installAssetCapture();
112
-
113
- const { records, stats, insights } = useAssetRecords();
114
- const [selectedId, setSelectedId] = useState<number | null>(null);
115
- const [filter, setFilter] = useState<AssetFilter>("all");
116
- const [query, setQuery] = useState("");
117
- const [sort, setSort] = useState<AssetSort>("size");
118
- const selected = selectedId != null ? getRecord(selectedId) : undefined;
119
-
120
- // Full scan on open — locally on device, forwarded on dashboard mirrors.
121
- // The baseline load is async and re-renders through the store emit.
122
- useEffect(() => {
123
- if (!visible) return;
124
- void loadBaseline();
125
- const remote = remoteAssetsDispatch();
126
- if (remote) {
127
- remote("rescan").catch(() => {});
128
- } else {
129
- void rescan();
130
- }
131
- }, [visible]);
132
-
133
- const sorted = [...records].sort(COMPARATORS[sort]);
134
- const trimmedQuery = query.trim().toLowerCase();
135
- const filtered = sorted.filter((r) => {
136
- if (filter === "unused" && r.loaded) return false;
137
- if (filter !== "all" && filter !== "unused" && r.kind !== filter) return false;
138
- if (trimmedQuery && !`${r.name}.${r.type}`.toLowerCase().includes(trimmedQuery)) {
139
- return false;
140
- }
141
- return true;
142
- });
143
-
144
- const selectedIndex = selected
145
- ? filtered.findIndex((r) => r.id === selected.id)
146
- : -1;
147
- const stepTo = (index: number) => {
148
- const target = filtered[index];
149
- if (target) setSelectedId(target.id);
150
- };
151
-
152
- // Set lookups so each row's badge checks are O(1).
153
- const flagSets = {
154
- scaleGaps: new Set(insights.scaleGaps),
155
- scaleAnomalies: new Set(insights.scaleAnomalies),
156
- duplicates: new Set(insights.duplicates.flatMap((d) => d.ids)),
157
- webpCandidates: new Set(insights.webpCandidates),
158
- overDecode: new Set(insights.overDecode),
159
- heavy: new Set([...insights.heavyGifs, ...insights.heavyFonts]),
160
- };
161
-
162
- const diff = getDiff();
163
-
164
- // The proportional size bar is relative to the largest asset in view.
165
- let maxBytes = 0;
166
- for (const r of filtered) {
167
- const bytes = r.sizeBytes ?? estDecodedBytes(r);
168
- if (bytes > maxBytes) maxBytes = bytes;
169
- }
170
-
171
- const getReport = () =>
172
- selected
173
- ? formatAssetMarkdown(selected)
174
- : formatInventoryMarkdown(sorted, stats, insights, getScanStatus(), getDiff());
175
-
176
- const headerContent = (
177
- <AssetsHeader
178
- stats={stats}
179
- measuring={getScanStatus().measuring}
180
- detailOpen={selected != null}
181
- detailIndex={selectedIndex}
182
- detailTotal={filtered.length}
183
- onCloseDetail={() => setSelectedId(null)}
184
- onClear={clearRecords}
185
- getReport={getReport}
186
- onBack={onBack}
187
- />
188
- );
189
-
190
- const body = selected ? (
191
- <View style={styles.detailContainer}>
192
- <AssetDetail record={selected} insights={insights} />
193
- {/* Same stepper the network tool uses — steps through the FILTERED
194
- list, so search/kind filters re-scope prev/next in place. */}
195
- {selectedIndex >= 0 ? (
196
- <EventStepperFooter
197
- currentIndex={selectedIndex}
198
- totalItems={filtered.length}
199
- itemLabel="Asset"
200
- onPrevious={() => stepTo(selectedIndex - 1)}
201
- onNext={() => stepTo(selectedIndex + 1)}
202
- />
203
- ) : null}
204
- </View>
205
- ) : (
206
- <View style={styles.listContainer}>
207
- <AssetsFilterBar stats={stats} filter={filter} onChange={setFilter} />
208
- <AssetsListControls
209
- query={query}
210
- onQueryChange={setQuery}
211
- sort={sort}
212
- onSortChange={setSort}
213
- showBaselineButton={diff == null}
214
- />
215
- <BaselineBar diff={diff} />
216
- <ScanNotice />
217
- {filter === "font" ? <FontFamilies /> : null}
218
- <FlatList
219
- data={filtered}
220
- keyExtractor={(item: AssetRecord) => String(item.id)}
221
- contentContainerStyle={styles.listContent}
222
- renderItem={({ item }) => (
223
- <AssetRow
224
- record={item}
225
- flags={{
226
- scaleGap: flagSets.scaleGaps.has(item.id),
227
- scaleAnomaly: flagSets.scaleAnomalies.has(item.id),
228
- duplicate: flagSets.duplicates.has(item.id),
229
- webp: flagSets.webpCandidates.has(item.id),
230
- oversize: flagSets.overDecode.has(item.id),
231
- heavy: flagSets.heavy.has(item.id),
232
- }}
233
- sizeFraction={
234
- maxBytes > 0
235
- ? (item.sizeBytes ?? estDecodedBytes(item)) / maxBytes
236
- : 0
237
- }
238
- // Guarded: programmatic invocations (automation) can call this
239
- // with an event object instead of an id.
240
- onPress={(id) => setSelectedId(typeof id === "number" ? id : null)}
241
- />
242
- )}
243
- ListEmptyComponent={<EmptyState />}
244
- />
245
- </View>
246
- );
247
-
248
- // The ONLY difference between phone and desktop is which shell the header
249
- // and body go in (same pattern as ImagesModal).
250
- if (inline) {
251
- return (
252
- <View style={styles.inlineRoot}>
253
- <View style={styles.inlineHeader}>{headerContent}</View>
254
- {body}
255
- </View>
256
- );
257
- }
258
-
259
- return (
260
- <JsModal
261
- visible={visible}
262
- onClose={onClose}
263
- onMinimize={onMinimize}
264
- persistenceKey={persistenceKey}
265
- header={{
266
- showToggleButton: true,
267
- customContent: headerContent,
268
- }}
269
- enablePersistence
270
- initialMode="bottomSheet"
271
- disableScrollWrapper
272
- >
273
- {body}
274
- </JsModal>
275
- );
276
- }
277
-
278
- /**
279
- * The same Assets tool rendered INLINE for hosts that place tools in a panel
280
- * rather than a modal (the desktop dashboard). One implementation only.
281
- */
282
- export function AssetsToolView(
283
- props: Omit<AssetsModalProps, "visible" | "onClose" | "inline"> = {}
284
- ) {
285
- return <AssetsModal {...props} inline visible onClose={() => {}} />;
286
- }
287
-
288
- const styles = StyleSheet.create({
289
- inlineRoot: {
290
- flex: 1,
291
- minHeight: 0,
292
- // Transparent so the desktop workbench's hoisted sky (one field spanning
293
- // sidebar + panel) shows through — the shell paints its base beneath it.
294
- backgroundColor: "transparent",
295
- },
296
- inlineHeader: {
297
- paddingHorizontal: 12,
298
- paddingVertical: 8,
299
- borderBottomWidth: 1,
300
- borderBottomColor: macOSColors.border.default,
301
- backgroundColor: macOSColors.background.card,
302
- },
303
- listContainer: {
304
- flex: 1,
305
- },
306
- listContent: {
307
- paddingBottom: 16,
308
- },
309
- detailContainer: {
310
- flex: 1,
311
- },
312
- notice: {
313
- marginHorizontal: 10,
314
- marginBottom: 8,
315
- padding: 10,
316
- borderRadius: 8,
317
- borderWidth: 1,
318
- borderColor: night.color.border,
319
- backgroundColor: night.color.surface,
320
- gap: 4,
321
- },
322
- noticeText: {
323
- color: night.color.textSecondary,
324
- fontSize: 11,
325
- },
326
- fontsBox: {
327
- marginHorizontal: 10,
328
- marginTop: 8,
329
- padding: 10,
330
- borderRadius: 8,
331
- backgroundColor: night.color.surface,
332
- gap: 6,
333
- },
334
- fontsTitle: {
335
- color: night.color.textTertiary,
336
- fontSize: 9,
337
- fontWeight: "700",
338
- letterSpacing: 0.5,
339
- },
340
- fontsWrap: {
341
- flexDirection: "row",
342
- flexWrap: "wrap",
343
- gap: 6,
344
- },
345
- fontChip: {
346
- color: night.color.textSecondary,
347
- fontSize: 10,
348
- backgroundColor: night.color.bg,
349
- borderRadius: 999,
350
- paddingHorizontal: 8,
351
- paddingVertical: 3,
352
- overflow: "hidden",
353
- },
354
- empty: {
355
- alignItems: "center",
356
- padding: 32,
357
- gap: 8,
358
- },
359
- emptyTitle: {
360
- color: night.color.text,
361
- fontSize: 13,
362
- fontWeight: "600",
363
- },
364
- emptyText: {
365
- color: night.color.textSecondary,
366
- fontSize: 11,
367
- textAlign: "center",
368
- lineHeight: 16,
369
- },
370
- });
@@ -1,98 +0,0 @@
1
- /**
2
- * BaselineBar — save/compare-against-baseline controls.
3
- *
4
- * No baseline: one "SAVE BASELINE" button. With a baseline: the diff summary
5
- * ("+3 new · 1 removed · +1.2 MB since 2h ago") plus UPDATE and ✕ (clear).
6
- * Growth renders in warning color, shrinkage in success — that's the whole
7
- * point of the feature.
8
- */
9
-
10
- import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
11
- import { macOSColors, night } from "@buoy-gg/shared-ui";
12
- import { formatBytes } from "../format";
13
- import { clearBaseline, saveBaseline } from "../store/baseline";
14
- import type { AssetsDiff } from "../types";
15
-
16
- function formatAge(savedAt: number): string {
17
- const minutes = Math.max(1, Math.round((Date.now() - savedAt) / 60000));
18
- if (minutes < 60) return `${minutes}m ago`;
19
- const hours = Math.round(minutes / 60);
20
- if (hours < 48) return `${hours}h ago`;
21
- return `${Math.round(hours / 24)}d ago`;
22
- }
23
-
24
- export function BaselineBar({ diff }: { diff: AssetsDiff | null }) {
25
- // No baseline → no bar; the BASE button in AssetsListControls creates one.
26
- if (!diff) return null;
27
-
28
- const parts: string[] = [];
29
- if (diff.added.length) parts.push(`+${diff.added.length} new`);
30
- if (diff.removed.length) parts.push(`${diff.removed.length} removed`);
31
- if (diff.grown.length) parts.push(`${diff.grown.length} grew`);
32
- const net =
33
- diff.netBytes != null && Math.abs(diff.netBytes) >= 1024
34
- ? `${diff.netBytes > 0 ? "+" : "−"}${formatBytes(Math.abs(diff.netBytes))}`
35
- : null;
36
- const changed = parts.length > 0 || net != null;
37
- const netColor =
38
- diff.netBytes != null && diff.netBytes > 0
39
- ? macOSColors.semantic.warning
40
- : macOSColors.semantic.success;
41
-
42
- return (
43
- <View style={styles.bar}>
44
- <Text style={styles.summary} numberOfLines={1}>
45
- {changed ? parts.join(" · ") : "No changes"}
46
- {net ? " · " : ""}
47
- {net ? <Text style={{ color: netColor }}>{net}</Text> : null}
48
- <Text style={styles.age}> vs {formatAge(diff.baselineSavedAt)}</Text>
49
- </Text>
50
- <TouchableOpacity style={styles.button} onPress={() => void saveBaseline()}>
51
- <Text style={styles.buttonText}>UPDATE</Text>
52
- </TouchableOpacity>
53
- <TouchableOpacity style={styles.button} onPress={() => void clearBaseline()}>
54
- <Text style={styles.buttonText}>✕</Text>
55
- </TouchableOpacity>
56
- </View>
57
- );
58
- }
59
-
60
- const styles = StyleSheet.create({
61
- bar: {
62
- flexDirection: "row",
63
- alignItems: "center",
64
- gap: 8,
65
- marginHorizontal: 10,
66
- marginBottom: 8,
67
- paddingHorizontal: 10,
68
- paddingVertical: 6,
69
- borderWidth: 1,
70
- borderColor: night.color.border,
71
- borderRadius: 8,
72
- backgroundColor: night.color.surface,
73
- },
74
- summary: {
75
- flex: 1,
76
- color: night.color.textSecondary,
77
- fontSize: 10,
78
- fontWeight: "600",
79
- },
80
- age: {
81
- color: night.color.textTertiary,
82
- fontWeight: "400",
83
- },
84
- button: {
85
- backgroundColor: night.color.surfaceElevated,
86
- borderWidth: 1,
87
- borderColor: night.color.border,
88
- borderRadius: 6,
89
- paddingHorizontal: 8,
90
- paddingVertical: 5,
91
- },
92
- buttonText: {
93
- color: night.color.textSecondary,
94
- fontSize: 9,
95
- fontWeight: "700",
96
- letterSpacing: 0.5,
97
- },
98
- });