@buoy-gg/images 7.0.3

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 (195) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +58 -0
  3. package/lib/commonjs/capture/actions.js +227 -0
  4. package/lib/commonjs/capture/actions.js.map +1 -0
  5. package/lib/commonjs/capture/install.js +41 -0
  6. package/lib/commonjs/capture/install.js.map +1 -0
  7. package/lib/commonjs/capture/installExpoImage.js +99 -0
  8. package/lib/commonjs/capture/installExpoImage.js.map +1 -0
  9. package/lib/commonjs/capture/installRnImage.js +128 -0
  10. package/lib/commonjs/capture/installRnImage.js.map +1 -0
  11. package/lib/commonjs/capture/overrides.js +286 -0
  12. package/lib/commonjs/capture/overrides.js.map +1 -0
  13. package/lib/commonjs/capture/savings.js +193 -0
  14. package/lib/commonjs/capture/savings.js.map +1 -0
  15. package/lib/commonjs/capture/shared.js +367 -0
  16. package/lib/commonjs/capture/shared.js.map +1 -0
  17. package/lib/commonjs/components/ImageDetail.js +444 -0
  18. package/lib/commonjs/components/ImageDetail.js.map +1 -0
  19. package/lib/commonjs/components/ImageDetailFooter.js +232 -0
  20. package/lib/commonjs/components/ImageDetailFooter.js.map +1 -0
  21. package/lib/commonjs/components/ImageRow.js +164 -0
  22. package/lib/commonjs/components/ImageRow.js.map +1 -0
  23. package/lib/commonjs/components/ImagesHeader.js +63 -0
  24. package/lib/commonjs/components/ImagesHeader.js.map +1 -0
  25. package/lib/commonjs/components/ImagesIcon.js +59 -0
  26. package/lib/commonjs/components/ImagesIcon.js.map +1 -0
  27. package/lib/commonjs/components/ImagesListFooter.js +194 -0
  28. package/lib/commonjs/components/ImagesListFooter.js.map +1 -0
  29. package/lib/commonjs/components/ImagesModal.js +173 -0
  30. package/lib/commonjs/components/ImagesModal.js.map +1 -0
  31. package/lib/commonjs/components/ImagesRoot.js +24 -0
  32. package/lib/commonjs/components/ImagesRoot.js.map +1 -0
  33. package/lib/commonjs/components/InsightsBar.js +59 -0
  34. package/lib/commonjs/components/InsightsBar.js.map +1 -0
  35. package/lib/commonjs/export.js +81 -0
  36. package/lib/commonjs/export.js.map +1 -0
  37. package/lib/commonjs/format.js +117 -0
  38. package/lib/commonjs/format.js.map +1 -0
  39. package/lib/commonjs/hooks/useImageRecords.js +20 -0
  40. package/lib/commonjs/hooks/useImageRecords.js.map +1 -0
  41. package/lib/commonjs/index.js +236 -0
  42. package/lib/commonjs/index.js.map +1 -0
  43. package/lib/commonjs/package.json +1 -0
  44. package/lib/commonjs/preset.js +45 -0
  45. package/lib/commonjs/preset.js.map +1 -0
  46. package/lib/commonjs/register.js +19 -0
  47. package/lib/commonjs/register.js.map +1 -0
  48. package/lib/commonjs/state.js +54 -0
  49. package/lib/commonjs/state.js.map +1 -0
  50. package/lib/commonjs/store/imageEventStore.js +160 -0
  51. package/lib/commonjs/store/imageEventStore.js.map +1 -0
  52. package/lib/commonjs/store/insights.js +76 -0
  53. package/lib/commonjs/store/insights.js.map +1 -0
  54. package/lib/commonjs/sync/imagesSyncAdapter.js +315 -0
  55. package/lib/commonjs/sync/imagesSyncAdapter.js.map +1 -0
  56. package/lib/commonjs/types.js +2 -0
  57. package/lib/commonjs/types.js.map +1 -0
  58. package/lib/module/capture/actions.js +214 -0
  59. package/lib/module/capture/actions.js.map +1 -0
  60. package/lib/module/capture/install.js +36 -0
  61. package/lib/module/capture/install.js.map +1 -0
  62. package/lib/module/capture/installExpoImage.js +94 -0
  63. package/lib/module/capture/installExpoImage.js.map +1 -0
  64. package/lib/module/capture/installRnImage.js +122 -0
  65. package/lib/module/capture/installRnImage.js.map +1 -0
  66. package/lib/module/capture/overrides.js +269 -0
  67. package/lib/module/capture/overrides.js.map +1 -0
  68. package/lib/module/capture/savings.js +189 -0
  69. package/lib/module/capture/savings.js.map +1 -0
  70. package/lib/module/capture/shared.js +359 -0
  71. package/lib/module/capture/shared.js.map +1 -0
  72. package/lib/module/components/ImageDetail.js +440 -0
  73. package/lib/module/components/ImageDetail.js.map +1 -0
  74. package/lib/module/components/ImageDetailFooter.js +228 -0
  75. package/lib/module/components/ImageDetailFooter.js.map +1 -0
  76. package/lib/module/components/ImageRow.js +160 -0
  77. package/lib/module/components/ImageRow.js.map +1 -0
  78. package/lib/module/components/ImagesHeader.js +59 -0
  79. package/lib/module/components/ImagesHeader.js.map +1 -0
  80. package/lib/module/components/ImagesIcon.js +55 -0
  81. package/lib/module/components/ImagesIcon.js.map +1 -0
  82. package/lib/module/components/ImagesListFooter.js +190 -0
  83. package/lib/module/components/ImagesListFooter.js.map +1 -0
  84. package/lib/module/components/ImagesModal.js +169 -0
  85. package/lib/module/components/ImagesModal.js.map +1 -0
  86. package/lib/module/components/ImagesRoot.js +20 -0
  87. package/lib/module/components/ImagesRoot.js.map +1 -0
  88. package/lib/module/components/InsightsBar.js +55 -0
  89. package/lib/module/components/InsightsBar.js.map +1 -0
  90. package/lib/module/export.js +76 -0
  91. package/lib/module/export.js.map +1 -0
  92. package/lib/module/format.js +108 -0
  93. package/lib/module/format.js.map +1 -0
  94. package/lib/module/hooks/useImageRecords.js +16 -0
  95. package/lib/module/hooks/useImageRecords.js.map +1 -0
  96. package/lib/module/index.js +73 -0
  97. package/lib/module/index.js.map +1 -0
  98. package/lib/module/preset.js +40 -0
  99. package/lib/module/preset.js.map +1 -0
  100. package/lib/module/register.js +19 -0
  101. package/lib/module/register.js.map +1 -0
  102. package/lib/module/state.js +50 -0
  103. package/lib/module/state.js.map +1 -0
  104. package/lib/module/store/imageEventStore.js +146 -0
  105. package/lib/module/store/imageEventStore.js.map +1 -0
  106. package/lib/module/store/insights.js +71 -0
  107. package/lib/module/store/insights.js.map +1 -0
  108. package/lib/module/sync/imagesSyncAdapter.js +311 -0
  109. package/lib/module/sync/imagesSyncAdapter.js.map +1 -0
  110. package/lib/module/types.js +2 -0
  111. package/lib/module/types.js.map +1 -0
  112. package/lib/typescript/capture/actions.d.ts +49 -0
  113. package/lib/typescript/capture/actions.d.ts.map +1 -0
  114. package/lib/typescript/capture/install.d.ts +15 -0
  115. package/lib/typescript/capture/install.d.ts.map +1 -0
  116. package/lib/typescript/capture/installExpoImage.d.ts +23 -0
  117. package/lib/typescript/capture/installExpoImage.d.ts.map +1 -0
  118. package/lib/typescript/capture/installRnImage.d.ts +24 -0
  119. package/lib/typescript/capture/installRnImage.d.ts.map +1 -0
  120. package/lib/typescript/capture/overrides.d.ts +83 -0
  121. package/lib/typescript/capture/overrides.d.ts.map +1 -0
  122. package/lib/typescript/capture/savings.d.ts +37 -0
  123. package/lib/typescript/capture/savings.d.ts.map +1 -0
  124. package/lib/typescript/capture/shared.d.ts +61 -0
  125. package/lib/typescript/capture/shared.d.ts.map +1 -0
  126. package/lib/typescript/components/ImageDetail.d.ts +13 -0
  127. package/lib/typescript/components/ImageDetail.d.ts.map +1 -0
  128. package/lib/typescript/components/ImageDetailFooter.d.ts +19 -0
  129. package/lib/typescript/components/ImageDetailFooter.d.ts.map +1 -0
  130. package/lib/typescript/components/ImageRow.d.ts +15 -0
  131. package/lib/typescript/components/ImageRow.d.ts.map +1 -0
  132. package/lib/typescript/components/ImagesHeader.d.ts +25 -0
  133. package/lib/typescript/components/ImagesHeader.d.ts.map +1 -0
  134. package/lib/typescript/components/ImagesIcon.d.ts +13 -0
  135. package/lib/typescript/components/ImagesIcon.d.ts.map +1 -0
  136. package/lib/typescript/components/ImagesListFooter.d.ts +8 -0
  137. package/lib/typescript/components/ImagesListFooter.d.ts.map +1 -0
  138. package/lib/typescript/components/ImagesModal.d.ts +17 -0
  139. package/lib/typescript/components/ImagesModal.d.ts.map +1 -0
  140. package/lib/typescript/components/ImagesRoot.d.ts +11 -0
  141. package/lib/typescript/components/ImagesRoot.d.ts.map +1 -0
  142. package/lib/typescript/components/InsightsBar.d.ts +13 -0
  143. package/lib/typescript/components/InsightsBar.d.ts.map +1 -0
  144. package/lib/typescript/export.d.ts +12 -0
  145. package/lib/typescript/export.d.ts.map +1 -0
  146. package/lib/typescript/format.d.ts +26 -0
  147. package/lib/typescript/format.d.ts.map +1 -0
  148. package/lib/typescript/hooks/useImageRecords.d.ts +11 -0
  149. package/lib/typescript/hooks/useImageRecords.d.ts.map +1 -0
  150. package/lib/typescript/index.d.ts +33 -0
  151. package/lib/typescript/index.d.ts.map +1 -0
  152. package/lib/typescript/preset.d.ts +38 -0
  153. package/lib/typescript/preset.d.ts.map +1 -0
  154. package/lib/typescript/register.d.ts +15 -0
  155. package/lib/typescript/register.d.ts.map +1 -0
  156. package/lib/typescript/state.d.ts +54 -0
  157. package/lib/typescript/state.d.ts.map +1 -0
  158. package/lib/typescript/store/imageEventStore.d.ts +34 -0
  159. package/lib/typescript/store/imageEventStore.d.ts.map +1 -0
  160. package/lib/typescript/store/insights.d.ts +11 -0
  161. package/lib/typescript/store/insights.d.ts.map +1 -0
  162. package/lib/typescript/sync/imagesSyncAdapter.d.ts +205 -0
  163. package/lib/typescript/sync/imagesSyncAdapter.d.ts.map +1 -0
  164. package/lib/typescript/types.d.ts +121 -0
  165. package/lib/typescript/types.d.ts.map +1 -0
  166. package/package.json +67 -0
  167. package/register.d.ts +1 -0
  168. package/register.js +15 -0
  169. package/src/capture/actions.ts +206 -0
  170. package/src/capture/install.ts +36 -0
  171. package/src/capture/installExpoImage.ts +115 -0
  172. package/src/capture/installRnImage.tsx +133 -0
  173. package/src/capture/overrides.ts +282 -0
  174. package/src/capture/savings.ts +193 -0
  175. package/src/capture/shared.ts +387 -0
  176. package/src/components/ImageDetail.tsx +456 -0
  177. package/src/components/ImageDetailFooter.tsx +270 -0
  178. package/src/components/ImageRow.tsx +148 -0
  179. package/src/components/ImagesHeader.tsx +77 -0
  180. package/src/components/ImagesIcon.tsx +66 -0
  181. package/src/components/ImagesListFooter.tsx +193 -0
  182. package/src/components/ImagesModal.tsx +195 -0
  183. package/src/components/ImagesRoot.tsx +18 -0
  184. package/src/components/InsightsBar.tsx +51 -0
  185. package/src/export.ts +100 -0
  186. package/src/format.ts +85 -0
  187. package/src/hooks/useImageRecords.ts +24 -0
  188. package/src/index.tsx +108 -0
  189. package/src/preset.tsx +35 -0
  190. package/src/register.ts +17 -0
  191. package/src/state.ts +88 -0
  192. package/src/store/imageEventStore.ts +162 -0
  193. package/src/store/insights.ts +66 -0
  194. package/src/sync/imagesSyncAdapter.ts +261 -0
  195. package/src/types.ts +111 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ImagesListFooter — the list screen's action bar: mass simulations applied
