framewatch-mcp-server 0.1.0 → 0.2.0

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 (133) hide show
  1. package/README.md +895 -15
  2. package/dist/constants.d.ts +274 -0
  3. package/dist/constants.js +279 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.d.ts +20 -4
  6. package/dist/engine/browser.js +26 -9
  7. package/dist/engine/browser.js.map +1 -1
  8. package/dist/engine/clicks.d.ts +221 -0
  9. package/dist/engine/clicks.js +801 -0
  10. package/dist/engine/clicks.js.map +1 -0
  11. package/dist/engine/forms.d.ts +137 -0
  12. package/dist/engine/forms.js +474 -0
  13. package/dist/engine/forms.js.map +1 -0
  14. package/dist/engine/hmr.d.ts +41 -0
  15. package/dist/engine/hmr.js +91 -0
  16. package/dist/engine/hmr.js.map +1 -0
  17. package/dist/engine/inspect.d.ts +31 -0
  18. package/dist/engine/inspect.js +383 -0
  19. package/dist/engine/inspect.js.map +1 -0
  20. package/dist/engine/interaction.d.ts +12 -7
  21. package/dist/engine/interaction.js +110 -18
  22. package/dist/engine/interaction.js.map +1 -1
  23. package/dist/engine/links.d.ts +134 -0
  24. package/dist/engine/links.js +384 -0
  25. package/dist/engine/links.js.map +1 -0
  26. package/dist/engine/mocks.d.ts +53 -0
  27. package/dist/engine/mocks.js +148 -0
  28. package/dist/engine/mocks.js.map +1 -0
  29. package/dist/engine/rtl.d.ts +129 -0
  30. package/dist/engine/rtl.js +540 -0
  31. package/dist/engine/rtl.js.map +1 -0
  32. package/dist/engine/seo.d.ts +189 -0
  33. package/dist/engine/seo.js +398 -0
  34. package/dist/engine/seo.js.map +1 -0
  35. package/dist/engine/snapshot.d.ts +29 -0
  36. package/dist/engine/snapshot.js +10 -0
  37. package/dist/engine/snapshot.js.map +1 -0
  38. package/dist/engine/vue.d.ts +54 -0
  39. package/dist/engine/vue.js +419 -0
  40. package/dist/engine/vue.js.map +1 -0
  41. package/dist/index.js +45 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/tools/accessibility.d.ts +4 -0
  44. package/dist/tools/accessibility.js +9 -2
  45. package/dist/tools/accessibility.js.map +1 -1
  46. package/dist/tools/api-mock.d.ts +405 -0
  47. package/dist/tools/api-mock.js +186 -0
  48. package/dist/tools/api-mock.js.map +1 -0
  49. package/dist/tools/capture.d.ts +90 -26
  50. package/dist/tools/capture.js +109 -58
  51. package/dist/tools/capture.js.map +1 -1
  52. package/dist/tools/compare.d.ts +4 -0
  53. package/dist/tools/compare.js +16 -5
  54. package/dist/tools/compare.js.map +1 -1
  55. package/dist/tools/dead-clicks.d.ts +128 -0
  56. package/dist/tools/dead-clicks.js +570 -0
  57. package/dist/tools/dead-clicks.js.map +1 -0
  58. package/dist/tools/form-test.d.ts +112 -0
  59. package/dist/tools/form-test.js +477 -0
  60. package/dist/tools/form-test.js.map +1 -0
  61. package/dist/tools/index.d.ts +17 -1
  62. package/dist/tools/index.js +45 -1
  63. package/dist/tools/index.js.map +1 -1
  64. package/dist/tools/inspect.d.ts +78 -0
  65. package/dist/tools/inspect.js +136 -0
  66. package/dist/tools/inspect.js.map +1 -0
  67. package/dist/tools/interact.d.ts +37 -18
  68. package/dist/tools/interact.js +113 -13
  69. package/dist/tools/interact.js.map +1 -1
  70. package/dist/tools/links.d.ts +129 -0
  71. package/dist/tools/links.js +640 -0
  72. package/dist/tools/links.js.map +1 -0
  73. package/dist/tools/responsive.d.ts +10 -6
  74. package/dist/tools/responsive.js +21 -4
  75. package/dist/tools/responsive.js.map +1 -1
  76. package/dist/tools/rtl.d.ts +241 -0
  77. package/dist/tools/rtl.js +410 -0
  78. package/dist/tools/rtl.js.map +1 -0
  79. package/dist/tools/save-auth.d.ts +263 -0
  80. package/dist/tools/save-auth.js +253 -0
  81. package/dist/tools/save-auth.js.map +1 -0
  82. package/dist/tools/screenshot.d.ts +4 -0
  83. package/dist/tools/screenshot.js +15 -4
  84. package/dist/tools/screenshot.js.map +1 -1
  85. package/dist/tools/seo.d.ts +113 -0
  86. package/dist/tools/seo.js +281 -0
  87. package/dist/tools/seo.js.map +1 -0
  88. package/dist/tools/snapshot.d.ts +122 -0
  89. package/dist/tools/snapshot.js +183 -0
  90. package/dist/tools/snapshot.js.map +1 -0
  91. package/dist/tools/wait-for.d.ts +107 -0
  92. package/dist/tools/wait-for.js +167 -0
  93. package/dist/tools/wait-for.js.map +1 -0
  94. package/dist/utils/arabic-text.d.ts +14 -0
  95. package/dist/utils/arabic-text.js +193 -0
  96. package/dist/utils/arabic-text.js.map +1 -0
  97. package/dist/utils/budget.d.ts +41 -0
  98. package/dist/utils/budget.js +182 -0
  99. package/dist/utils/budget.js.map +1 -0
  100. package/dist/utils/format.d.ts +11 -1
  101. package/dist/utils/format.js +27 -4
  102. package/dist/utils/format.js.map +1 -1
  103. package/dist/utils/highlight.d.ts +69 -0
  104. package/dist/utils/highlight.js +181 -0
  105. package/dist/utils/highlight.js.map +1 -0
  106. package/dist/utils/link-rules.d.ts +100 -0
  107. package/dist/utils/link-rules.js +284 -0
  108. package/dist/utils/link-rules.js.map +1 -0
  109. package/dist/utils/mock-rules.d.ts +144 -0
  110. package/dist/utils/mock-rules.js +224 -0
  111. package/dist/utils/mock-rules.js.map +1 -0
  112. package/dist/utils/rtl-rules.d.ts +142 -0
  113. package/dist/utils/rtl-rules.js +296 -0
  114. package/dist/utils/rtl-rules.js.map +1 -0
  115. package/dist/utils/seo-rules.d.ts +129 -0
  116. package/dist/utils/seo-rules.js +726 -0
  117. package/dist/utils/seo-rules.js.map +1 -0
  118. package/dist/utils/snapshot-rules.d.ts +33 -0
  119. package/dist/utils/snapshot-rules.js +111 -0
  120. package/dist/utils/snapshot-rules.js.map +1 -0
  121. package/dist/utils/storage-state.d.ts +76 -0
  122. package/dist/utils/storage-state.js +195 -0
  123. package/dist/utils/storage-state.js.map +1 -0
  124. package/dist/utils/style-rules.d.ts +107 -0
  125. package/dist/utils/style-rules.js +223 -0
  126. package/dist/utils/style-rules.js.map +1 -0
  127. package/dist/utils/test-data.d.ts +75 -0
  128. package/dist/utils/test-data.js +294 -0
  129. package/dist/utils/test-data.js.map +1 -0
  130. package/dist/utils/vue-rules.d.ts +72 -0
  131. package/dist/utils/vue-rules.js +108 -0
  132. package/dist/utils/vue-rules.js.map +1 -0
  133. package/package.json +6 -4
