@aayambansal/squint 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -174,6 +174,12 @@ squint doctor --probe # run every engine end to end, verify auth act
174
174
  flagged from the live page, and on Next 16+ the framework's own `/_next/mcp`
175
175
  channel feeds structured errors straight into the fix loop. `/context` itemizes
176
176
  the injected-context bill per source, with staleness warnings.
177
+ - **The sentinel**: gate evasion (deleted tests, added skips, suppressed
178
+ diagnostics, weakened checks, locked-path touches) detected deterministically per
179
+ turn and reported to you — never sent back to the engine that did it.
180
+ - **Detachable sessions**: `squint serve` owns the session on a unix socket;
181
+ `squint attach` joins from any terminal (observer seats for teammates, driver
182
+ seat inherited on detach). Crash-proof, ssh-friendly, no cloud.
177
183
  - **Persistent checks**: assertions the engine verifies once persist as
178
184
  `.squint/checks/*.js` and replay against the live page every turn — one-off
179
185
  verifications compound into repo-versioned regression checks.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ App
4
+ } from "./chunk-JZMJGLIZ.js";
5
+ import "./chunk-43NQNIJY.js";
6
+ import "./chunk-YGPVP4YB.js";
7
+ import "./chunk-ARDV4XH6.js";
8
+ import "./chunk-AUJJGMZG.js";
9
+ import "./chunk-YHRAOBI2.js";
10
+ import "./chunk-QCHBDP46.js";
11
+ import "./chunk-YGSF2TSO.js";
12
+ import "./chunk-VH7OOFQP.js";
13
+ import "./chunk-K5QJMSJH.js";
14
+ import "./chunk-WAJXATCO.js";
15
+ import "./chunk-62JNF5M2.js";
16
+ import "./chunk-7RFQWOQV.js";
17
+ import "./chunk-7CAGWFAQ.js";
18
+ import "./chunk-BGBG2MNY.js";
19
+ import "./chunk-IMDRXXFU.js";
20
+ import "./chunk-KVYGPLWW.js";
21
+ export {
22
+ App
23
+ };
@@ -2,12 +2,14 @@
2
2
  import {
3
3
  cdpCapture,
4
4
  hasWebSocket,
5
+ pixelDiffAttributed,
5
6
  pixelDiffPct,
6
7
  runFlow
7
- } from "./chunk-X2RZJT4L.js";
8
+ } from "./chunk-BGBG2MNY.js";
8
9
  export {
9
10
  cdpCapture,
10
11
  hasWebSocket,
12
+ pixelDiffAttributed,
11
13
  pixelDiffPct,
12
14
  runFlow
13
15
  };
@@ -3,7 +3,7 @@
3
3
  // src/state/state.ts
4
4
  import fs from "fs";
5
5
  import path from "path";
6
- var IGNORED = ["preview/", "state.json", "variants/", "sandbox/", "sandbox.patch", "transcripts/"];
6
+ var IGNORED = ["preview/", "state.json", "variants/", "sandbox/", "sandbox.patch", "transcripts/", "daemon.sock"];
7
7
  function ensureSquintIgnore(cwd) {
8
8
  const dir = path.join(cwd, ".squint");
9
9
  fs.mkdirSync(dir, { recursive: true });
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ensureSquintIgnore
4
- } from "./chunk-O2S6PAJE.js";
4
+ } from "./chunk-7CAGWFAQ.js";
5
+ import {
6
+ cdpCapture,
7
+ hasWebSocket
8
+ } from "./chunk-BGBG2MNY.js";
5
9
  import {
6
10
  findChrome,
7
11
  screenshot
8
12
  } from "./chunk-IMDRXXFU.js";
9
- import {
10
- cdpCapture,
11
- hasWebSocket
12
- } from "./chunk-X2RZJT4L.js";
13
13
 
14
14
  // src/preview/preview.ts
15
15
  import fs2 from "fs";
