aipeek 0.2.9 → 0.2.10

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.
@@ -127,8 +127,8 @@ function detailPerformance(p) {
127
127
  const windowSec = (p.windowMs / 1e3).toFixed(1);
128
128
  lines.push(`=== Performance (${windowSec}s window) ===`);
129
129
  if (p.hiddenFrames > 0) {
130
- lines.push(`\u26A0 Tab was backgrounded \u2014 ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`);
131
- lines.push("For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing");
130
+ lines.push(`\u26A0 Tab was backgrounded or unfocused \u2014 ${p.hiddenFrames} throttled frames skipped (rAF throttling lies about timing)`);
131
+ lines.push("For accurate profiling: keep tab FOREGROUND + FOCUSED + run /profile/reset before reproducing");
132
132
  }
133
133
  lines.push(`MobX attribution: ${p.mobxPatched ? "ON" : "OFF"}`);
134
134
  if (!p.mobxPatched) {
@@ -182,7 +182,7 @@ function diffPerformance(before, after) {
182
182
  const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0;
183
183
  const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0;
184
184
  const framesExercised = bothSampledFrames && sawDrops;
185
- const afterBlind = (afterAll?.frames.total ?? 0) === 0 && afterSelf === 0;
185
+ const afterBlind = afterSelf === 0 && (beforeSelf > 0 || (afterAll?.frames.total ?? 0) === 0);
186
186
  let verdict;
187
187
  if (afterBlind) {
188
188
  verdict = "NO DATA";
@@ -198,7 +198,8 @@ function diffPerformance(before, after) {
198
198
  }
199
199
  out.push(`=== Perf diff: ${verdict} ===`);
200
200
  if (afterBlind) {
201
- out.push("after-window recorded no frames and no self-time \u2014 tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.");
201
+ const why = (afterAll?.frames.total ?? 0) === 0 ? "recorded no frames and no re-renders" : `recorded frames but zero re-renders (was ${Math.round(beforeSelf)}ms self-time)`;
202
+ out.push(`after-window ${why} \u2014 tab backgrounded or workload not reproduced. Re-run with the tab focused and the interaction repeated.`);
202
203
  out.push("");
203
204
  return out.join("\n").trimEnd();
204
205
  }
@@ -131,8 +131,8 @@ function detailPerformance(p) {
131
131
  const windowSec = (p.windowMs / 1e3).toFixed(1);
132
132
  lines.push(`=== Performance (${windowSec}s window) ===`);
133
133
  if (p.hiddenFrames > 0) {
134
- lines.push(`\u26A0 Tab was backgrounded \u2014 ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`);
135
- lines.push("For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing");
134
+ lines.push(`\u26A0 Tab was backgrounded or unfocused \u2014 ${p.hiddenFrames} throttled frames skipped (rAF throttling lies about timing)`);
135
+ lines.push("For accurate profiling: keep tab FOREGROUND + FOCUSED + run /profile/reset before reproducing");
136
136
  }
137
137
  lines.push(`MobX attribution: ${p.mobxPatched ? "ON" : "OFF"}`);
138
138
  if (!p.mobxPatched) {
@@ -186,7 +186,7 @@ function diffPerformance(before, after) {
186
186
  const bothSampledFrames = (_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _ => _.frames, 'access', _2 => _2.total]), () => ( 0))) > 0 && (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _3 => _3.frames, 'access', _4 => _4.total]), () => ( 0))) > 0;
187
187
  const sawDrops = (_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _5 => _5.frames, 'access', _6 => _6.long]), () => ( 0))) > 0 || (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _7 => _7.frames, 'access', _8 => _8.long]), () => ( 0))) > 0;
188
188
  const framesExercised = bothSampledFrames && sawDrops;
189
- const afterBlind = (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _9 => _9.frames, 'access', _10 => _10.total]), () => ( 0))) === 0 && afterSelf === 0;
189
+ const afterBlind = afterSelf === 0 && (beforeSelf > 0 || (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _9 => _9.frames, 'access', _10 => _10.total]), () => ( 0))) === 0);
190
190
  let verdict;