3
+ * to EVERY mounted image at once (error / loading / blank), mass hard-reload,
4
+ * flash everything, restore all — plus the global network simulation modes
5
+ * (Normal / Offline / Cold). Styled to match the detail footer.
6
+ */
7
+
8
+ import { useState, useSyncExternalStore } from "react";
9
+ import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
10
+ import { macOSColors, useSafeAreaInsets } from "@buoy-gg/shared-ui";
11
+ import { hardReloadAllMounted, subscribeActions } from "../capture/actions";
12
+ import {
13
+ clearAllOverrides,
14
+ flashAllMounted,
15
+ getGlobalModes,
16
+ setNetworkMode,
17
+ setOverrideForAllMounted,
18
+ } from "../capture/overrides";
19
+ import type { NetworkMode, OverrideSource } from "../capture/overrides";
20
+
21
+ const NETWORK_MODES: { key: NetworkMode; label: string }[] = [
22
+ { key: "normal", label: "Normal" },
23
+ { key: "offline", label: "Offline" },
24
+ { key: "cold", label: "Cold" },
25
+ ];
26
+
27
+ export function ImagesListFooter() {
28
+ const insets = useSafeAreaInsets();
29
+ const [message, setMessage] = useState<string | null>(null);
30
+ const network = useSyncExternalStore(
31
+ subscribeActions,
32
+ () => getGlobalModes().network
33
+ );
34
+
35
+ const massOverride = (source: OverrideSource, label: string) => {
36
+ const count = setOverrideForAllMounted(source, label);
37
+ setMessage(count > 0 ? `${label} on ${count} image${count === 1 ? "" : "s"} — Restore all to undo` : "No mounted images");
38
+ };
39
+
40
+ return (
41
+ <View style={[styles.footer, { paddingBottom: 10 + insets.bottom }]}>
42
+ {message ? (
43
+ <Text style={styles.message} numberOfLines={1}>
44
+ {message}
45
+ </Text>
46
+ ) : (
47
+ <Text style={styles.caption}>MASS ACTIONS — every image on screen</Text>
48
+ )}
49
+ <View style={styles.row}>
50
+ <MassButton label="Error" onPress={() => massOverride({ type: "error" }, "Forced error")} />
51
+ <MassButton label="Loading" onPress={() => massOverride({ type: "hang" }, "Forced loading")} />
52
+ <MassButton label="Blank" onPress={() => massOverride({ type: "blank" }, "Blanked")} />
53
+ <MassButton
54
+ label="Reload"
55
+ onPress={() => {
56
+ setMessage("…");
57
+ hardReloadAllMounted().then((r) => setMessage(r.message));
58
+ }}
59
+ />
60
+ <MassButton
61
+ label="Find"
62
+ onPress={() => {
63
+ const count = flashAllMounted();
64
+ setMessage(`Flashing ${count} image${count === 1 ? "" : "s"} (2.5s)`);
65
+ }}
66
+ />
67
+ <MassButton
68
+ label="Restore"
69
+ accent
70
+ onPress={() => {
71
+ const count = clearAllOverrides();
72
+ setMessage(count > 0 ? `Restored ${count} image${count === 1 ? "" : "s"}` : "No overrides active");
73
+ }}
74
+ />
75
+ </View>
76
+
77
+ <View style={styles.row}>
78
+ <Text style={styles.netLabel}>NET</Text>
79
+ {NETWORK_MODES.map((mode) => {
80
+ const active = network === mode.key;
81
+ return (
82
+ <TouchableOpacity
83
+ key={mode.key}
84
+ onPress={() => {
85
+ setNetworkMode(mode.key);
86
+ // Modes only affect loads that happen AFTER they're set — an
87
+ // on-screen image keeps its bitmap until something reloads it.
88
+ setMessage(
89
+ mode.key === "normal"
90
+ ? "Network simulation off — tap RELOAD to load normally again"
91
+ : mode.key === "offline"
92
+ ? "Offline: network images FAIL (bundled assets still load) — tap RELOAD to apply to on-screen images"
93
+ : "Cold (bypass caches) — tap RELOAD: everything refetches like first launch"
94
+ );
95
+ }}
96
+ style={[styles.netButton, active && styles.netButtonActive]}
97
+ activeOpacity={0.7}
98
+ >
99
+ <Text style={[styles.buttonText, active && styles.netTextActive]}>
100
+ {mode.label}
101
+ </Text>
102
+ </TouchableOpacity>
103
+ );
104
+ })}
105
+ </View>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ function MassButton({
111
+ label,
112
+ onPress,
113
+ accent,
114
+ }: {
115
+ label: string;
116
+ onPress: () => void;
117
+ accent?: boolean;
118
+ }) {
119
+ return (
120
+ <TouchableOpacity onPress={onPress} style={styles.button} activeOpacity={0.7}>
121
+ <Text style={[styles.buttonText, accent && styles.buttonTextAccent]}>{label}</Text>
122
+ </TouchableOpacity>
123
+ );
124
+ }
125
+
126
+ const styles = StyleSheet.create({
127
+ footer: {
128
+ paddingHorizontal: 12,
129
+ paddingTop: 8,
130
+ backgroundColor: macOSColors.background.base,
131
+ borderTopWidth: 1,
132
+ borderTopColor: macOSColors.border.default,
133
+ gap: 8,
134
+ },
135
+ caption: {
136
+ color: macOSColors.text.muted,
137
+ fontSize: 9,
138
+ fontWeight: "700",
139
+ letterSpacing: 0.8,
140
+ },
141
+ message: {
142
+ color: macOSColors.semantic.info,
143
+ fontSize: 10,
144
+ fontFamily: "monospace",
145
+ },
146
+ row: {
147
+ flexDirection: "row",
148
+ gap: 6,
149
+ },
150
+ button: {
151
+ flex: 1,
152
+ alignItems: "center",
153
+ paddingVertical: 8,
154
+ borderRadius: 6,
155
+ backgroundColor: macOSColors.background.card,
156
+ borderWidth: StyleSheet.hairlineWidth,
157
+ borderColor: macOSColors.border.default,
158
+ },
159
+ buttonText: {
160
+ fontSize: 10,
161
+ fontWeight: "600",
162
+ color: macOSColors.text.secondary,
163
+ fontFamily: "monospace",
164
+ textTransform: "uppercase",
165
+ },
166
+ buttonTextAccent: {
167
+ color: macOSColors.semantic.warning,
168
+ },
169
+ netLabel: {
170
+ color: macOSColors.text.muted,
171
+ fontSize: 9,
172
+ fontWeight: "700",
173
+ letterSpacing: 0.8,
174
+ alignSelf: "center",
175
+ marginRight: 2,
176
+ },
177
+ netButton: {
178
+ flex: 1,
179
+ alignItems: "center",
180
+ paddingVertical: 6,
181
+ borderRadius: 6,
182
+ backgroundColor: macOSColors.background.card,
183
+ borderWidth: StyleSheet.hairlineWidth,
184
+ borderColor: macOSColors.border.default,
185
+ },
186
+ netButtonActive: {
187
+ backgroundColor: macOSColors.semantic.infoBackground,
188
+ borderColor: macOSColors.semantic.info,
189
+ },
190
+ netTextActive: {
191
+ color: macOSColors.semantic.info,
192
+ },
193
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ImagesModal — bottom-sheet wrapper rendered by FloatingDevTools when the
3
+ * Images tool is opened. List of captured images → tap for detail. Also
4
+ * surfaces the capture status so a mis-ordered install is self-diagnosing
5
+ * instead of silently empty.
6
+ */
7
+
8
+ import { useState } from "react";
9
+ import { FlatList, StyleSheet, Text, View } from "react-native";
10
+ import { JsModal, macOSColors } from "@buoy-gg/shared-ui";
11
+ import type { ModalState } from "@buoy-gg/shared-ui";
12
+ import { getCaptureStatus, installImageCapture } from "../capture/install";
13
+ import { getGlobalModes } from "../capture/overrides";
14
+ import { formatRecordMarkdown, formatRegistryMarkdown } from "../export";
15
+ import { useImageRecords } from "../hooks/useImageRecords";
16
+ import { clearRecords, getRecord } from "../store/imageEventStore";
17
+ import { ImageDetail } from "./ImageDetail";
18
+ import { ImageDetailFooter } from "./ImageDetailFooter";
19
+ import { ImageRow } from "./ImageRow";
20
+ import { ImagesHeader } from "./ImagesHeader";
21
+ import { ImagesListFooter } from "./ImagesListFooter";
22
+ import { InsightsBar } from "./InsightsBar";
23
+ import { computeInsights } from "../store/insights";
24
+ import type { ImageRecord } from "../types";
25
+
26
+ interface ImagesModalProps {
27
+ visible: boolean;
28
+ onClose: () => void;
29
+ onBack?: () => void;
30
+ onMinimize?: (modalState: ModalState) => void;
31
+ persistenceKey?: string;
32
+ }
33
+
34
+ function CaptureNotice() {
35
+ const status = getCaptureStatus();
36
+ const notices: string[] = [];
37
+ if (status.rnDecoratorTooLate) {
38
+ notices.push(
39
+ 'RN <Image> instrumentation missed the module-load race — add `import "@buoy-gg/images/register"` as the FIRST import of your entry file.'
40
+ );
41
+ }
42
+ if (!status.expoAvailable && !status.rnDecoratorActive && !status.rnDecoratorTooLate) {
43
+ notices.push("Waiting for images… render some <Image> components to populate the list.");
44
+ }
45
+ if (notices.length === 0) return null;
46
+ return (
47
+ <View style={styles.notice}>
48
+ {notices.map((n) => (
49
+ <Text key={n} style={styles.noticeText}>
50
+ {n}
51
+ </Text>
52
+ ))}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ function EmptyState() {
58
+ return (
59
+ <View style={styles.empty}>
60
+ <Text style={styles.emptyTitle}>No images captured yet</Text>
61
+ <Text style={styles.emptyText}>
62
+ Every RN {"<Image>"} and expo-image load is recorded here with cache
63
+ verdicts, timings, and oversize checks.
64
+ </Text>
65
+ </View>
66
+ );
67
+ }
68
+
69
+ export function ImagesModal({
70
+ visible,
71
+ onClose,
72
+ onBack,
73
+ onMinimize,
74
+ persistenceKey,
75
+ }: ImagesModalProps) {
76
+ // Safety net: ensures capture is live even without the early register
77
+ // import (expo-image capture is timing-immune, so this still gets most data).
78
+ installImageCapture();
79
+
80
+ const { records, stats } = useImageRecords();
81
+ const [selectedId, setSelectedId] = useState<number | null>(null);
82
+ const selected = selectedId != null ? getRecord(selectedId) : undefined;
83
+
84
+ // Stepping is relative to the live list (newest first): previous = newer.
85
+ const selectedIndex = selected
86
+ ? records.findIndex((r) => r.id === selected.id)
87
+ : -1;
88
+ const stepTo = (index: number) => {
89
+ const target = records[index];
90
+ if (target) setSelectedId(target.id);
91
+ };
92
+
93
+ // Mode-appropriate report: single record in detail, full registry (stats +
94
+ // insights + table) in the list. The shared ToolbarCopyButton resolves this
95
+ // lazily on press and handles clipboard + feedback + gating.
96
+ const getReport = () =>
97
+ selected
98
+ ? formatRecordMarkdown(selected)
99
+ : formatRegistryMarkdown(
100
+ records,
101
+ stats,
102
+ computeInsights(records),
103
+ getCaptureStatus(),
104
+ getGlobalModes()
105
+ );
106
+
107
+ return (
108
+ <JsModal
109
+ visible={visible}
110
+ onClose={onClose}
111
+ onMinimize={onMinimize}
112
+ persistenceKey={persistenceKey}
113
+ header={{
114
+ showToggleButton: true,
115
+ customContent: (
116
+ <ImagesHeader
117
+ stats={stats}
118
+ detailOpen={selected != null}
119
+ detailIndex={selectedIndex}
120
+ detailTotal={records.length}
121
+ onCloseDetail={() => setSelectedId(null)}
122
+ onClear={clearRecords}
123
+ getReport={getReport}
124
+ onBack={onBack}
125
+ />
126
+ ),
127
+ }}
128
+ enablePersistence
129
+ initialMode="bottomSheet"
130
+ disableScrollWrapper
131
+ >
132
+ {selected ? (
133
+ <View style={styles.detailContainer}>
134
+ <ImageDetail record={selected} />
135
+ <ImageDetailFooter
136
+ key={selected.id}
137
+ record={selected}
138
+ hasPrevious={selectedIndex > 0}
139
+ hasNext={selectedIndex >= 0 && selectedIndex < records.length - 1}
140
+ onPrevious={() => stepTo(selectedIndex - 1)}
141
+ onNext={() => stepTo(selectedIndex + 1)}
142
+ />
143
+ </View>
144
+ ) : (
145
+ <View style={styles.listContainer}>
146
+ <InsightsBar insights={computeInsights(records)} />
147
+ <CaptureNotice />
148
+ <FlatList
149
+ data={records}
150
+ keyExtractor={(item: ImageRecord) => String(item.id)}
151
+ renderItem={({ item }) => <ImageRow record={item} onPress={setSelectedId} />}
152
+ ListEmptyComponent={<EmptyState />}
153
+ />
154
+ <ImagesListFooter />
155
+ </View>
156
+ )}
157
+ </JsModal>
158
+ );
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ listContainer: {
163
+ flex: 1,
164
+ },
165
+ detailContainer: {
166
+ flex: 1,
167
+ },
168
+ notice: {
169
+ margin: 10,
170
+ padding: 10,
171
+ borderRadius: 8,
172
+ backgroundColor: macOSColors.semantic.warningBackground,
173
+ gap: 4,
174
+ },
175
+ noticeText: {
176
+ color: macOSColors.semantic.warning,
177
+ fontSize: 11,
178
+ },
179
+ empty: {
180
+ alignItems: "center",
181
+ padding: 32,
182
+ gap: 8,
183
+ },
184
+ emptyTitle: {
185
+ color: macOSColors.text.primary,
186
+ fontSize: 13,
187
+ fontWeight: "600",
188
+ },
189
+ emptyText: {
190
+ color: macOSColors.text.secondary,
191
+ fontSize: 11,
192
+ textAlign: "center",
193
+ lineHeight: 16,
194
+ },
195
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Mount once near the app root to start image capture immediately.
3
+ *
4
+ * NOTE: for full RN-core <Image> coverage prefer the side-effect import
5
+ * `import "@buoy-gg/images/register"` as the first import of your entry file —
6
+ * the RN decorator must be installed before React Native's Image module first
7
+ * evaluates. ImagesRoot alone still captures every expo-image instance
8
+ * (timing-immune) and reports whether the RN hook landed.
9
+ */
10
+
11
+ import { installImageCapture } from "../capture/install";
12
+
13
+ export function ImagesRoot(): null {
14
+ // Synchronous on first render — earlier than an effect, though still later
15
+ // than the register import. Idempotent.
16
+ installImageCapture();
17
+ return null;
18
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * InsightsBar — compact warning chips above the list when cross-record
3
+ * findings exist (duplicates, retry storms, queue saturation, missing alt,
4
+ * layout shifts). Renders nothing when all clear.
5
+ *
6
+ * A wrapping row (not a horizontal ScrollView — that under-measures its
7
+ * height inside the modal's flex column and clips the chips).
8
+ */
9
+
10
+ import { StyleSheet, Text, View } from "react-native";
11
+ import { macOSColors } from "@buoy-gg/shared-ui";
12
+ import { insightChips } from "../store/insights";
13
+ import type { ImageInsights } from "../types";
14
+
15
+ export function InsightsBar({ insights }: { insights: ImageInsights }) {
16
+ const chips = insightChips(insights);
17
+ if (chips.length === 0) return null;
18
+ return (
19
+ <View style={styles.bar}>
20
+ {chips.map((chip) => (
21
+ <View key={chip} style={styles.chip}>
22
+ <Text style={styles.chipText}>{chip}</Text>
23
+ </View>
24
+ ))}
25
+ </View>
26
+ );
27
+ }
28
+
29
+ const styles = StyleSheet.create({
30
+ bar: {
31
+ flexDirection: "row",
32
+ flexWrap: "wrap",
33
+ gap: 6,
34
+ paddingHorizontal: 10,
35
+ paddingVertical: 8,
36
+ borderBottomWidth: StyleSheet.hairlineWidth,
37
+ borderBottomColor: macOSColors.border.default,
38
+ },
39
+ chip: {
40
+ backgroundColor: macOSColors.semantic.warningBackground,
41
+ borderRadius: 999,
42
+ paddingHorizontal: 10,
43
+ paddingVertical: 5,
44
+ },
45
+ chipText: {
46
+ color: macOSColors.semantic.warning,
47
+ fontSize: 10,
48
+ fontWeight: "600",
49
+ lineHeight: 13,
50
+ },
51
+ });
package/src/export.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Copy-friendly markdown reports — paste-ready context for an issue, PR, or
3
+ * an AI chat. One for the whole registry (list header copy) and one for a
4
+ * single record (detail header copy).
5
+ */
6
+
7
+ import { PixelRatio } from "react-native";
8
+ import {
9
+ estDecodedBytes,
10
+ estWastedBytes,
11
+ neededPixels,
12
+ } from "./store/imageEventStore";
13
+ import { insightChips } from "./store/insights";
14
+ import { cacheBadge, formatBytes, formatMs, sizeVerdict, uriTail } from "./format";
15
+ import type { CaptureStatus, ImageInsights, ImageRecord, ImageStats } from "./types";
16
+
17
+ export function formatRecordMarkdown(record: ImageRecord): string {
18
+ const verdict = sizeVerdict(record);
19
+ const cache = cacheBadge(record);
20
+ const needed = neededPixels(record);
21
+ const decoded = estDecodedBytes(record);
22
+ const wasted = estWastedBytes(record);
23
+
24
+ const lines: string[] = [];
25
+ lines.push(`## Image report — ${uriTail(record.uri)}`);
26
+ lines.push("");
27
+ lines.push(`- URI: ${record.uri || "(none)"}`);
28
+ lines.push(`- Library: ${record.lib === "expo" ? "expo-image" : "RN <Image>"} · kind: ${record.sourceKind} · mounted: ${record.mounted ? "yes" : "no"}`);
29
+ lines.push(`- Status: ${record.status}${record.errorCode ? ` (HTTP ${record.errorCode})` : ""} · load cycles: ${record.loadCount}`);
30
+ lines.push(`- Cache: ${cache.label}${record.cacheVerdictSource ? ` (via ${record.cacheVerdictSource === "event" ? "expo onLoad" : "Image.queryCache"})` : ""}`);
31
+ lines.push(`- Load time: ${formatMs(record.durationMs)}${record.displayedAt && record.loadedAt ? ` · decode→display ${formatMs(Math.max(0, record.displayedAt - record.loadedAt))}` : ""}`);
32
+ if (record.intrinsic) {
33
+ lines.push(`- Decoded: ${record.intrinsic.width}×${record.intrinsic.height}px${record.mediaType ? ` (${record.mediaType})` : ""}${record.isAnimated ? " animated" : ""} ≈ ${formatBytes(decoded)} decoded memory`);
34
+ }
35
+ if (record.layout) {
36
+ lines.push(`- Displayed: ${Math.round(record.layout.width)}×${Math.round(record.layout.height)}dp @${PixelRatio.get()}x (needs ${needed ? `${needed.width}×${needed.height}px` : "?"})`);
37
+ }
38
+ if (verdict.label) {
39
+ lines.push(`- Size verdict: ${verdict.label}${wasted > 0 && needed ? ` — wasting ≈${formatBytes(wasted)}; serve ${needed.width}×${needed.height}` : ""}`);
40
+ }
41
+ if (record.progressSeen) {
42
+ lines.push(`- Network transfer: ${record.bytesTotal ? formatBytes(record.bytesTotal) : "observed"}`);
43
+ }
44
+ if (record.hasAltText === false) lines.push(`- Accessibility: MISSING alt/accessibilityLabel`);
45
+ if ((record.layoutShifts ?? 0) > 0) lines.push(`- Layout shifts after load: ${record.layoutShifts} (reserve explicit dimensions)`);
46
+ if (record.error) {
47
+ lines.push(`- Error: ${record.error}`);
48
+ if (record.errorHeaders) {
49
+ lines.push(`- Response headers: ${JSON.stringify(record.errorHeaders)}`);
50
+ }
51
+ }
52
+ if (record.overrideLabel) lines.push(`- Simulation active: ${record.overrideLabel}`);
53
+ return lines.join("\n");
54
+ }
55
+
56
+ export function formatRegistryMarkdown(
57
+ records: ImageRecord[],
58
+ stats: ImageStats,
59
+ insights: ImageInsights,
60
+ captureStatus: CaptureStatus,
61
+ modes: { network: string; blank: boolean }
62
+ ): string {
63
+ const lines: string[] = [];
64
+ lines.push(`## Buoy Images registry (${stats.total} records)`);
65
+ lines.push("");
66
+ lines.push(
67
+ `${stats.loaded} loaded · ${stats.errors} failed · ${stats.loading} in flight · ≈${formatBytes(stats.estDecodedBytes)} decoded` +
68
+ (stats.estWastedBytes > 0 ? ` · ${formatBytes(stats.estWastedBytes)} wasted on oversized sources` : "")
69
+ );
70
+ const chips = insightChips(insights);
71
+ if (chips.length > 0) lines.push(`Insights: ${chips.join(" · ")}`);
72
+ if (modes.network !== "normal" || modes.blank) {
73
+ lines.push(`Simulation active: network=${modes.network}${modes.blank ? ", blank images" : ""}`);
74
+ }
75
+ if (captureStatus.rnDecoratorTooLate) {
76
+ lines.push(`⚠ RN <Image> capture inactive (register import too late)`);
77
+ }
78
+ lines.push("");
79
+ lines.push("| image | lib | status | cache | ms | decoded px | shown dp | verdict | notes |");
80
+ lines.push("|---|---|---|---|---|---|---|---|---|");
81
+ for (const r of records) {
82
+ const verdict = sizeVerdict(r);
83
+ const cache = cacheBadge(r);
84
+ const dims = r.intrinsic ? `${r.intrinsic.width}×${r.intrinsic.height}` : "–";
85
+ const layout = r.layout ? `${Math.round(r.layout.width)}×${Math.round(r.layout.height)}` : "–";
86
+ const notes = [
87
+ r.error ? `error: ${r.error}` : null,
88
+ r.overrideLabel ? `sim: ${r.overrideLabel}` : null,
89
+ r.hasAltText === false ? "no alt" : null,
90
+ (r.layoutShifts ?? 0) > 0 ? `${r.layoutShifts} layout shifts` : null,
91
+ r.mounted ? null : "unmounted",
92
+ ]
93
+ .filter(Boolean)
94
+ .join("; ");
95
+ lines.push(
96
+ `| ${uriTail(r.uri)} | ${r.lib} | ${r.status} | ${cache.label} | ${r.durationMs != null ? Math.round(r.durationMs) : "–"} | ${dims} | ${layout} | ${verdict.label || "–"} | ${notes || "–"} |`
97
+ );
98
+ }
99
+ return lines.join("\n");
100
+ }
package/src/format.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Formatting + verdict helpers shared by list rows and the detail view.
3
+ */
4
+
5
+ import { macOSColors } from "@buoy-gg/shared-ui";
6
+ import { oversizeFactor } from "./store/imageEventStore";
7
+ import type { ImageRecord } from "./types";
8
+
9
+ export function formatBytes(bytes: number): string {
10
+ if (bytes <= 0) return "0 B";
11
+ if (bytes < 1024) return `${bytes} B`;
12
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
13
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
14
+ }
15
+
16
+ export function formatMs(ms: number | undefined): string {
17
+ if (ms == null) return "—";
18
+ if (ms < 1000) return `${Math.round(ms)}ms`;
19
+ return `${(ms / 1000).toFixed(2)}s`;
20
+ }
21
+
22
+ /** Last path segment of a URI, for compact row display. */
23
+ export function uriTail(uri: string): string {
24
+ if (!uri) return "(no source)";
25
+ // Metro dev-server asset URLs put the real path in ?unstable_path=…
26
+ const unstable = uri.match(/[?&]unstable_path=([^&?]+)/);
27
+ const effective = unstable ? decodeURIComponent(unstable[1]!) : uri;
28
+ const withoutQuery = effective.split("?")[0] ?? effective;
29
+ const segments = withoutQuery.split("/").filter(Boolean);
30
+ return segments[segments.length - 1] ?? effective;
31
+ }
32
+
33
+ export interface SizeVerdict {
34
+ label: string;
35
+ color: string;
36
+ /** Linear factor (2 = twice as wide/tall as needed). */
37
+ factor: number | null;
38
+ }
39
+
40
+ /**
41
+ * Fresco-style fit verdict: green within 10%, yellow within 50%, red beyond.
42
+ * Undersized sources (upscaled, likely blurry) get their own callout.
43
+ */
44
+ export function sizeVerdict(record: ImageRecord): SizeVerdict {
45
+ const factor = oversizeFactor(record);
46
+ if (factor == null) return { label: "", color: macOSColors.text.muted, factor };
47
+ if (factor < 0.9) {
48
+ return { label: `${factor.toFixed(1)}× upscaled`, color: macOSColors.semantic.info, factor };
49
+ }
50
+ if (factor <= 1.1) {
51
+ return { label: "right-sized", color: macOSColors.semantic.success, factor };
52
+ }
53
+ if (factor <= 1.5) {
54
+ return { label: `${factor.toFixed(1)}× oversized`, color: macOSColors.semantic.warning, factor };
55
+ }
56
+ return { label: `${factor.toFixed(1)}× oversized`, color: macOSColors.semantic.error, factor };
57
+ }
58
+
59
+ export function statusColor(record: ImageRecord): string {
60
+ switch (record.status) {
61
+ case "loaded":
62
+ return macOSColors.semantic.success;
63
+ case "error":
64
+ return macOSColors.semantic.error;
65
+ case "loading":
66
+ return macOSColors.semantic.warning;
67
+ default:
68
+ return macOSColors.text.muted;
69
+ }
70
+ }
71
+
72
+ /** Cache verdict badge text + tint (memory=green, disk=yellow, network=red). */
73
+ export function cacheBadge(record: ImageRecord): { label: string; color: string } {
74
+ const verdict = record.cacheVerdict;
75
+ if (verdict === "memory" || verdict === "disk/memory") {
76
+ return { label: verdict.toUpperCase(), color: macOSColors.semantic.success };
77
+ }
78
+ if (verdict === "disk") {
79
+ return { label: "DISK", color: macOSColors.semantic.warning };
80
+ }
81
+ if (verdict === "none" || record.progressSeen) {
82
+ return { label: "NETWORK", color: macOSColors.semantic.error };
83
+ }
84
+ return { label: "?", color: macOSColors.text.muted };
85
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * React bindings for the image event store.
3
+ */
4
+
5
+ import { useMemo, useSyncExternalStore } from "react";
6
+ import { computeStats, getSnapshot, subscribe } from "../store/imageEventStore";
7
+ import type { ImageRecord, ImageStats } from "../types";
8
+
9
+ export interface UseImageRecordsResult {
10
+ /** Newest first. */
11
+ records: ImageRecord[];
12
+ stats: ImageStats;
13
+ }
14
+
15
+ export function useImageRecords(): UseImageRecordsResult {
16
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
17
+ return useMemo(
18
+ () => ({
19
+ records: [...snapshot].reverse(),
20
+ stats: computeStats(snapshot),
21
+ }),
22
+ [snapshot]
23
+ );
24
+ }