@@ -75,7 +75,7 @@ async function captureViewports(cwd, url) {
75
75
  if (hasWebSocket()) {
76
76
  try {
77
77
  const checks = loadChecks(cwd);
78
- const { report, shots: shots2, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true, checks);
78
+ const { report, shots: shots2, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true, checks);
79
79
  const errors2 = [];
80
80
  for (const route of routes.slice(1)) {
81
81
  try {
@@ -90,7 +90,7 @@ async function captureViewports(cwd, url) {
90
90
  errors2.push(`${route}: capture failed`);
91
91
  }
92
92
  }
93
- return { shots: shots2, errors: errors2, runtime: report, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp };
93
+ return { shots: shots2, errors: errors2, runtime: report, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank };
94
94
  } catch {
95
95
  }
96
96
  }
@@ -163,9 +163,15 @@ async function probeRuntime(url, cwd) {
163
163
  async function comparePulse(previous, current) {
164
164
  const chrome = findChrome();
165
165
  if (!chrome || !hasWebSocket()) return null;
166
- const { pixelDiffPct } = await import("./cdp-OYRHOPVP.js");
166
+ const { pixelDiffPct } = await import("./cdp-2VVQOHWM.js");
167
167
  return pixelDiffPct(chrome, previous, current);
168
168
  }
169
+ async function comparePulseAttributed(previous, current, url) {
170
+ const chrome = findChrome();
171
+ if (!chrome || !hasWebSocket()) return null;
172
+ const { pixelDiffAttributed } = await import("./cdp-2VVQOHWM.js");
173
+ return pixelDiffAttributed(chrome, previous, current, url);
174
+ }
169
175
  function buildRuntimeFixPrompt(report) {
170
176
  return `The running app has runtime problems.${runtimeSection(report)}
171
177
 
@@ -191,6 +197,16 @@ ${narration.join("\n")}
191
197
 
192
198
  Judge this narration as an experience: does the reading order make sense? do names actually describe their targets? is anything announced as "(no accessible name)"? Fix real incoherence \u2014 this is how non-visual users meet the page.`;
193
199
  }
200
+ function jankSection(jank) {
201
+ if (!jank || jank.length === 0) return "";
202
+ return `
203
+
204
+ ## Jank attribution (main-thread frames \u226550ms)
205
+
206
+ ${jank.join("\n")}
207
+
208
+ Each line names the function behind a long animation frame observed during load and a scripted scroll. Fix the work (memoize, virtualize, move off the main thread) \u2014 do not remove the animation.`;
209
+ }
194
210
  function webmcpSection(webmcp) {
195
211
  if (!webmcp || webmcp.length === 0) return "";
196
212
  return `
@@ -241,13 +257,13 @@ ${findings.join("\n")}
241
257
 
242
258
  These patterns make the page read as template output. Rework them within the committed design direction \u2014 this is style debt, not a defect list.`;
243
259
  }
244
- function buildReviewPrompt(shots, extra, runtime, a11y, slop, narration, phantoms, viewTransitions, components, webmcp) {
260
+ function buildReviewPrompt(shots, extra, runtime, a11y, slop, narration, phantoms, viewTransitions, components, webmcp, jank) {
245
261
  const list = shots.map((s) => `- ${s.name}: ${s.path}`).join("\n");
246
262
  return `Screenshots of the running app were just captured:
247
263
 
248
264
  ${list}
249
265
 
250
- Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}${slopSection(slop)}${narrationSection(narration)}${phantomSection(phantoms)}${vtSection(viewTransitions)}${componentSection(components)}${webmcpSection(webmcp)}`;
266
+ Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}${slopSection(slop)}${narrationSection(narration)}${phantomSection(phantoms)}${vtSection(viewTransitions)}${componentSection(components)}${webmcpSection(webmcp)}${jankSection(jank)}`;
251
267
  }
252
268
 
253
269
  export {
@@ -259,6 +275,7 @@ export {
259
275
  runtimeSummary,
260
276
  probeRuntime,
261
277
  comparePulse,
278
+ comparePulseAttributed,
262
279
  buildRuntimeFixPrompt,
263
280
  buildReviewPrompt
264
281
  };
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ Session
4
+ } from "./chunk-YGPVP4YB.js";
5
+
6
+ // src/daemon/client.ts
7
+ import net from "net";
8
+ function connectDaemon(sock, timeoutMs = 3e3) {
9
+ return new Promise((resolve, reject) => {
10
+ const socket = net.createConnection(sock);
11
+ const timer = setTimeout(() => {
12
+ socket.destroy();
13
+ reject(new Error(`no daemon listening at ${sock}`));
14
+ }, timeoutMs);
15
+ const handlers = [];
16
+ let buffer = "";
17
+ socket.on("connect", () => {
18
+ clearTimeout(timer);
19
+ resolve({
20
+ send: (msg) => socket.write(`${JSON.stringify(msg)}
21
+ `),
22
+ onMessage: (handler) => handlers.push(handler),
23
+ close: () => socket.destroy()
24
+ });
25
+ });
26
+ socket.on("error", (error) => {
27
+ clearTimeout(timer);
28
+ reject(error);
29
+ });
30
+ socket.on("data", (chunk) => {
31
+ buffer += chunk.toString();
32
+ let idx;
33
+ while ((idx = buffer.indexOf("\n")) >= 0) {
34
+ const line = buffer.slice(0, idx);
35
+ buffer = buffer.slice(idx + 1);
36
+ if (!line.trim()) continue;
37
+ try {
38
+ const msg = JSON.parse(line);
39
+ for (const handler of handlers) handler(msg);
40
+ } catch {
41
+ }
42
+ }
43
+ });
44
+ });
45
+ }
46
+
47
+ // src/daemon/server.ts
48
+ import crypto from "crypto";
49
+ import fs from "fs";
50
+ import net2 from "net";
51
+ import os from "os";
52
+ import path from "path";
53
+ var MAX_ITEMS_SENT = 200;
54
+ function socketPath(cwd) {
55
+ const direct = path.join(cwd, ".squint", "daemon.sock");
56
+ if (Buffer.byteLength(direct) <= 96) return direct;
57
+ const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 12);
58
+ return path.join(os.tmpdir(), `squint-${hash}.sock`);
59
+ }
60
+ function startDaemon(opts) {
61
+ const session = new Session(opts);
62
+ const sock = socketPath(opts.cwd);
63
+ fs.mkdirSync(path.dirname(sock), { recursive: true });
64
+ fs.rmSync(sock, { force: true });
65
+ const clients = [];
66
+ const driver = () => clients[0];
67
+ const serialize = () => {
68
+ const state = session.getState();
69
+ return `${JSON.stringify({ type: "state", state: { ...state, items: state.items.slice(-MAX_ITEMS_SENT) } })}
70
+ `;
71
+ };
72
+ const server = net2.createServer((socket) => {
73
+ clients.push(socket);
74
+ const role = socket === driver() ? "driver" : "observer";
75
+ socket.write(`${JSON.stringify({ type: "hello", role, engineId: session.getState().engineId })}
76
+ `);
77
+ socket.write(serialize());
78
+ let buffer = "";
79
+ socket.on("data", (chunk) => {
80
+ buffer += chunk.toString();
81
+ let idx;
82
+ while ((idx = buffer.indexOf("\n")) >= 0) {
83
+ const line = buffer.slice(0, idx);
84
+ buffer = buffer.slice(idx + 1);
85
+ if (!line.trim()) continue;
86
+ let msg;
87
+ try {
88
+ msg = JSON.parse(line);
89
+ } catch {
90
+ continue;
91
+ }
92
+ if (msg.type === "ping") {
93
+ socket.write(`${JSON.stringify({ type: "ping" })}
94
+ `);
95
+ continue;
96
+ }
97
+ if (socket !== driver()) {
98
+ socket.write(`${JSON.stringify({ type: "denied", reason: "observer \u2014 the driver steers this session" })}
99
+ `);
100
+ continue;
101
+ }
102
+ if (msg.type === "input" && typeof msg.text === "string") session.input(msg.text);
103
+ else if (msg.type === "command" && typeof msg.text === "string") session.command(msg.text);
104
+ else if (msg.type === "interrupt") session.interrupt();
105
+ else if (msg.type === "cycleMode") session.cycleMode();
106
+ }
107
+ });
108
+ const drop = () => {
109
+ const index = clients.indexOf(socket);
110
+ if (index >= 0) clients.splice(index, 1);
111
+ const next = driver();
112
+ if (index === 0 && next && !next.destroyed) {
113
+ next.write(`${JSON.stringify({ type: "hello", role: "driver", engineId: session.getState().engineId })}
114
+ `);
115
+ }
116
+ };
117
+ socket.on("close", drop);
118
+ socket.on("error", drop);
119
+ });
120
+ const unsubscribe = session.subscribe(() => {
121
+ const payload = serialize();
122
+ for (const client of clients) {
123
+ if (!client.destroyed) client.write(payload);
124
+ }
125
+ });
126
+ return new Promise((resolve, reject) => {
127
+ server.once("error", reject);
128
+ server.listen(sock, () => {
129
+ resolve({
130
+ session,
131
+ clientCount: () => clients.length,
132
+ close: () => {
133
+ unsubscribe();
134
+ for (const client of clients) client.destroy();
135
+ server.close();
136
+ fs.rmSync(sock, { force: true });
137
+ }
138
+ });
139
+ });
140
+ });
141
+ }
142
+
143
+ export {
144
+ connectDaemon,
145
+ socketPath,
146
+ startDaemon
147
+ };
@@ -15,7 +15,7 @@ function sandboxExists(cwd) {
15
15
  }
16
16
  function openSandbox(cwd) {
17
17
  const dir = sandboxDir(cwd);
18
- void import("./state-QOS7WCZO.js").then(({ ensureSquintIgnore }) => ensureSquintIgnore(cwd)).catch(() => {
18
+ void import("./state-PLY7YAD2.js").then(({ ensureSquintIgnore }) => ensureSquintIgnore(cwd)).catch(() => {
19
19
  });
20
20
  if (sandboxExists(cwd)) return { dir, reused: true };
21
21
  fs.rmSync(dir, { recursive: true, force: true });
@@ -189,6 +189,7 @@ async function runFlow(chromePath, baseUrl, flow, outDir) {
189
189
  const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
190
190
  await connection.send("Page.enable", {}, sessionId);
191
191
  await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBMCP_SHIM }, sessionId).catch(() => null);
192
+ await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: LOAF_SHIM }, sessionId).catch(() => null);
192
193
  await connection.send(
193
194
  "Emulation.setDeviceMetricsOverride",
194
195
  { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false },
@@ -237,6 +238,145 @@ async function runFlow(chromePath, baseUrl, flow, outDir) {
237
238
  setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
238
239
  }
239
240
  }
241
+ async function pixelDiffAttributed(chromePath, pngA, pngB, url) {
242
+ const { child, wsUrl, profileDir } = await launchChrome(chromePath);
243
+ let connection = null;
244
+ try {
245
+ connection = await CdpConnection.connect(wsUrl, 1e4);
246
+ const { targetId } = await connection.send("Target.createTarget", { url: "about:blank" });
247
+ const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
248
+ await connection.send("Runtime.enable", {}, sessionId);
249
+ const expression = `(async () => {
250
+ const load = (src) => new Promise((resolve, reject) => {
251
+ const img = new Image();
252
+ img.onload = () => resolve(img);
253
+ img.onerror = () => reject(new Error('decode'));
254
+ img.src = src;
255
+ });
256
+ const a = await load('data:image/png;base64,${pngA.toString("base64")}');
257
+ const b = await load('data:image/png;base64,${pngB.toString("base64")}');
258
+ const w = Math.min(a.width, b.width), h = Math.min(a.height, b.height);
259
+ if (w === 0 || h === 0) return null;
260
+ const draw = (img) => {
261
+ const c = document.createElement('canvas');
262
+ c.width = w; c.height = h;
263
+ const ctx = c.getContext('2d');
264
+ ctx.drawImage(img, 0, 0);
265
+ return ctx.getImageData(0, 0, w, h).data;
266
+ };
267
+ const da = draw(a), db = draw(b);
268
+ const CELL = 32;
269
+ const gw = Math.ceil(w / CELL), gh = Math.ceil(h / CELL);
270
+ const marked = new Uint8Array(gw * gh);
271
+ let differ = 0, total = 0;
272
+ for (let y = 0; y < h; y += 2) {
273
+ for (let x = 0; x < w; x += 2) {
274
+ total++;
275
+ const i = (y * w + x) * 4;
276
+ if (Math.abs(da[i] - db[i]) > 8 || Math.abs(da[i + 1] - db[i + 1]) > 8 || Math.abs(da[i + 2] - db[i + 2]) > 8) {
277
+ differ++;
278
+ marked[Math.floor(y / CELL) * gw + Math.floor(x / CELL)] = 1;
279
+ }
280
+ }
281
+ }
282
+ // Flood-fill marked cells into region bounding boxes.
283
+ const seen = new Uint8Array(gw * gh);
284
+ const regions = [];
285
+ for (let cy = 0; cy < gh; cy++) {
286
+ for (let cx = 0; cx < gw; cx++) {
287
+ const start = cy * gw + cx;
288
+ if (!marked[start] || seen[start]) continue;
289
+ let minX = cx, maxX = cx, minY = cy, maxY = cy, cells = 0;
290
+ const stack = [start];
291
+ seen[start] = 1;
292
+ while (stack.length) {
293
+ const cell = stack.pop();
294
+ const x = cell % gw, y = Math.floor(cell / gw);
295
+ cells++;
296
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
297
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
298
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
299
+ const nx = x + dx, ny = y + dy;
300
+ if (nx < 0 || ny < 0 || nx >= gw || ny >= gh) continue;
301
+ const n = ny * gw + nx;
302
+ if (marked[n] && !seen[n]) { seen[n] = 1; stack.push(n); }
303
+ }
304
+ }
305
+ regions.push({
306
+ x: minX * CELL, y: minY * CELL,
307
+ w: Math.min(w, (maxX + 1) * CELL) - minX * CELL,
308
+ h: Math.min(h, (maxY + 1) * CELL) - minY * CELL,
309
+ cells,
310
+ });
311
+ }
312
+ }
313
+ regions.sort((p, q) => q.cells - p.cells);
314
+ const sizePenalty = (a.width !== b.width || a.height !== b.height) ? 1 : 0;
315
+ return { pct: Math.min(100, (differ / total) * 100 + sizePenalty), regions: regions.slice(0, 5), w, h };
316
+ })()`;
317
+ const { result } = await connection.send(
318
+ "Runtime.evaluate",
319
+ { expression, awaitPromise: true, returnByValue: true },
320
+ sessionId
321
+ );
322
+ const value = result?.value;
323
+ if (!value || typeof value.pct !== "number") return null;
324
+ if (!url || value.regions.length === 0 || value.pct < 0.5) return { pct: value.pct, sentences: [] };
325
+ let sentences = [];
326
+ try {
327
+ const page = await connection.send("Target.createTarget", { url: "about:blank" });
328
+ const attach = await connection.send("Target.attachToTarget", { targetId: page.targetId, flatten: true });
329
+ const pageSession = attach.sessionId;
330
+ await connection.send("Runtime.enable", {}, pageSession);
331
+ await connection.send("Page.enable", {}, pageSession);
332
+ await connection.send(
333
+ "Emulation.setDeviceMetricsOverride",
334
+ { width: value.w, height: value.h, deviceScaleFactor: 1, mobile: false },
335
+ pageSession
336
+ );
337
+ await connection.send("Page.navigate", { url }, pageSession);
338
+ await new Promise((resolve) => setTimeout(resolve, 1200));
339
+ const hitTest = `((regions) => regions.map((r) => {
340
+ const el = document.elementsFromPoint(r.x + r.w / 2, r.y + r.h / 2)
341
+ .find((e) => e !== document.documentElement && e !== document.body);
342
+ const where = r.w + '\xD7' + r.h + ' region @ (' + r.x + ',' + r.y + ')';
343
+ if (!el) return where + ' changed';
344
+ let label = el.tagName.toLowerCase();
345
+ if (el.id) label += '#' + el.id;
346
+ else if (el.classList[0]) label += '.' + el.classList[0];
347
+ const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
348
+ let chain = '';
349
+ if (key) {
350
+ let fiber = el[key];
351
+ const names = [];
352
+ let hops = 0;
353
+ while (fiber && hops < 50 && names.length < 2) {
354
+ const t = fiber.type;
355
+ const n = typeof t === 'function' ? (t.displayName || t.name || '') : '';
356
+ if (n && !names.includes(n)) names.push(n);
357
+ fiber = fiber.return; hops++;
358
+ }
359
+ chain = names.join(' < ');
360
+ }
361
+ return '<' + label + '>' + (chain ? ' (' + chain + ')' : '') + ': ' + where + ' changed';
362
+ }))(${JSON.stringify(value.regions)})`;
363
+ const hit = await connection.send(
364
+ "Runtime.evaluate",
365
+ { expression: hitTest, returnByValue: true },
366
+ pageSession
367
+ );
368
+ if (Array.isArray(hit.result?.value)) sentences = hit.result.value.map(String);
369
+ } catch {
370
+ }
371
+ return { pct: value.pct, sentences };
372
+ } catch {
373
+ return null;
374
+ } finally {
375
+ connection?.close();
376
+ child.kill("SIGKILL");
377
+ setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
378
+ }
379
+ }
240
380
  async function pixelDiffPct(chromePath, pngA, pngB) {
241
381
  const { child, wsUrl, profileDir } = await launchChrome(chromePath);
242
382
  let connection = null;
@@ -445,11 +585,51 @@ var WEBMCP_SHIM = `(() => {
445
585
  if (t && t.name) window.__squintWebMcp.push(t.name + (t.description ? ' \u2014 ' + t.description : ''));
446
586
  }
447
587
  };
448
- const target = navigator.modelContext || (navigator.modelContext = {});
449
- const provide = target.provideContext && target.provideContext.bind(target);
450
- target.provideContext = (params) => { record(params && params.tools); return provide ? provide(params) : undefined; };
451
- const register = target.registerTool && target.registerTool.bind(target);
452
- target.registerTool = (tool) => { record([tool]); return register ? register(tool) : undefined; };
588
+ const wrap = (target) => {
589
+ const provide = target.provideContext && target.provideContext.bind(target);
590
+ target.provideContext = (params) => { record(params && params.tools); return provide ? provide(params) : undefined; };
591
+ const register = target.registerTool && target.registerTool.bind(target);
592
+ target.registerTool = (tool) => { record([tool]); return register ? register(tool) : undefined; };
593
+ return target;
594
+ };
595
+ // The spec moved the API to document.modelContext (Chrome 150 drops
596
+ // the navigator location); shim both so either registration is seen.
597
+ wrap(document.modelContext || (document.modelContext = {}));
598
+ wrap(navigator.modelContext || (navigator.modelContext = {}));
599
+ })()`;
600
+ var LOAF_SHIM = `(() => {
601
+ window.__squintLoaf = [];
602
+ try {
603
+ new PerformanceObserver((list) => {
604
+ for (const e of list.getEntries()) {
605
+ if (e.duration < 50) continue;
606
+ const s = (e.scripts && e.scripts[0]) || {};
607
+ window.__squintLoaf.push({
608
+ duration: Math.round(e.duration),
609
+ fn: s.sourceFunctionName || '',
610
+ url: (s.sourceURL || s.name || '').split('/').pop() || '',
611
+ invoker: s.invoker || '',
612
+ });
613
+ }
614
+ }).observe({ type: 'long-animation-frame', buffered: true });
615
+ } catch {}
616
+ })()`;
617
+ var SCRIPTED_SCROLL = `(async () => {
618
+ const half = Math.max(0, (document.body.scrollHeight - innerHeight) / 2);
619
+ window.scrollTo({ top: half, behavior: 'smooth' });
620
+ await new Promise((r) => setTimeout(r, 400));
621
+ window.scrollTo({ top: 0, behavior: 'smooth' });
622
+ await new Promise((r) => setTimeout(r, 300));
623
+ const seen = new Map();
624
+ for (const e of window.__squintLoaf || []) {
625
+ const key = e.fn + '@' + e.url + '@' + e.invoker;
626
+ const prev = seen.get(key);
627
+ if (!prev || e.duration > prev.duration) seen.set(key, e);
628
+ }
629
+ return [...seen.values()]
630
+ .sort((a, b) => b.duration - a.duration)
631
+ .slice(0, 6)
632
+ .map((e) => e.duration + 'ms frame \u2014 ' + (e.fn || e.invoker || 'script') + (e.url ? ' @ ' + e.url : ''));
453
633
  })()`;
454
634
  async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, audit = false, checks = []) {
455
635
  const { child, wsUrl, profileDir } = await launchChrome(chromePath);
@@ -464,6 +644,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
464
644
  let components = [];
465
645
  const checkFailures = [];
466
646
  let webmcp = [];
647
+ let jank = [];
467
648
  const requests = /* @__PURE__ */ new Map();
468
649
  let connection = null;
469
650
  try {
@@ -503,6 +684,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
503
684
  await connection.send("Network.enable", {}, sessionId);
504
685
  await connection.send("Page.enable", {}, sessionId);
505
686
  await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBMCP_SHIM }, sessionId).catch(() => null);
687
+ await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: LOAF_SHIM }, sessionId).catch(() => null);
506
688
  await connection.send("Page.navigate", { url }, sessionId);
507
689
  const deadline = Date.now() + 12e3;
508
690
  while (!loaded && Date.now() < deadline) {
@@ -518,6 +700,15 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
518
700
  if (result?.value && typeof result.value === "object") perf = result.value;
519
701
  } catch {
520
702
  }
703
+ try {
704
+ const { result } = await connection.send(
705
+ "Runtime.evaluate",
706
+ { expression: SCRIPTED_SCROLL, returnByValue: true, awaitPromise: true },
707
+ sessionId
708
+ );
709
+ if (Array.isArray(result?.value)) jank = result.value.map(String);
710
+ } catch {
711
+ }
521
712
  try {
522
713
  const { result } = await connection.send(
523
714
  "Runtime.evaluate",
@@ -653,12 +844,13 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
653
844
  child.kill("SIGKILL");
654
845
  setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
655
846
  }
656
- return { report, shots, a11y, slop, perf, narration, phantoms, viewTransitions, components, checkFailures, webmcp };
847
+ return { report, shots, a11y, slop, perf, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank };
657
848
  }
658
849
 
659
850
  export {
660
851
  hasWebSocket,
661
852
  runFlow,
853
+ pixelDiffAttributed,
662
854
  pixelDiffPct,
663
855
  cdpCapture
664
856
  };