191
191
  if (afterBlind) {
192
192
  verdict = "NO DATA";
@@ -202,7 +202,8 @@ function diffPerformance(before, after) {
202
202
  }
203
203
  out.push(`=== Perf diff: ${verdict} ===`);
204
204
  if (afterBlind) {
205
- out.push("after-window recorded no frames and no self-time \u2014 tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.");
205
+ const why = (_nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _11 => _11.frames, 'access', _12 => _12.total]), () => ( 0))) === 0 ? "recorded no frames and no re-renders" : `recorded frames but zero re-renders (was ${Math.round(beforeSelf)}ms self-time)`;
206
+ out.push(`after-window ${why} \u2014 tab backgrounded or workload not reproduced. Re-run with the tab focused and the interaction repeated.`);
206
207
  out.push("");
207
208
  return out.join("\n").trimEnd();
208
209
  }
@@ -214,13 +215,13 @@ function diffPerformance(before, after) {
214
215
  out.push(`Longtasks: ${before.longtasks.count} \u2192 ${after.longtasks.count}`);
215
216
  }
216
217
  out.push("");
217
- const compDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _11 => _11.components]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _12 => _12.components]), () => ( {})));
218
+ const compDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _13 => _13.components]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _14 => _14.components]), () => ( {})));
218
219
  if (compDelta.length > 0) {
219
220
  out.push("Components (\u0394 total self-time):");
220
221
  for (const d of compDelta) out.push(" " + formatDelta(d));
221
222
  out.push("");
222
223
  }
223
- const lineDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _13 => _13.lines]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _14 => _14.lines]), () => ( {})));
224
+ const lineDelta = keyedTotalDelta(_nullishCoalesce(_optionalChain([beforeAll, 'optionalAccess', _15 => _15.lines]), () => ( {})), _nullishCoalesce(_optionalChain([afterAll, 'optionalAccess', _16 => _16.lines]), () => ( {})));
224
225
  if (lineDelta.length > 0) {
225
226
  out.push("Hot lines (\u0394 total inclusive time):");
226
227
  for (const d of lineDelta) out.push(" " + formatDelta(d));
@@ -231,8 +232,8 @@ function keyedTotalDelta(a, b) {
231
232
  const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
232
233
  const rows = [];
233
234
  for (const key of keys) {
234
- const beforeT = _nullishCoalesce(_optionalChain([a, 'access', _15 => _15[key], 'optionalAccess', _16 => _16.total]), () => ( 0));
235
- const afterT = _nullishCoalesce(_optionalChain([b, 'access', _17 => _17[key], 'optionalAccess', _18 => _18.total]), () => ( 0));
235
+ const beforeT = _nullishCoalesce(_optionalChain([a, 'access', _17 => _17[key], 'optionalAccess', _18 => _18.total]), () => ( 0));
236
+ const afterT = _nullishCoalesce(_optionalChain([b, 'access', _19 => _19[key], 'optionalAccess', _20 => _20.total]), () => ( 0));
236
237
  const delta = afterT - beforeT;
237
238
  if (Math.round(delta) === 0) continue;
238
239
  rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] });
@@ -349,7 +350,7 @@ function formatActions(entries, anchorTs) {
349
350
  }