@@ -0,0 +1,193 @@
1
+ import { MAX_ARABIC_INJECTION_LENGTH } from "../constants.js";
2
+ /**
3
+ * Arabic stand-in text, matched to the length of what it replaces.
4
+ *
5
+ * `framewatch_rtl` can replace every visible string on a page with Arabic, so
6
+ * that the layout is tested against text that actually renders right-to-left
7
+ * rather than against English sitting inside an RTL container. English in an
8
+ * RTL page is a bidirectional edge case of its own and hides the ordinary
9
+ * bugs: the Latin run keeps its own direction, so a container that never
10
+ * mirrored still *looks* plausible.
11
+ *
12
+ * The length matching is the part that matters. If "Add to cart" became a
13
+ * three-character word, the button would shrink and any overflow the tool
14
+ * reported afterwards would be an artifact of the substitution rather than a
15
+ * property of the page — and if it became a paragraph, every page would
16
+ * overflow. So each replacement is built to within a few characters of the
17
+ * string it replaces, and the report's overflow findings mean what they say.
18
+ *
19
+ * Nothing here touches a browser: it is a string in, a string out, so every
20
+ * case is unit-testable without launching Chromium.
21
+ */
22
+ /**
23
+ * The vocabulary, grouped by length so a replacement can be assembled to fit.
24
+ *
25
+ * Real words, not lorem ipsum: an Arabic reader glancing at a screenshot
26
+ * should see a plausible interface, and the shaping/ligature behaviour of real
27
+ * words is what stresses the text rendering. These are the words a UI is
28
+ * actually made of — actions, labels, navigation.
29
+ */
30
+ const WORDS = [
31
+ // 2–4 characters
32
+ "نعم",
33
+ "لا",
34
+ "حسنا",
35
+ "بحث",
36
+ "إغلاق",
37
+ "حفظ",
38
+ "فتح",
39
+ "التالي",
40
+ "السابق",
41
+ "رجوع",
42
+ // 5–8
43
+ "تسجيل",
44
+ "الدخول",
45
+ "الخروج",
46
+ "الرئيسية",
47
+ "المنتجات",
48
+ "الخدمات",
49
+ "الطلبات",
50
+ "الحساب",
51
+ "الإعدادات",
52
+ "المفضلة",
53
+ // 9+
54
+ "أضف إلى السلة",
55
+ "إتمام الشراء",
56
+ "تواصل معنا",
57
+ "من نحن",
58
+ "الشروط والأحكام",
59
+ "سياسة الخصوصية",
60
+ "اشترك في النشرة",
61
+ "عرض جميع النتائج",
62
+ ];
63
+ /** A sentence's worth, for replacing paragraphs rather than labels. */
64
+ const SENTENCES = [
65
+ "مرحبا بك في متجرنا الإلكتروني",
66
+ "نقدم لك أفضل المنتجات بأسعار تنافسية",
67
+ "يمكنك تصفح الأقسام والاطلاع على العروض",
68
+ "خدمة التوصيل متاحة إلى جميع المناطق",
69
+ "فريق الدعم جاهز للإجابة عن أسئلتك",
70
+ ];
71
+ /**
72
+ * Arabic-Indic digits, so a number reads as Arabic too.
73
+ *
74
+ * Kept as a separate mapping rather than being replaced by words: a price, a
75
+ * quantity or a date is still a number in an Arabic interface, and turning
76
+ * "24" into a word would change what the element is rather than what language
77
+ * it is in — which is the opposite of what this substitution is for.
78
+ */
79
+ const ARABIC_DIGITS = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"];
80
+ /**
81
+ * Build Arabic text about as long as `original`.
82
+ *
83
+ * `seed` makes the choice deterministic and varied: the same string in the
84
+ * same place gets the same replacement on every run (so two runs of the tool
85
+ * are comparable, and the LTR and RTL passes agree), while two different
86
+ * elements of the same length do not all get the same word.
87
+ *
88
+ * A string that is only digits and punctuation keeps its shape and is merely
89
+ * transliterated — see `ARABIC_DIGITS`.
90
+ */
91
+ export function arabicFor(original, seed = 0) {
92
+ const source = String(original ?? "");
93
+ const trimmed = source.trim();
94
+ if (trimmed === "")
95
+ return source;
96
+ // Numbers, prices, times: keep the shape, change the digits. Replacing
97
+ // "١٢:٣٠" with a word would be a different element, not a translated one.
98
+ if (!/\p{L}/u.test(trimmed))
99
+ return toArabicDigits(source);
100
+ const target = Math.min(trimmed.length, MAX_ARABIC_INJECTION_LENGTH);
101
+ const built = target > 24 ? buildSentence(target, seed) : buildPhrase(target, seed);
102
+ // Leading/trailing whitespace is layout — a label may rely on it — so the
103
+ // original's edges are preserved and only the text between them replaced.
104
+ const leading = source.slice(0, source.length - source.trimStart().length);
105
+ const trailing = source.slice(source.trimEnd().length);
106
+ return `${leading}${built}${trailing}`;
107
+ }
108
+ /**
109
+ * Words joined until they are about `target` characters long.
110
+ *
111
+ * Each step picks the word that gets closest to the remaining space rather
112
+ * than taking the first candidate and giving up when it does not fit. Giving
113
+ * up is the trap: a 19-character string whose first word happens to be 10
114
+ * characters would stop there, nine short, and every button on the page would
115
+ * come back narrower than it really is — which is exactly the measurement
116
+ * error this whole module exists to avoid.
117
+ */
118
+ function buildPhrase(target, seed) {
119
+ // A starting word no longer than the target, so a 3-character label does
120
+ // not become a 13-character phrase.
121
+ const candidates = WORDS.filter((word) => word.length <= Math.max(3, target));
122
+ const pool = candidates.length > 0 ? candidates : WORDS;
123
+ let out = pool[hash(seed) % pool.length];
124
+ let step = 1;
125
+ while (out.length < target - 1 && step <= 40) {
126
+ const remaining = target - out.length - 1;
127
+ const next = bestFit(pool, remaining, seed + step);
128
+ // Nothing left in the vocabulary fits the gap: stop rather than overshoot.
129
+ if (next === null)
130
+ break;
131
+ out = `${out} ${next}`;
132
+ step += 1;
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * The word closest to `remaining` characters without overshooting by more
138
+ * than a hair, chosen deterministically among equally good candidates.
139
+ */
140
+ function bestFit(pool, remaining, seed) {
141
+ if (remaining <= 0)
142
+ return null;
143
+ // A slight overshoot is better than a large undershoot: landing two
144
+ // characters long is invisible, landing nine short is a different layout.
145
+ const fitting = pool.filter((word) => word.length <= remaining + 2);
146
+ if (fitting.length === 0)
147
+ return null;
148
+ let best = fitting[0].length;
149
+ for (const word of fitting) {
150
+ if (Math.abs(word.length - remaining) < Math.abs(best - remaining))
151
+ best = word.length;
152
+ }
153
+ const closest = fitting.filter((word) => word.length === best);
154
+ return closest[hash(seed) % closest.length];
155
+ }
156
+ /** Sentences joined until they are about `target` characters long. */
157
+ function buildSentence(target, seed) {
158
+ let out = SENTENCES[hash(seed) % SENTENCES.length];
159
+ let step = 1;
160
+ while (out.length < target - 4) {
161
+ const next = SENTENCES[hash(seed + step) % SENTENCES.length];
162
+ if (out.length + 2 + next.length > target + 8) {
163
+ // No whole sentence fits the remainder; pad with words instead of
164
+ // overshooting, so a long paragraph still lands near its original length.
165
+ const filler = buildPhrase(target - out.length - 1, seed + step);
166
+ out = `${out}، ${filler}`;
167
+ break;
168
+ }
169
+ out = `${out}، ${next}`;
170
+ step += 1;
171
+ if (step > 40)
172
+ break;
173
+ }
174
+ return out;
175
+ }
176
+ /** Latin digits to Arabic-Indic, leaving everything else alone. */
177
+ export function toArabicDigits(value) {
178
+ return String(value ?? "").replace(/[0-9]/g, (digit) => ARABIC_DIGITS[Number(digit)]);
179
+ }
180
+ /**
181
+ * A small deterministic hash.
182
+ *
183
+ * Not for security — for repeatability. `Math.random()` would make two runs of
184
+ * the same audit disagree about which element overflowed, which is exactly the
185
+ * kind of flakiness that makes a testing tool untrusted.
186
+ */
187
+ function hash(seed) {
188
+ let value = (seed | 0) + 0x9e3779b9;
189
+ value = Math.imul(value ^ (value >>> 16), 0x21f0aaad);
190
+ value = Math.imul(value ^ (value >>> 15), 0x735a2d97);
191
+ return Math.abs(value ^ (value >>> 15));
192
+ }
193
+ //# sourceMappingURL=arabic-text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arabic-text.js","sourceRoot":"","sources":["../../src/utils/arabic-text.ts"],"names":[],"mappings":"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","sourcesContent":["import { MAX_ARABIC_INJECTION_LENGTH } from \"../constants.js\";\n\n/**\n * Arabic stand-in text, matched to the length of what it replaces.\n *\n * `framewatch_rtl` can replace every visible string on a page with Arabic, so\n * that the layout is tested against text that actually renders right-to-left\n * rather than against English sitting inside an RTL container. English in an\n * RTL page is a bidirectional edge case of its own and hides the ordinary\n * bugs: the Latin run keeps its own direction, so a container that never\n * mirrored still *looks* plausible.\n *\n * The length matching is the part that matters. If \"Add to cart\" became a\n * three-character word, the button would shrink and any overflow the tool\n * reported afterwards would be an artifact of the substitution rather than a\n * property of the page — and if it became a paragraph, every page would\n * overflow. So each replacement is built to within a few characters of the\n * string it replaces, and the report's overflow findings mean what they say.\n *\n * Nothing here touches a browser: it is a string in, a string out, so every\n * case is unit-testable without launching Chromium.\n */\n\n/**\n * The vocabulary, grouped by length so a replacement can be assembled to fit.\n *\n * Real words, not lorem ipsum: an Arabic reader glancing at a screenshot\n * should see a plausible interface, and the shaping/ligature behaviour of real\n * words is what stresses the text rendering. These are the words a UI is\n * actually made of — actions, labels, navigation.\n */\nconst WORDS: readonly string[] = [\n // 2–4 characters\n \"نعم\",\n \"لا\",\n \"حسنا\",\n \"بحث\",\n \"إغلاق\",\n \"حفظ\",\n \"فتح\",\n \"التالي\",\n \"السابق\",\n \"رجوع\",\n // 5–8\n \"تسجيل\",\n \"الدخول\",\n \"الخروج\",\n \"الرئيسية\",\n \"المنتجات\",\n \"الخدمات\",\n \"الطلبات\",\n \"الحساب\",\n \"الإعدادات\",\n \"المفضلة\",\n // 9+\n \"أضف إلى السلة\",\n \"إتمام الشراء\",\n \"تواصل معنا\",\n \"من نحن\",\n \"الشروط والأحكام\",\n \"سياسة الخصوصية\",\n \"اشترك في النشرة\",\n \"عرض جميع النتائج\",\n];\n\n/** A sentence's worth, for replacing paragraphs rather than labels. */\nconst SENTENCES: readonly string[] = [\n \"مرحبا بك في متجرنا الإلكتروني\",\n \"نقدم لك أفضل المنتجات بأسعار تنافسية\",\n \"يمكنك تصفح الأقسام والاطلاع على العروض\",\n \"خدمة التوصيل متاحة إلى جميع المناطق\",\n \"فريق الدعم جاهز للإجابة عن أسئلتك\",\n];\n\n/**\n * Arabic-Indic digits, so a number reads as Arabic too.\n *\n * Kept as a separate mapping rather than being replaced by words: a price, a\n * quantity or a date is still a number in an Arabic interface, and turning\n * \"24\" into a word would change what the element is rather than what language\n * it is in — which is the opposite of what this substitution is for.\n */\nconst ARABIC_DIGITS = [\"٠\", \"١\", \"٢\", \"٣\", \"٤\", \"٥\", \"٦\", \"٧\", \"٨\", \"٩\"] as const;\n\n/**\n * Build Arabic text about as long as `original`.\n *\n * `seed` makes the choice deterministic and varied: the same string in the\n * same place gets the same replacement on every run (so two runs of the tool\n * are comparable, and the LTR and RTL passes agree), while two different\n * elements of the same length do not all get the same word.\n *\n * A string that is only digits and punctuation keeps its shape and is merely\n * transliterated — see `ARABIC_DIGITS`.\n */\nexport function arabicFor(original: string, seed = 0): string {\n const source = String(original ?? \"\");\n const trimmed = source.trim();\n if (trimmed === \"\") return source;\n\n // Numbers, prices, times: keep the shape, change the digits. Replacing\n // \"١٢:٣٠\" with a word would be a different element, not a translated one.\n if (!/\\p{L}/u.test(trimmed)) return toArabicDigits(source);\n\n const target = Math.min(trimmed.length, MAX_ARABIC_INJECTION_LENGTH);\n const built = target > 24 ? buildSentence(target, seed) : buildPhrase(target, seed);\n\n // Leading/trailing whitespace is layout — a label may rely on it — so the\n // original's edges are preserved and only the text between them replaced.\n const leading = source.slice(0, source.length - source.trimStart().length);\n const trailing = source.slice(source.trimEnd().length);\n return `${leading}${built}${trailing}`;\n}\n\n/**\n * Words joined until they are about `target` characters long.\n *\n * Each step picks the word that gets closest to the remaining space rather\n * than taking the first candidate and giving up when it does not fit. Giving\n * up is the trap: a 19-character string whose first word happens to be 10\n * characters would stop there, nine short, and every button on the page would\n * come back narrower than it really is — which is exactly the measurement\n * error this whole module exists to avoid.\n */\nfunction buildPhrase(target: number, seed: number): string {\n // A starting word no longer than the target, so a 3-character label does\n // not become a 13-character phrase.\n const candidates = WORDS.filter((word) => word.length <= Math.max(3, target));\n const pool = candidates.length > 0 ? candidates : WORDS;\n\n let out = pool[hash(seed) % pool.length];\n let step = 1;\n while (out.length < target - 1 && step <= 40) {\n const remaining = target - out.length - 1;\n const next = bestFit(pool, remaining, seed + step);\n // Nothing left in the vocabulary fits the gap: stop rather than overshoot.\n if (next === null) break;\n out = `${out} ${next}`;\n step += 1;\n }\n return out;\n}\n\n/**\n * The word closest to `remaining` characters without overshooting by more\n * than a hair, chosen deterministically among equally good candidates.\n */\nfunction bestFit(pool: readonly string[], remaining: number, seed: number): string | null {\n if (remaining <= 0) return null;\n // A slight overshoot is better than a large undershoot: landing two\n // characters long is invisible, landing nine short is a different layout.\n const fitting = pool.filter((word) => word.length <= remaining + 2);\n if (fitting.length === 0) return null;\n\n let best = fitting[0].length;\n for (const word of fitting) {\n if (Math.abs(word.length - remaining) < Math.abs(best - remaining)) best = word.length;\n }\n const closest = fitting.filter((word) => word.length === best);\n return closest[hash(seed) % closest.length];\n}\n\n/** Sentences joined until they are about `target` characters long. */\nfunction buildSentence(target: number, seed: number): string {\n let out = SENTENCES[hash(seed) % SENTENCES.length];\n let step = 1;\n while (out.length < target - 4) {\n const next = SENTENCES[hash(seed + step) % SENTENCES.length];\n if (out.length + 2 + next.length > target + 8) {\n // No whole sentence fits the remainder; pad with words instead of\n // overshooting, so a long paragraph still lands near its original length.\n const filler = buildPhrase(target - out.length - 1, seed + step);\n out = `${out}، ${filler}`;\n break;\n }\n out = `${out}، ${next}`;\n step += 1;\n if (step > 40) break;\n }\n return out;\n}\n\n/** Latin digits to Arabic-Indic, leaving everything else alone. */\nexport function toArabicDigits(value: string): string {\n return String(value ?? \"\").replace(/[0-9]/g, (digit) => ARABIC_DIGITS[Number(digit)]);\n}\n\n/**\n * A small deterministic hash.\n *\n * Not for security — for repeatability. `Math.random()` would make two runs of\n * the same audit disagree about which element overflowed, which is exactly the\n * kind of flakiness that makes a testing tool untrusted.\n */\nfunction hash(seed: number): number {\n let value = (seed | 0) + 0x9e3779b9;\n value = Math.imul(value ^ (value >>> 16), 0x21f0aaad);\n value = Math.imul(value ^ (value >>> 15), 0x735a2d97);\n return Math.abs(value ^ (value >>> 15));\n}\n"]}
@@ -0,0 +1,41 @@
1
+ import type { CallToolResult, ImageContent } from "@modelcontextprotocol/sdk/types.js";
2
+ /**
3
+ * The image budget.
4
+ *
5
+ * Claude Code caps one MCP tool result at `MAX_MCP_OUTPUT_TOKENS` (25,000 by
6
+ * default) and counts base64 image data toward it. A result over the cap is
7
+ * written to a file and replaced with a reference, so the model sees no
8
+ * images at all — and a single screenshot of a real page is 180 KB of PNG,
9
+ * which is over on its own. Every result therefore passes through here on
10
+ * its way out: each image is encoded the cheapest way that is still faithful,
11
+ * and if the result still does not fit it is degraded in a fixed order —
12
+ * crops first, then frame size, then frames from the middle — and says so.
13
+ * Fewer images that arrive beat all of them lost.
14
+ */
15
+ export interface ImageMark {
16
+ /** A `crop` duplicates part of a frame and is the first thing to go. */
17
+ role?: "frame" | "crop";
18
+ /** Never dropped while any unmarked frame remains (first/last/interaction frames). */
19
+ keep?: boolean;
20
+ }
21
+ /** Stamp an image block with its role, for `fitToBudget` to read (and strip). */
22
+ export declare function markImage(block: ImageContent, mark: ImageMark): ImageContent;
23
+ /** The cap in force: what the shell that started Claude Code exported, or the default. */
24
+ export declare function capFromEnv(env?: NodeJS.ProcessEnv): number;
25
+ /** Base64 characters of image data a result may carry once its text is paid for. */
26
+ export declare function imageBudgetChars(capTokens: number, textChars: number): number;
27
+ export interface BudgetStats {
28
+ total: number;
29
+ kept: number;
30
+ crops_dropped: number;
31
+ frames_dropped: number;
32
+ /** Width the surviving frames were shrunk to, or undefined when untouched. */
33
+ width?: number;
34
+ cap_tokens: number;
35
+ }
36
+ export declare function describeBudget(stats: BudgetStats): string;
37
+ export interface FitOptions {
38
+ /** Override the cap (tests); defaults to `capFromEnv()`. */
39
+ cap_tokens?: number;
40
+ }
41
+ export declare function fitToBudget(result: CallToolResult, options?: FitOptions): Promise<CallToolResult>;
@@ -0,0 +1,182 @@
1
+ import sharp from "sharp";
2
+ import { BUDGET_CHARS_PER_TOKEN, BUDGET_JPEG_QUALITY, BUDGET_LAST_RESORT_WIDTHS, BUDGET_MARGIN_TOKENS, BUDGET_SUGGESTED_TOKENS, BUDGET_WIDTHS, DEFAULT_MCP_OUTPUT_TOKENS, } from "../constants.js";
3
+ const META_KEY = "framewatch";
4
+ /** Stamp an image block with its role, for `fitToBudget` to read (and strip). */
5
+ export function markImage(block, mark) {
6
+ return { ...block, _meta: { ...(block._meta ?? {}), [META_KEY]: mark } };
7
+ }
8
+ /** The cap in force: what the shell that started Claude Code exported, or the default. */
9
+ export function capFromEnv(env = process.env) {
10
+ const raw = Number(env.MAX_MCP_OUTPUT_TOKENS);
11
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MCP_OUTPUT_TOKENS;
12
+ }
13
+ /** Base64 characters of image data a result may carry once its text is paid for. */
14
+ export function imageBudgetChars(capTokens, textChars) {
15
+ const textTokens = Math.ceil(textChars / BUDGET_CHARS_PER_TOKEN);
16
+ return Math.max(0, (capTokens - textTokens - BUDGET_MARGIN_TOKENS) * BUDGET_CHARS_PER_TOKEN);
17
+ }
18
+ export function describeBudget(stats) {
19
+ const cuts = [];
20
+ if (stats.crops_dropped > 0)
21
+ cuts.push(`${stats.crops_dropped} crop${stats.crops_dropped === 1 ? "" : "s"} dropped`);
22
+ if (stats.frames_dropped > 0)
23
+ cuts.push(`${stats.frames_dropped} frame${stats.frames_dropped === 1 ? "" : "s"} dropped`);
24
+ if (stats.width !== undefined)
25
+ cuts.push(`frames at ${stats.width}px`);
26
+ const kb = Math.round((imageBudgetChars(stats.cap_tokens, 0) * 0.75) / 1024);
27
+ return (`Image budget: ${stats.kept} of ${stats.total} images kept — ${cuts.join(", ")} — to fit MAX_MCP_OUTPUT_TOKENS=${stats.cap_tokens} ` +
28
+ `(~${kb} KB of images per result). Set MAX_MCP_OUTPUT_TOKENS=${BUDGET_SUGGESTED_TOKENS} in the shell that starts Claude Code for full results.`);
29
+ }
30
+ const OMITTED = "(image omitted — see the Image budget line)";
31
+ export async function fitToBudget(result, options = {}) {
32
+ const content = result.content;
33
+ const slots = [];
34
+ for (let i = 0; i < content.length; i++) {
35
+ const block = content[i];
36
+ if (block.type !== "image")
37
+ continue;
38
+ const mark = ((block._meta ?? {})[META_KEY] ?? {});
39
+ const source = Buffer.from(block.data, "base64");
40
+ let width = 0;
41
+ try {
42
+ width = (await sharp(source).metadata()).width ?? 0;
43
+ }
44
+ catch {
45
+ // Not an image sharp can read: leave the block exactly as it is.
46
+ continue;
47
+ }
48
+ slots.push({
49
+ index: i,
50
+ original: { data: block.data, mimeType: block.mimeType },
51
+ role: mark.role === "crop" ? "crop" : "frame",
52
+ keep: mark.keep === true,
53
+ source,
54
+ width,
55
+ encoded: new Map(),
56
+ dropped: false,
57
+ at: width,
58
+ });
59
+ }
60
+ if (slots.length === 0)
61
+ return result;
62
+ const cap = options.cap_tokens ?? capFromEnv();
63
+ const textChars = content.reduce((n, c) => (c.type === "text" ? n + c.text.length : n), 0);
64
+ // Leave room for the note this may have to add.
65
+ const budget = imageBudgetChars(cap, textChars + 400);
66
+ const encode = async (slot, width) => {
67
+ const target = Math.min(width, slot.width);
68
+ const cached = slot.encoded.get(target);
69
+ if (cached)
70
+ return cached;
71
+ let image = sharp(slot.source);
72
+ if (target < slot.width)
73
+ image = image.resize(target, null, { fit: "inside", withoutEnlargement: true });
74
+ const [png, jpeg] = await Promise.all([
75
+ image.clone().png({ quality: 80, effort: 1, compressionLevel: 6 }).toBuffer(),
76
+ image.clone().jpeg({ quality: BUDGET_JPEG_QUALITY, mozjpeg: true }).toBuffer(),
77
+ ]);
78
+ let best = jpeg.length < png.length
79
+ ? { data: jpeg.toString("base64"), mimeType: "image/jpeg" }
80
+ : { data: png.toString("base64"), mimeType: "image/png" };
81
+ // At its own size, what the tool produced is a candidate too — and when
82
+ // it is already the smallest, it goes out untouched.
83
+ if (target === slot.width && slot.original.data.length <= best.data.length)
84
+ best = slot.original;
85
+ slot.encoded.set(target, best);
86
+ return best;
87
+ };
88
+ const total = async () => {
89
+ let sum = 0;
90
+ for (const slot of slots) {
91
+ if (slot.dropped)
92
+ continue;
93
+ sum += (await encode(slot, slot.at)).data.length;
94
+ }
95
+ return sum;
96
+ };
97
+ const fits = async () => (await total()) <= budget;
98
+ let cropsDropped = 0;
99
+ let framesDropped = 0;
100
+ let width;
101
+ if (!(await fits())) {
102
+ // 1. Crops duplicate what a frame already shows.
103
+ for (const slot of slots) {
104
+ if (slot.role === "crop" && !slot.dropped) {
105
+ slot.dropped = true;
106
+ cropsDropped++;
107
+ }
108
+ }
109
+ }
110
+ // 2. Smaller frames, all of them together, so they still compare.
111
+ for (const step of BUDGET_WIDTHS) {
112
+ if (await fits())
113
+ break;
114
+ for (const slot of slots)
115
+ if (!slot.dropped)
116
+ slot.at = Math.min(slot.at, step);
117
+ width = step;
118
+ }
119
+ // 3. Frames from the middle outwards: unprotected first, then protected,
120
+ // never the last one standing.
121
+ if (!(await fits())) {
122
+ const live = () => slots.filter((s) => !s.dropped);
123
+ const order = (protectedToo) => {
124
+ const alive = live();
125
+ const first = alive[0];
126
+ const last = alive[alive.length - 1];
127
+ const candidates = alive.filter((s) => s !== first && s !== last && (protectedToo || !s.keep));
128
+ // Middle outwards: sort by distance from the centre, farthest last.
129
+ const centre = (alive.length - 1) / 2;
130
+ return candidates.sort((a, b) => Math.abs(alive.indexOf(a) - centre) - Math.abs(alive.indexOf(b) - centre));
131
+ };
132
+ for (const protectedToo of [false, true]) {
133
+ for (const slot of order(protectedToo)) {
134
+ if (await fits())
135
+ break;
136
+ slot.dropped = true;
137
+ framesDropped++;
138
+ }
139
+ }
140
+ // Down to the first and the last; then the first goes too.
141
+ while (!(await fits()) && live().length > 1) {
142
+ live()[0].dropped = true;
143
+ framesDropped++;
144
+ }
145
+ }
146
+ // 4. One image left and still over: shrink it as far as it takes.
147
+ for (const step of BUDGET_LAST_RESORT_WIDTHS) {
148
+ if (await fits())
149
+ break;
150
+ for (const slot of slots)
151
+ if (!slot.dropped)
152
+ slot.at = Math.min(slot.at, step);
153
+ width = step;
154
+ }
155
+ const degraded = cropsDropped > 0 || framesDropped > 0 || width !== undefined;
156
+ const out = [];
157
+ for (let i = 0; i < content.length; i++) {
158
+ const slot = slots.find((s) => s.index === i);
159
+ if (!slot) {
160
+ out.push(content[i]);
161
+ continue;
162
+ }
163
+ if (slot.dropped) {
164
+ out.push({ type: "text", text: OMITTED });
165
+ continue;
166
+ }
167
+ const best = await encode(slot, slot.at);
168
+ const block = content[i];
169
+ const { _meta, ...rest } = block;
170
+ const meta = _meta ? Object.fromEntries(Object.entries(_meta).filter(([key]) => key !== META_KEY)) : undefined;
171
+ out.push({ ...rest, data: best.data, mimeType: best.mimeType, ...(meta && Object.keys(meta).length > 0 ? { _meta: meta } : {}) });
172
+ }
173
+ if (degraded) {
174
+ const kept = slots.filter((s) => !s.dropped).length;
175
+ out.push({
176
+ type: "text",
177
+ text: describeBudget({ total: slots.length, kept, crops_dropped: cropsDropped, frames_dropped: framesDropped, width, cap_tokens: cap }),
178
+ });
179
+ }
180
+ return { ...result, content: out };
181
+ }
182
+ //# sourceMappingURL=budget.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.js","sourceRoot":"","sources":["../../src/utils/budget.ts"],"names":[],"mappings":"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","sourcesContent":["import sharp from \"sharp\";\nimport type { CallToolResult, ImageContent } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n BUDGET_CHARS_PER_TOKEN,\n BUDGET_JPEG_QUALITY,\n BUDGET_LAST_RESORT_WIDTHS,\n BUDGET_MARGIN_TOKENS,\n BUDGET_SUGGESTED_TOKENS,\n BUDGET_WIDTHS,\n DEFAULT_MCP_OUTPUT_TOKENS,\n} from \"../constants.js\";\n\n/**\n * The image budget.\n *\n * Claude Code caps one MCP tool result at `MAX_MCP_OUTPUT_TOKENS` (25,000 by\n * default) and counts base64 image data toward it. A result over the cap is\n * written to a file and replaced with a reference, so the model sees no\n * images at all — and a single screenshot of a real page is 180 KB of PNG,\n * which is over on its own. Every result therefore passes through here on\n * its way out: each image is encoded the cheapest way that is still faithful,\n * and if the result still does not fit it is degraded in a fixed order —\n * crops first, then frame size, then frames from the middle — and says so.\n * Fewer images that arrive beat all of them lost.\n */\n\nexport interface ImageMark {\n /** A `crop` duplicates part of a frame and is the first thing to go. */\n role?: \"frame\" | \"crop\";\n /** Never dropped while any unmarked frame remains (first/last/interaction frames). */\n keep?: boolean;\n}\n\nconst META_KEY = \"framewatch\";\n\n/** Stamp an image block with its role, for `fitToBudget` to read (and strip). */\nexport function markImage(block: ImageContent, mark: ImageMark): ImageContent {\n return { ...block, _meta: { ...(block._meta ?? {}), [META_KEY]: mark } };\n}\n\n/** The cap in force: what the shell that started Claude Code exported, or the default. */\nexport function capFromEnv(env: NodeJS.ProcessEnv = process.env): number {\n const raw = Number(env.MAX_MCP_OUTPUT_TOKENS);\n return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MCP_OUTPUT_TOKENS;\n}\n\n/** Base64 characters of image data a result may carry once its text is paid for. */\nexport function imageBudgetChars(capTokens: number, textChars: number): number {\n const textTokens = Math.ceil(textChars / BUDGET_CHARS_PER_TOKEN);\n return Math.max(0, (capTokens - textTokens - BUDGET_MARGIN_TOKENS) * BUDGET_CHARS_PER_TOKEN);\n}\n\nexport interface BudgetStats {\n total: number;\n kept: number;\n crops_dropped: number;\n frames_dropped: number;\n /** Width the surviving frames were shrunk to, or undefined when untouched. */\n width?: number;\n cap_tokens: number;\n}\n\nexport function describeBudget(stats: BudgetStats): string {\n const cuts: string[] = [];\n if (stats.crops_dropped > 0) cuts.push(`${stats.crops_dropped} crop${stats.crops_dropped === 1 ? \"\" : \"s\"} dropped`);\n if (stats.frames_dropped > 0) cuts.push(`${stats.frames_dropped} frame${stats.frames_dropped === 1 ? \"\" : \"s\"} dropped`);\n if (stats.width !== undefined) cuts.push(`frames at ${stats.width}px`);\n const kb = Math.round((imageBudgetChars(stats.cap_tokens, 0) * 0.75) / 1024);\n return (\n `Image budget: ${stats.kept} of ${stats.total} images kept — ${cuts.join(\", \")} — to fit MAX_MCP_OUTPUT_TOKENS=${stats.cap_tokens} ` +\n `(~${kb} KB of images per result). Set MAX_MCP_OUTPUT_TOKENS=${BUDGET_SUGGESTED_TOKENS} in the shell that starts Claude Code for full results.`\n );\n}\n\nexport interface FitOptions {\n /** Override the cap (tests); defaults to `capFromEnv()`. */\n cap_tokens?: number;\n}\n\ninterface Candidate {\n data: string;\n mimeType: string;\n}\n\ninterface Slot {\n /** Index into `result.content`. */\n index: number;\n /** The block as the tool produced it — kept verbatim when nothing beats it. */\n original: Candidate;\n role: \"frame\" | \"crop\";\n keep: boolean;\n source: Buffer;\n width: number;\n /** Encodings by width, filled lazily. */\n encoded: Map<number, Candidate>;\n dropped: boolean;\n /** Width in force for this slot (its own width when untouched). */\n at: number;\n}\n\nconst OMITTED = \"(image omitted — see the Image budget line)\";\n\nexport async function fitToBudget(result: CallToolResult, options: FitOptions = {}): Promise<CallToolResult> {\n const content = result.content;\n const slots: Slot[] = [];\n for (let i = 0; i < content.length; i++) {\n const block = content[i];\n if (block.type !== \"image\") continue;\n const mark = ((block._meta ?? {})[META_KEY] ?? {}) as ImageMark;\n const source = Buffer.from(block.data, \"base64\");\n let width = 0;\n try {\n width = (await sharp(source).metadata()).width ?? 0;\n } catch {\n // Not an image sharp can read: leave the block exactly as it is.\n continue;\n }\n slots.push({\n index: i,\n original: { data: block.data, mimeType: block.mimeType },\n role: mark.role === \"crop\" ? \"crop\" : \"frame\",\n keep: mark.keep === true,\n source,\n width,\n encoded: new Map(),\n dropped: false,\n at: width,\n });\n }\n if (slots.length === 0) return result;\n\n const cap = options.cap_tokens ?? capFromEnv();\n const textChars = content.reduce((n, c) => (c.type === \"text\" ? n + c.text.length : n), 0);\n // Leave room for the note this may have to add.\n const budget = imageBudgetChars(cap, textChars + 400);\n\n const encode = async (slot: Slot, width: number): Promise<Candidate> => {\n const target = Math.min(width, slot.width);\n const cached = slot.encoded.get(target);\n if (cached) return cached;\n let image = sharp(slot.source);\n if (target < slot.width) image = image.resize(target, null, { fit: \"inside\", withoutEnlargement: true });\n const [png, jpeg] = await Promise.all([\n image.clone().png({ quality: 80, effort: 1, compressionLevel: 6 }).toBuffer(),\n image.clone().jpeg({ quality: BUDGET_JPEG_QUALITY, mozjpeg: true }).toBuffer(),\n ]);\n let best: Candidate =\n jpeg.length < png.length\n ? { data: jpeg.toString(\"base64\"), mimeType: \"image/jpeg\" }\n : { data: png.toString(\"base64\"), mimeType: \"image/png\" };\n // At its own size, what the tool produced is a candidate too — and when\n // it is already the smallest, it goes out untouched.\n if (target === slot.width && slot.original.data.length <= best.data.length) best = slot.original;\n slot.encoded.set(target, best);\n return best;\n };\n const total = async (): Promise<number> => {\n let sum = 0;\n for (const slot of slots) {\n if (slot.dropped) continue;\n sum += (await encode(slot, slot.at)).data.length;\n }\n return sum;\n };\n const fits = async (): Promise<boolean> => (await total()) <= budget;\n\n let cropsDropped = 0;\n let framesDropped = 0;\n let width: number | undefined;\n\n if (!(await fits())) {\n // 1. Crops duplicate what a frame already shows.\n for (const slot of slots) {\n if (slot.role === \"crop\" && !slot.dropped) {\n slot.dropped = true;\n cropsDropped++;\n }\n }\n }\n // 2. Smaller frames, all of them together, so they still compare.\n for (const step of BUDGET_WIDTHS) {\n if (await fits()) break;\n for (const slot of slots) if (!slot.dropped) slot.at = Math.min(slot.at, step);\n width = step;\n }\n // 3. Frames from the middle outwards: unprotected first, then protected,\n // never the last one standing.\n if (!(await fits())) {\n const live = () => slots.filter((s) => !s.dropped);\n const order = (protectedToo: boolean): Slot[] => {\n const alive = live();\n const first = alive[0];\n const last = alive[alive.length - 1];\n const candidates = alive.filter((s) => s !== first && s !== last && (protectedToo || !s.keep));\n // Middle outwards: sort by distance from the centre, farthest last.\n const centre = (alive.length - 1) / 2;\n return candidates.sort((a, b) => Math.abs(alive.indexOf(a) - centre) - Math.abs(alive.indexOf(b) - centre));\n };\n for (const protectedToo of [false, true]) {\n for (const slot of order(protectedToo)) {\n if (await fits()) break;\n slot.dropped = true;\n framesDropped++;\n }\n }\n // Down to the first and the last; then the first goes too.\n while (!(await fits()) && live().length > 1) {\n live()[0].dropped = true;\n framesDropped++;\n }\n }\n // 4. One image left and still over: shrink it as far as it takes.\n for (const step of BUDGET_LAST_RESORT_WIDTHS) {\n if (await fits()) break;\n for (const slot of slots) if (!slot.dropped) slot.at = Math.min(slot.at, step);\n width = step;\n }\n\n const degraded = cropsDropped > 0 || framesDropped > 0 || width !== undefined;\n const out: CallToolResult[\"content\"] = [];\n for (let i = 0; i < content.length; i++) {\n const slot = slots.find((s) => s.index === i);\n if (!slot) {\n out.push(content[i]);\n continue;\n }\n if (slot.dropped) {\n out.push({ type: \"text\", text: OMITTED });\n continue;\n }\n const best = await encode(slot, slot.at);\n const block = content[i] as ImageContent;\n const { _meta, ...rest } = block;\n const meta = _meta ? Object.fromEntries(Object.entries(_meta).filter(([key]) => key !== META_KEY)) : undefined;\n out.push({ ...rest, data: best.data, mimeType: best.mimeType, ...(meta && Object.keys(meta).length > 0 ? { _meta: meta } : {}) });\n }\n if (degraded) {\n const kept = slots.filter((s) => !s.dropped).length;\n out.push({\n type: \"text\",\n text: describeBudget({ total: slots.length, kept, crops_dropped: cropsDropped, frames_dropped: framesDropped, width, cap_tokens: cap }),\n });\n }\n return { ...result, content: out };\n}\n"]}
@@ -1,5 +1,13 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
- import type { DiffCard } from "../types.js";
2
+ import type { DiffCard, Viewport } from "../types.js";
3
+ /**
4
+ * How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,
5
+ * but every coordinate a tool prints — change regions, boxes, click points —
6
+ * is in viewport pixels. A reader who measures a position off the image and
7
+ * clicks there lands 1.6× off on the default viewport, so every tool that
8
+ * returns frames says which it is.
9
+ */
10
+ export declare function describeScale(viewport: Viewport): string;
3
11
  /** What became of a replayed interaction script. */
4
12
  export interface InteractionReport {
5
13
  /** Steps in the script. */
@@ -27,6 +35,8 @@ export interface CaptureSummary {
27
35
  dropped?: number;
28
36
  /** Present only when an interaction script was replayed. */
29
37
  interactions?: InteractionReport;
38
+ /** The viewport the frames were taken at; adds the image-scale line when known. */
39
+ viewport?: Viewport;
30
40
  /**
31
41
  * Remarks about the capture itself rather than about any one frame — a
32
42
  * context layer that hit its cap, requests still in flight when the
@@ -1,3 +1,19 @@
1
+ import { OUTPUT_MAX_WIDTH } from "../constants.js";
2
+ import { markImage } from "./budget.js";
3
+ /**
4
+ * How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,
5
+ * but every coordinate a tool prints — change regions, boxes, click points —
6
+ * is in viewport pixels. A reader who measures a position off the image and
7
+ * clicks there lands 1.6× off on the default viewport, so every tool that
8
+ * returns frames says which it is.
9
+ */
10
+ export function describeScale(viewport) {
11
+ const base = `viewport ${viewport.width}x${viewport.height}`;
12
+ if (viewport.width <= OUTPUT_MAX_WIDTH)
13
+ return `${base}, images at full size`;
14
+ const factor = (OUTPUT_MAX_WIDTH / viewport.width).toFixed(2);
15
+ return `${base}, images ${OUTPUT_MAX_WIDTH}px wide (${factor}×) — coordinates and regions are in viewport px`;
16
+ }
1
17
  /**
2
18
  * Build the MCP CallToolResult for a capture, following the "MCP Response
3
19
  * Format" in CLAUDE.md: one summary text block, then per card an image block,
@@ -5,6 +21,10 @@
5
21
  */
6
22
  export function formatDiffCards(summary) {
7
23
  const lines = [formatSummaryLine(summary)];
24
+ if (summary.viewport) {
25
+ const scale = describeScale(summary.viewport);
26
+ lines.push(scale.charAt(0).toUpperCase() + scale.slice(1));
27
+ }
8
28
  if (summary.interactions) {
9
29
  lines.push(formatInteractionLine(summary.interactions));
10
30
  }
@@ -12,13 +32,16 @@ export function formatDiffCards(summary) {
12
32
  lines.push(note);
13
33
  }
14
34
  const content = [{ type: "text", text: lines.join("\n") }];
15
- for (const card of summary.cards) {
16
- content.push({ type: "image", data: card.full_frame, mimeType: "image/png" });
35
+ const last = summary.cards.length - 1;
36
+ summary.cards.forEach((card, i) => {
37
+ // First, last and event frames survive the image budget longest; crops go first.
38
+ const keep = i === 0 || i === last || card.trigger !== "animation";
39
+ content.push(markImage({ type: "image", data: card.full_frame, mimeType: "image/png" }, { role: "frame", keep }));
17
40
  content.push({ type: "text", text: formatCardMeta(card) });
18
41
  if (card.change_region?.crop) {
19
- content.push({ type: "image", data: card.change_region.crop, mimeType: "image/png" });
42
+ content.push(markImage({ type: "image", data: card.change_region.crop, mimeType: "image/png" }, { role: "crop" }));
20
43
  }
21
- }
44
+ });
22
45
  return { content };
23
46
  }
24
47
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport type { DiffCard } from \"../types.js\";\n\n/** What became of a replayed interaction script. */\nexport interface InteractionReport {\n /** Steps in the script. */\n total: number;\n /** Steps that ran successfully. */\n completed: number;\n /** One-line description of each completed step, in order. */\n steps: string[];\n /** Message from the step that failed, if any (already a single line). */\n error?: string;\n /** 1-based position of the failing step. */\n failed_index?: number;\n}\n\n/** Everything the formatter needs to describe one capture session. */\nexport interface CaptureSummary {\n cards: DiffCard[];\n total_frames: number;\n duration_ms: number;\n /** URL that was requested. */\n url: string;\n /** URL the page ended on (after redirects / in-page navigation). */\n final_url?: string;\n title?: string;\n /** Frames dropped by the recorder (screenshot failures). */\n dropped?: number;\n /** Present only when an interaction script was replayed. */\n interactions?: InteractionReport;\n /**\n * Remarks about the capture itself rather than about any one frame — a\n * context layer that hit its cap, requests still in flight when the\n * recording ended. One line each, after the summary.\n */\n notes?: string[];\n}\n\n/**\n * Build the MCP CallToolResult for a capture, following the \"MCP Response\n * Format\" in CLAUDE.md: one summary text block, then per card an image block,\n * a metadata text block and (when present) the change-region crop image.\n */\nexport function formatDiffCards(summary: CaptureSummary): CallToolResult {\n const lines = [formatSummaryLine(summary)];\n if (summary.interactions) {\n lines.push(formatInteractionLine(summary.interactions));\n }\n for (const note of summary.notes ?? []) {\n lines.push(note);\n }\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: lines.join(\"\\n\") }];\n for (const card of summary.cards) {\n content.push({ type: \"image\", data: card.full_frame, mimeType: \"image/png\" });\n content.push({ type: \"text\", text: formatCardMeta(card) });\n if (card.change_region?.crop) {\n content.push({ type: \"image\", data: card.change_region.crop, mimeType: \"image/png\" });\n }\n }\n return { content };\n}\n\n/**\n * One-line description of the whole capture. Always names the requested url;\n * appends the final url only when the page ended somewhere else, the page\n * title when known, and the dropped-frame count only when frames were lost.\n */\nfunction formatSummaryLine(summary: CaptureSummary): string {\n const { cards, total_frames, duration_ms, url, final_url, title, dropped } = summary;\n const recording = `from ${total_frames} raw frames (${duration_ms}ms recording) of ${url}`;\n let text = cards.length === 0 ? `No frames captured ${recording}` : `Captured ${cards.length} meaningful frames ${recording}`;\n if (final_url !== undefined && !isSameUrl(url, final_url)) {\n text += ` → ${final_url}`;\n }\n if (title) {\n text += ` — \"${title}\"`;\n }\n if (dropped !== undefined && dropped > 0) {\n text += ` (${dropped} frames dropped)`;\n }\n return text;\n}\n\n/**\n * How the interaction script went: how many steps ran, what they were, and —\n * when one failed — which one and why. A failed step is a finding about the\n * page, not a tool failure, so it is reported here alongside the frames rather\n * than replacing them with an error.\n */\nexport function formatInteractionLine(report: InteractionReport): string {\n let text = `Interactions: ${report.completed}/${report.total} replayed`;\n if (report.steps.length > 0) {\n text += ` — ${report.steps.join(\", \")}`;\n }\n if (report.error !== undefined) {\n text += `. Step ${report.failed_index ?? report.completed + 1}: ${report.error}`;\n }\n return text;\n}\n\n/**\n * Compare two URLs as URLs, not as strings: `page.url()` returns the\n * WHATWG-normalised form, so a request for `http://localhost:3000` comes back\n * as `http://localhost:3000/` without anything having navigated.\n */\nfunction isSameUrl(a: string, b: string): boolean {\n if (a === b) return true;\n try {\n return new URL(a).href === new URL(b).href;\n } catch {\n return false;\n }\n}\n\n/**\n * The metadata text block for one card (exported for tests and for reuse by\n * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the\n * optional sections follow in a fixed order — Changed, Console, Network,\n * Performance, DOM — and are omitted entirely when their data is absent.\n */\nexport function formatCardMeta(card: DiffCard): string {\n const lines: string[] = [`Frame ${card.index} @ ${card.timestamp_ms}ms [${card.trigger}]`];\n\n const region = card.change_region;\n if (region) {\n if (region.change_percent === 0) {\n lines.push(\"Changed: 0.0% — no visual change since previous frame\");\n } else {\n const { bbox } = region;\n let changed = `Changed: ${region.change_percent.toFixed(1)}% — region: ${bbox.x},${bbox.y} ${bbox.width}x${bbox.height}`;\n if (!region.crop) {\n changed += \" (full-frame change, see frame image)\";\n }\n lines.push(changed);\n }\n }\n\n if (card.console_entries?.length) {\n lines.push(\"Console:\");\n for (const entry of card.console_entries) {\n lines.push(` [${entry.level}] ${entry.text}`);\n }\n }\n\n if (card.network_events?.length) {\n lines.push(\"Network:\");\n for (const event of card.network_events) {\n // A request that never got a response has no status to print; what\n // stopped it (or that it is still running) is the useful part.\n const outcome = event.status > 0 ? String(event.status) : (event.error ?? \"no response\");\n const why = event.status > 0 && event.error !== undefined ? ` ${event.error}` : \"\";\n lines.push(` ${event.method} ${event.url} → ${outcome}${why} (${event.duration_ms}ms)`);\n }\n }\n\n const perf = card.performance;\n if (perf) {\n lines.push(\"Performance:\");\n if (perf.paint_time_ms !== undefined) lines.push(` paint ${perf.paint_time_ms}ms`);\n if (perf.layout_shifts !== undefined) {\n const score = perf.layout_shift_score !== undefined ? ` (score ${perf.layout_shift_score})` : \"\";\n lines.push(` layout shifts ${perf.layout_shifts}${score}`);\n }\n if (perf.lcp_ms !== undefined) lines.push(` lcp ${perf.lcp_ms}ms`);\n }\n\n if (card.dom_snapshot) {\n lines.push(`DOM:\\n${card.dom_snapshot}`);\n }\n\n return lines.join(\"\\n\");\n}\n"]}
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAGxC;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,QAAkB;IAC9C,MAAM,IAAI,GAAG,YAAY,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;IAC7D,IAAI,QAAQ,CAAC,KAAK,IAAI,gBAAgB;QAAE,OAAO,GAAG,IAAI,uBAAuB,CAAC;IAC9E,MAAM,MAAM,GAAG,CAAC,gBAAgB,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9D,OAAO,GAAG,IAAI,YAAY,gBAAgB,YAAY,MAAM,iDAAiD,CAAC;AAChH,CAAC;AAwCD;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,OAAuB;IACrD,MAAM,KAAK,GAAG,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,CAAC;IAC3C,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;QACrB,MAAM,KAAK,GAAG,aAAa,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QAC9C,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7D,CAAC;IACD,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC,qBAAqB,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC;IAC1D,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;QACvC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAA8B,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtF,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IACtC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;QAChC,iFAAiF;QACjF,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,IAAI,CAAC,OAAO,KAAK,WAAW,CAAC;QACnE,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QAClH,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3D,IAAI,IAAI,CAAC,aAAa,EAAE,IAAI,EAAE,CAAC;YAC7B,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;QACrH,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,OAAO,EAAE,CAAC;AACrB,CAAC;AAED;;;;GAIG;AACH,SAAS,iBAAiB,CAAC,OAAuB;IAChD,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IACrF,MAAM,SAAS,GAAG,QAAQ,YAAY,gBAAgB,WAAW,oBAAoB,GAAG,EAAE,CAAC;IAC3F,IAAI,IAAI,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,sBAAsB,SAAS,EAAE,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,MAAM,sBAAsB,SAAS,EAAE,CAAC;IAC9H,IAAI,SAAS,KAAK,SAAS,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC;QAC1D,IAAI,IAAI,MAAM,SAAS,EAAE,CAAC;IAC5B,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,IAAI,OAAO,KAAK,GAAG,CAAC;IAC1B,CAAC;IACD,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,OAAO,kBAAkB,CAAC;IACzC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAyB;IAC7D,IAAI,IAAI,GAAG,iBAAiB,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,KAAK,WAAW,CAAC;IACxE,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,IAAI,IAAI,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAC1C,CAAC;IACD,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC/B,IAAI,IAAI,UAAU,MAAM,CAAC,YAAY,IAAI,MAAM,CAAC,SAAS,GAAG,CAAC,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,SAAS,SAAS,CAAC,CAAS,EAAE,CAAS;IACrC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,IAAc;IAC3C,MAAM,KAAK,GAAa,CAAC,SAAS,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,YAAY,OAAO,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;IAE3F,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC;IAClC,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,MAAM,CAAC,cAAc,KAAK,CAAC,EAAE,CAAC;YAChC,KAAK,CAAC,IAAI,CAAC,uDAAuD,CAAC,CAAC;QACtE,CAAC;aAAM,CAAC;YACN,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC;YACxB,IAAI,OAAO,GAAG,YAAY,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACzH,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;gBACjB,OAAO,IAAI,uCAAuC,CAAC;YACrD,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAED,IAAI,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,CAAC;QACjC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;YACzC,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QACjD,CAAC;IACH,CAAC;IAED,IAAI,IAAI,CAAC,cAAc,EAAE,MAAM,EAAE,CAAC;QAChC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACxC,mEAAmE;YACnE,+DAA+D;YAC/D,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,aAAa,CAAC,CAAC;YACzF,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnF,KAAK,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,OAAO,GAAG,GAAG,KAAK,KAAK,CAAC,WAAW,KAAK,CAAC,CAAC;QAC3F,CAAC;IACH,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC;IAC9B,IAAI,IAAI,EAAE,CAAC;QACT,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS;YAAE,KAAK,CAAC,IAAI,CAAC,WAAW,IAAI,CAAC,aAAa,IAAI,CAAC,CAAC;QACpF,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS,EAAE,CAAC;YACrC,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,kBAAkB,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YACjG,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,aAAa,GAAG,KAAK,EAAE,CAAC,CAAC;QAC9D,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,KAAK,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;IACtE,CAAC;IAED,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC","sourcesContent":["import type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport { OUTPUT_MAX_WIDTH } from \"../constants.js\";\nimport { markImage } from \"./budget.js\";\nimport type { DiffCard, Viewport } from \"../types.js\";\n\n/**\n * How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,\n * but every coordinate a tool prints — change regions, boxes, click points —\n * is in viewport pixels. A reader who measures a position off the image and\n * clicks there lands 1.6× off on the default viewport, so every tool that\n * returns frames says which it is.\n */\nexport function describeScale(viewport: Viewport): string {\n const base = `viewport ${viewport.width}x${viewport.height}`;\n if (viewport.width <= OUTPUT_MAX_WIDTH) return `${base}, images at full size`;\n const factor = (OUTPUT_MAX_WIDTH / viewport.width).toFixed(2);\n return `${base}, images ${OUTPUT_MAX_WIDTH}px wide (${factor}×) — coordinates and regions are in viewport px`;\n}\n\n/** What became of a replayed interaction script. */\nexport interface InteractionReport {\n /** Steps in the script. */\n total: number;\n /** Steps that ran successfully. */\n completed: number;\n /** One-line description of each completed step, in order. */\n steps: string[];\n /** Message from the step that failed, if any (already a single line). */\n error?: string;\n /** 1-based position of the failing step. */\n failed_index?: number;\n}\n\n/** Everything the formatter needs to describe one capture session. */\nexport interface CaptureSummary {\n cards: DiffCard[];\n total_frames: number;\n duration_ms: number;\n /** URL that was requested. */\n url: string;\n /** URL the page ended on (after redirects / in-page navigation). */\n final_url?: string;\n title?: string;\n /** Frames dropped by the recorder (screenshot failures). */\n dropped?: number;\n /** Present only when an interaction script was replayed. */\n interactions?: InteractionReport;\n /** The viewport the frames were taken at; adds the image-scale line when known. */\n viewport?: Viewport;\n /**\n * Remarks about the capture itself rather than about any one frame — a\n * context layer that hit its cap, requests still in flight when the\n * recording ended. One line each, after the summary.\n */\n notes?: string[];\n}\n\n/**\n * Build the MCP CallToolResult for a capture, following the \"MCP Response\n * Format\" in CLAUDE.md: one summary text block, then per card an image block,\n * a metadata text block and (when present) the change-region crop image.\n */\nexport function formatDiffCards(summary: CaptureSummary): CallToolResult {\n const lines = [formatSummaryLine(summary)];\n if (summary.viewport) {\n const scale = describeScale(summary.viewport);\n lines.push(scale.charAt(0).toUpperCase() + scale.slice(1));\n }\n if (summary.interactions) {\n lines.push(formatInteractionLine(summary.interactions));\n }\n for (const note of summary.notes ?? []) {\n lines.push(note);\n }\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: lines.join(\"\\n\") }];\n const last = summary.cards.length - 1;\n summary.cards.forEach((card, i) => {\n // First, last and event frames survive the image budget longest; crops go first.\n const keep = i === 0 || i === last || card.trigger !== \"animation\";\n content.push(markImage({ type: \"image\", data: card.full_frame, mimeType: \"image/png\" }, { role: \"frame\", keep }));\n content.push({ type: \"text\", text: formatCardMeta(card) });\n if (card.change_region?.crop) {\n content.push(markImage({ type: \"image\", data: card.change_region.crop, mimeType: \"image/png\" }, { role: \"crop\" }));\n }\n });\n return { content };\n}\n\n/**\n * One-line description of the whole capture. Always names the requested url;\n * appends the final url only when the page ended somewhere else, the page\n * title when known, and the dropped-frame count only when frames were lost.\n */\nfunction formatSummaryLine(summary: CaptureSummary): string {\n const { cards, total_frames, duration_ms, url, final_url, title, dropped } = summary;\n const recording = `from ${total_frames} raw frames (${duration_ms}ms recording) of ${url}`;\n let text = cards.length === 0 ? `No frames captured ${recording}` : `Captured ${cards.length} meaningful frames ${recording}`;\n if (final_url !== undefined && !isSameUrl(url, final_url)) {\n text += ` → ${final_url}`;\n }\n if (title) {\n text += ` — \"${title}\"`;\n }\n if (dropped !== undefined && dropped > 0) {\n text += ` (${dropped} frames dropped)`;\n }\n return text;\n}\n\n/**\n * How the interaction script went: how many steps ran, what they were, and —\n * when one failed — which one and why. A failed step is a finding about the\n * page, not a tool failure, so it is reported here alongside the frames rather\n * than replacing them with an error.\n */\nexport function formatInteractionLine(report: InteractionReport): string {\n let text = `Interactions: ${report.completed}/${report.total} replayed`;\n if (report.steps.length > 0) {\n text += ` — ${report.steps.join(\", \")}`;\n }\n if (report.error !== undefined) {\n text += `. Step ${report.failed_index ?? report.completed + 1}: ${report.error}`;\n }\n return text;\n}\n\n/**\n * Compare two URLs as URLs, not as strings: `page.url()` returns the\n * WHATWG-normalised form, so a request for `http://localhost:3000` comes back\n * as `http://localhost:3000/` without anything having navigated.\n */\nfunction isSameUrl(a: string, b: string): boolean {\n if (a === b) return true;\n try {\n return new URL(a).href === new URL(b).href;\n } catch {\n return false;\n }\n}\n\n/**\n * The metadata text block for one card (exported for tests and for reuse by\n * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the\n * optional sections follow in a fixed order — Changed, Console, Network,\n * Performance, DOM — and are omitted entirely when their data is absent.\n */\nexport function formatCardMeta(card: DiffCard): string {\n const lines: string[] = [`Frame ${card.index} @ ${card.timestamp_ms}ms [${card.trigger}]`];\n\n const region = card.change_region;\n if (region) {\n if (region.change_percent === 0) {\n lines.push(\"Changed: 0.0% — no visual change since previous frame\");\n } else {\n const { bbox } = region;\n let changed = `Changed: ${region.change_percent.toFixed(1)}% — region: ${bbox.x},${bbox.y} ${bbox.width}x${bbox.height}`;\n if (!region.crop) {\n changed += \" (full-frame change, see frame image)\";\n }\n lines.push(changed);\n }\n }\n\n if (card.console_entries?.length) {\n lines.push(\"Console:\");\n for (const entry of card.console_entries) {\n lines.push(` [${entry.level}] ${entry.text}`);\n }\n }\n\n if (card.network_events?.length) {\n lines.push(\"Network:\");\n for (const event of card.network_events) {\n // A request that never got a response has no status to print; what\n // stopped it (or that it is still running) is the useful part.\n const outcome = event.status > 0 ? String(event.status) : (event.error ?? \"no response\");\n const why = event.status > 0 && event.error !== undefined ? ` ${event.error}` : \"\";\n lines.push(` ${event.method} ${event.url} → ${outcome}${why} (${event.duration_ms}ms)`);\n }\n }\n\n const perf = card.performance;\n if (perf) {\n lines.push(\"Performance:\");\n if (perf.paint_time_ms !== undefined) lines.push(` paint ${perf.paint_time_ms}ms`);\n if (perf.layout_shifts !== undefined) {\n const score = perf.layout_shift_score !== undefined ? ` (score ${perf.layout_shift_score})` : \"\";\n lines.push(` layout shifts ${perf.layout_shifts}${score}`);\n }\n if (perf.lcp_ms !== undefined) lines.push(` lcp ${perf.lcp_ms}ms`);\n }\n\n if (card.dom_snapshot) {\n lines.push(`DOM:\\n${card.dom_snapshot}`);\n }\n\n return lines.join(\"\\n\");\n}\n"]}