350
351
  function appendAction(log, tab, entry, max) {
351
352
  const last = log[log.length - 1];
352
- if (entry.type === "input" && _optionalChain([last, 'optionalAccess', _19 => _19.type]) === "input" && last.tab === tab && last.target === entry.target) {
353
+ if (entry.type === "input" && _optionalChain([last, 'optionalAccess', _21 => _21.type]) === "input" && last.tab === tab && last.target === entry.target) {
353
354
  log[log.length - 1] = { ...entry, tab };
354
355
  return;
355
356
  }
@@ -360,7 +361,7 @@ function appendAction(log, tab, entry, max) {
360
361
  function uiChange(prev, cur) {
361
362
  if (cur.modal === void 0)
362
363
  return "";
363
- const prevModal = _nullishCoalesce(_optionalChain([prev, 'optionalAccess', _20 => _20.modal]), () => ( "none"));
364
+ const prevModal = _nullishCoalesce(_optionalChain([prev, 'optionalAccess', _22 => _22.modal]), () => ( "none"));
364
365
  if (cur.modal !== prevModal) {
365
366
  if (cur.modal === "none")
366
367
  return "\u5F39\u7A97\u5173\u95ED";
@@ -910,7 +911,7 @@ function emit(state) {
910
911
  for (const key of SECTIONS) {
911
912
  if (state[key]) {
912
913
  const countKey = COUNTED_SECTIONS[key];
913
- const count = countKey ? _nullishCoalesce(_optionalChain([state, 'access', _21 => _21.counts, 'optionalAccess', _22 => _22[countKey]]), () => ( 0)) : 0;
914
+ const count = countKey ? _nullishCoalesce(_optionalChain([state, 'access', _23 => _23.counts, 'optionalAccess', _24 => _24[countKey]]), () => ( 0)) : 0;
914
915
  const attr = count ? ` count="${count}"` : "";
915
916
  sections.push(`<${key}${attr}>
916
917
  ${state[key]}
@@ -1265,9 +1266,9 @@ function aipeekPlugin() {
1265
1266
  const prev = tabs.get(data.tab);
1266
1267
  tabs.set(data.tab, {
1267
1268
  id: data.tab,
1268
- url: _nullishCoalesce(_nullishCoalesce(data.url, () => ( _optionalChain([prev, 'optionalAccess', _23 => _23.url]))), () => ( "")),
1269
- title: _nullishCoalesce(_nullishCoalesce(data.title, () => ( _optionalChain([prev, 'optionalAccess', _24 => _24.title]))), () => ( "")),
1270
- visible: _nullishCoalesce(_nullishCoalesce(data.visible, () => ( _optionalChain([prev, 'optionalAccess', _25 => _25.visible]))), () => ( false)),
1269
+ url: _nullishCoalesce(_nullishCoalesce(data.url, () => ( _optionalChain([prev, 'optionalAccess', _25 => _25.url]))), () => ( "")),
1270
+ title: _nullishCoalesce(_nullishCoalesce(data.title, () => ( _optionalChain([prev, 'optionalAccess', _26 => _26.title]))), () => ( "")),
1271
+ visible: _nullishCoalesce(_nullishCoalesce(data.visible, () => ( _optionalChain([prev, 'optionalAccess', _27 => _27.visible]))), () => ( false)),
1271
1272
  lastSeen: Date.now()
1272
1273
  });
1273
1274
  }
@@ -1276,7 +1277,7 @@ function aipeekPlugin() {
1276
1277
  const actionLog = [];
1277
1278
  const BOOT_ID = Date.now().toString(36);
1278
1279
  const mergeActions = (tab, actions) => {
1279
- if (!tab || !_optionalChain([actions, 'optionalAccess', _26 => _26.length]))
1280
+ if (!tab || !_optionalChain([actions, 'optionalAccess', _28 => _28.length]))
1280
1281
  return;
1281
1282
  for (const entry of actions) {
1282
1283
  if (!actionLog.some((e) => e.tab === tab && e.ts === entry.ts))
@@ -1521,7 +1522,7 @@ function aipeekPlugin() {
1521
1522
  const r = await fetch(target, { method: req.method, body });
1522
1523
  send(res, r.status, await r.text());
1523
1524
  } catch (e) {
1524
- const code = _optionalChain([e, 'access', _27 => _27.cause, 'optionalAccess', _28 => _28.code]);
1525
+ const code = _optionalChain([e, 'access', _29 => _29.cause, 'optionalAccess', _30 => _30.code]);
1525
1526
  const why = code === "ECONNREFUSED" ? `nothing is listening on ${host} \u2014 its dev server isn't running (start it), or the port is wrong.` : code === "ENOTFOUND" ? `host '${host}' doesn't resolve \u2014 check the hostname.` : code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT" ? `connection to ${host} timed out \u2014 it's unreachable (firewall, or wrong host).` : `${e.message} (code ${_nullishCoalesce(code, () => ( "unknown"))}).`;
1526
1527
  send(res, 502, `cannot reach aipeek peer at ${host}: ${why}`);
1527
1528
  }
package/dist/index.cjs CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
 
7
7
 
8
- var _chunk7ALIH3JXcjs = require('./chunk-7ALIH3JX.cjs');
8
+ var _chunkNJ3KST7Pcjs = require('./chunk-NJ3KST7P.cjs');
9
9
  require('./chunk-Z2Y65YOY.cjs');
10
10
 
11
11
 
@@ -14,4 +14,4 @@ require('./chunk-Z2Y65YOY.cjs');
14
14
 
15
15
 
16
16
 
17
- exports.aipeekPlugin = _chunk7ALIH3JXcjs.aipeekPlugin; exports.check = _chunk7ALIH3JXcjs.check; exports.diffState = _chunk7ALIH3JXcjs.diffState; exports.emitCheck = _chunk7ALIH3JXcjs.emitCheck; exports.emitDiff = _chunk7ALIH3JXcjs.emitDiff; exports.emitSummary = _chunk7ALIH3JXcjs.emitSummary;
17
+ exports.aipeekPlugin = _chunkNJ3KST7Pcjs.aipeekPlugin; exports.check = _chunkNJ3KST7Pcjs.check; exports.diffState = _chunkNJ3KST7Pcjs.diffState; exports.emitCheck = _chunkNJ3KST7Pcjs.emitCheck; exports.emitDiff = _chunkNJ3KST7Pcjs.emitDiff; exports.emitSummary = _chunkNJ3KST7Pcjs.emitSummary;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  emitCheck,
6
6
  emitDiff,
7
7
  emitSummary
8
- } from "./chunk-7NJSWR7E.js";
8
+ } from "./chunk-FBY5P5KH.js";
9
9
  export {
10
10
  aipeekPlugin,
11
11
  check,
package/dist/plugin.cjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
 
6
6
 
7
- var _chunk7ALIH3JXcjs = require('./chunk-7ALIH3JX.cjs');
7
+ var _chunkNJ3KST7Pcjs = require('./chunk-NJ3KST7P.cjs');
8
8
  require('./chunk-Z2Y65YOY.cjs');
9
9
 
10
10
 
@@ -12,4 +12,4 @@ require('./chunk-Z2Y65YOY.cjs');
12
12
 
13
13
 
14
14
 
15
- exports.END_TAG = _chunk7ALIH3JXcjs.END_TAG; exports.START_TAG = _chunk7ALIH3JXcjs.START_TAG; exports.aipeekPlugin = _chunk7ALIH3JXcjs.aipeekPlugin; exports.injectClaudeMd = _chunk7ALIH3JXcjs.injectClaudeMd; exports.renderClaudeMd = _chunk7ALIH3JXcjs.renderClaudeMd;
15
+ exports.END_TAG = _chunkNJ3KST7Pcjs.END_TAG; exports.START_TAG = _chunkNJ3KST7Pcjs.START_TAG; exports.aipeekPlugin = _chunkNJ3KST7Pcjs.aipeekPlugin; exports.injectClaudeMd = _chunkNJ3KST7Pcjs.injectClaudeMd; exports.renderClaudeMd = _chunkNJ3KST7Pcjs.renderClaudeMd;
package/dist/plugin.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  aipeekPlugin,
5
5
  injectClaudeMd,
6
6
  renderClaudeMd
7
- } from "./chunk-7NJSWR7E.js";
7
+ } from "./chunk-FBY5P5KH.js";
8
8
  export {
9
9
  END_TAG,
10
10
  START_TAG,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aipeek",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Gives AI a peek into your running browser app — UI tree, console, network, errors, state",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -129,16 +129,19 @@ function hitFrame(dt: number) {
129
129
  }
130
130
  }
131
131
 
132
- // (b) Frame loop — zero-coupling, holds for any app. document.hidden guard: backgrounded rAF
133
- // throttles to ~1fps, so each hidden frame looks like a 1000ms "dropped" frame and lies.
134
- // Don't count dt while hidden; tally hiddenFrames so the report can flag it.
132
+ // (b) Frame loop — zero-coupling, holds for any app. Throttle guard: a backgrounded OR unfocused
133
+ // window throttles rAF to ~1fps, so each such frame looks like a ~1000ms "dropped" frame and lies.
134
+ // document.hidden only fires when the TAB is switched away; a window that's merely unfocused
135
+ // (another app has focus, minimized, fully occluded) keeps document.hidden=false yet the OS still
136
+ // throttles its rAF — same lie, different probe. document.hasFocus() catches that second case.
137
+ // Don't count dt while throttled; tally hiddenFrames so the report can flag it.
135
138
  const WARN_THRESHOLD = 20 // dropped% — push warning when ≥20% frames jank
136
139
  const WARN_COOLDOWN = 5000 // ms — don't spam, at most one warn per 5s
137
140
  let lastFrame = performance.now()
138
141
  let lastWarn = 0
139
142
  function frameLoop() {
140
143
  const now = performance.now()
141
- if (document.hidden) {
144
+ if (document.hidden || !document.hasFocus()) {
142
145
  perf.hiddenFrames += 1
143
146
  lastFrame = now
144
147
  requestAnimationFrame(frameLoop)
@@ -775,16 +775,41 @@ if (import.meta.hot) {
775
775
  hello()
776
776
  addEventListener('visibilitychange', hello)
777
777
 
778
- // WS-death beacon for the HTTP heartbeat in client-patch. The whole action chain rides this
779
- // HMR socket; when it drops, Vite's own reconnect (client.mjs waitForSuccessfulPing) is
780
- // GATED on document.visibilityState === 'visible' so a backgrounded Electron window (we
781
- // app.hide() on launch, and backgroundThrottling throttles it) never reconnects and never
782
- // reloads, stranding the chain on a dead socket while BOOT_ID is unchanged. client-patch
783
- // can't see import.meta.hot (it's a non-module inline script), so we surface liveness as a
784
- // global flag it polls: WS dead + ping OK + same BOOT_ID it forces the reload Vite won't.
785
- import.meta.hot.on('vite:ws:disconnect', () => { (window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ = true })
778
+ // Self-heal a dropped HMR socket. The whole action chain rides this WS; when it drops,
779
+ // Vite's own reconnect (client.mjs waitForSuccessfulPing) is GATED on visibilityState ===
780
+ // 'visible', so a backgrounded tab/window (Electron we app.hide() on launch) never reconnects
781
+ // and never reloads stranding the chain on a dead socket while BOOT_ID is unchanged.
782
+ //
783
+ // The fix is EVENT-DRIVEN, not polled. An earlier version set a flag for the client-patch
784
+ // HTTP heartbeat to notice but a long-lived background setInterval is exactly what the
785
+ // browser throttles/freezes (verified: a page-load-time interval stops firing in a long-
786
+ // backgrounded tab, while the WS message channel stays instantly live). So we act the moment
787
+ // vite:ws:disconnect fires: kick off a short self-bound setTimeout poll (armed by the event,
788
+ // not a resident timer) that waits for /ping to answer, then reloads to re-handshake. We also
789
+ // keep the global flag for the heartbeat as a belt-and-suspenders second trigger.
790
+ const wsDead = (v: boolean) => { (window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ = v }
791
+ let healing = false
792
+ const healAfterDrop = async () => {
793
+ if (healing) return
794
+ healing = true
795
+ // Poll the HTTP heartbeat (independent of the dead WS). Once the server answers, reload.
796
+ // setTimeout (re-armed per tick) rather than setInterval: a fresh timer scheduled right
797
+ // after live activity resists the background freeze that kills resident intervals.
798
+ const tick = async () => {
799
+ try {
800
+ await fetch('/__aipeek/ping', { cache: 'no-store' })
801
+ location.reload() // server reachable again → re-handshake the WS via a fresh load
802
+ }
803
+ catch {
804
+ setTimeout(tick, 2000) // server still down — keep waiting, don't reload on failure
805
+ }
806
+ }
807
+ tick()
808
+ }
809
+ import.meta.hot.on('vite:ws:disconnect', () => { wsDead(true); healAfterDrop() })
786
810
  import.meta.hot.on('vite:ws:connect', () => {
787
- ;(window as { __aipeek_ws_dead__?: boolean }).__aipeek_ws_dead__ = false
811
+ wsDead(false)
812
+ healing = false
788
813
  hello() // socket came back without a page reload — re-register so roster doesn't go stale
789
814
  })
790
815
 
package/src/core/perf.ts CHANGED
@@ -77,8 +77,8 @@ export function detailPerformance(p: PerformanceData): string {
77
77
  const windowSec = (p.windowMs / 1000).toFixed(1)
78
78
  lines.push(`=== Performance (${windowSec}s window) ===`)
79
79
  if (p.hiddenFrames > 0) {
80
- lines.push(`⚠ Tab was backgrounded — ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`)
81
- lines.push('For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing')
80
+ lines.push(`⚠ Tab was backgrounded or unfocused — ${p.hiddenFrames} throttled frames skipped (rAF throttling lies about timing)`)
81
+ lines.push('For accurate profiling: keep tab FOREGROUND + FOCUSED + run /profile/reset before reproducing')
82
82
  }
83
83
  lines.push(`MobX attribution: ${p.mobxPatched ? 'ON' : 'OFF'}`)
84
84
  if (!p.mobxPatched) {
@@ -175,12 +175,16 @@ export function diffPerformance(before: PerformanceData, after: PerformanceData)
175
175
  const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0
176
176
  const framesExercised = bothSampledFrames && sawDrops
177
177
 
178
- // Blind-window guard: a backgrounded after-tab records NEITHER frames (rAF frozen) NOR
179
- // self-time (nothing re-renders, so Reaction.track never fires). Both signals are absent, so
180
- // selfDelta = 0 - beforeSelf is a large negative → a FALSE "IMPROVED" off an empty window.
181
- // (The fallback comment above assumed self-time survives backgrounding; live data disproved it
182
- // a hidden tab renders nothing.) No data is not a win: refuse to render any direction.
183
- const afterBlind = (afterAll?.frames.total ?? 0) === 0 && afterSelf === 0
178
+ // Blind-window guard: the after-window must actually REPRODUCE the workload to be diffable.
179
+ // afterSelf===0 means zero components re-rendered. Two shapes both mean "workload not run":
180
+ // (a) hard-blind frames.total===0 too (rAF frozen, tab fully suspended);
181
+ // (b) soft-blind — frames still ticked (rAF only throttled) but self-time is zero while the
182
+ // before-window had real self-time. Frames alone then describe an idle tab's compositor,
183
+ // not the render workload a -5% frame delta off a backgrounded tab is NOT a code win.
184
+ // A genuine fix shows frames recovering AND self-time dropping in agreement; "frames still
185
+ // dropping but self-time vanished entirely" is the contradiction that betrays an idle after-tab.
186
+ // The pure-paint case (beforeSelf===0===afterSelf, frames>0) is NOT blind — it goes to frames.
187
+ const afterBlind = afterSelf === 0 && (beforeSelf > 0 || (afterAll?.frames.total ?? 0) === 0)
184
188
  let verdict: string
185
189
  if (afterBlind) {
186
190
  verdict = 'NO DATA'
@@ -197,7 +201,10 @@ export function diffPerformance(before: PerformanceData, after: PerformanceData)
197
201
 
198
202
  out.push(`=== Perf diff: ${verdict} ===`)
199
203
  if (afterBlind) {
200
- out.push('after-window recorded no frames and no self-time — tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.')
204
+ const why = (afterAll?.frames.total ?? 0) === 0
205
+ ? 'recorded no frames and no re-renders'
206
+ : `recorded frames but zero re-renders (was ${Math.round(beforeSelf)}ms self-time)`
207
+ out.push(`after-window ${why} — tab backgrounded or workload not reproduced. Re-run with the tab focused and the interaction repeated.`)
201
208
  out.push('')
202
209
  return out.join('\n').trimEnd()
203
210
  }