@flemo/devtools 0.1.0 → 0.2.1

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
@@ -13,6 +13,46 @@ leave a toggle set?").
13
13
  Zero dependencies. No imports from `@flemo/core` or `@flemo/react`; attaching
14
14
  the recorder never changes the motion it measures.
15
15
 
16
+ ## Production safety
17
+
18
+ `@flemo/devtools` resolves to an inert entry when your bundler builds for
19
+ production, so the ordinary import is the safe one:
20
+
21
+ ```ts
22
+ import { attachFlightRecorder } from "@flemo/devtools";
23
+
24
+ // Development: the real recorder. Production: a no-op that records nothing
25
+ // and whose implementation never enters the bundle.
26
+ const { detach } = attachFlightRecorder({ log: true });
27
+ ```
28
+
29
+ This is done with the `development` / `production` export conditions rather
30
+ than left to the caller, because the failure is silent: a normal import of a
31
+ dev-time tool builds clean, warns about nothing, and ships to every visitor.
32
+ It happened to this project — the recorder's strings were found in a
33
+ production chunk of flemo.dev and had to be removed.
34
+
35
+ Two things to know:
36
+
37
+ - **Not every bundler sets those conditions.** Vite and Next do. If yours does
38
+ not, keep the module behind a dynamic import guarded by a build-time
39
+ constant (`process.env.NODE_ENV !== "production"`, Vite's
40
+ `import.meta.env.DEV`) so the branch — and the module behind it — is
41
+ eliminated. There is no automatic fallback for that case, and the shape of
42
+ this package is why: `@tanstack/react-query-devtools` gets one by importing
43
+ its implementation statically and swapping it on `process.env.NODE_ENV`, so
44
+ the bundler folds the constant and drops the now-unreferenced module. That
45
+ works because their implementation stays a separate module file. Ours cannot
46
+ — `dist/index.mjs` is self-contained on purpose, so it can be loaded
47
+ directly in a page — and measured with esbuild (which sets neither
48
+ condition), the same swap strips nothing: production and development bundles
49
+ came out byte-identical in size with the recorder present in both. The guard
50
+ is the answer there, not a trick inside the package.
51
+ - **`@flemo/devtools/force` is the escape hatch.** It resolves to the real
52
+ tool whatever the build mode, for when you deliberately want the recorder in
53
+ a production build (a staging deploy, an e2e suite that must run against a
54
+ production build). Import it dynamically behind your own opt-in flag.
55
+
16
56
  ## Keeping it out of your production bundle
17
57
 
18
58
  Install it as a **devDependency** — and know that this alone is not enough.
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.mjs CHANGED
@@ -186,13 +186,13 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
186
186
  key: "flemo:layers",
187
187
  storage: "session",
188
188
  kind: "opt-in-diagnostic",
189
- description: "resident screen layers at rest (armed by ?flemo-layers=)"
189
+ description: "resident screen layers at rest"
190
190
  },
191
191
  {
192
192
  key: "flemo:freeze",
193
193
  storage: "session",
194
194
  kind: "opt-in-diagnostic",
195
- description: "keep the direct prev screen live (armed by ?flemo-freeze=)"
195
+ description: "keep the direct prev screen live"
196
196
  },
197
197
  {
198
198
  key: "flemo:preraster",
@@ -403,20 +403,20 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
403
403
  for (let t of e.imagesTracked) t.hasAttribute(L) && e.imagesHeld.add(t);
404
404
  }, G = (e, t) => {
405
405
  e.releasedFrames += 1;
406
- let n = !1;
406
+ let n = !1, r = !1;
407
407
  for (let t of e.elements) {
408
- let r = e.clocks.get(t);
409
- if (r) {
410
- r.playState === "paused" && (e.pausedAfterRelease = !0);
411
- let i = typeof r.currentTime == "number" ? r.currentTime : null;
412
- if (i !== null) {
408
+ let i = e.clocks.get(t);
409
+ if (i) {
410
+ i.playState === "paused" && (e.pausedAfterRelease = !0), i.playState !== "finished" && (r = !0);
411
+ let a = typeof i.currentTime == "number" ? i.currentTime : null;
412
+ if (a !== null) {
413
413
  let r = e.lastClock.get(t);
414
- r !== void 0 && i !== r && (n = !0), e.lastClock.set(t, i);
414
+ r !== void 0 && a !== r && (n = !0), e.lastClock.set(t, a);
415
415
  }
416
416
  }
417
- let i = t.style;
418
- if (i && i.animation !== "") {
419
- let r = `${i.transform}/${i.opacity}`;
417
+ let a = t.style;
418
+ if (a && a.animation !== "") {
419
+ let r = `${a.transform}/${a.opacity}`;
420
420
  e.lastPose.get(t) !== r && (n = !0);
421
421
  }
422
422
  }
@@ -425,6 +425,10 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
425
425
  e.stallRunMs = 0;
426
426
  return;
427
427
  }
428
+ if (!r && e.clocks.size > 0) {
429
+ e.tailFrames += 1, e.stallRunMs = 0;
430
+ return;
431
+ }
428
432
  e.stalledFrames += 1, e.stallRunMs += t, e.stallRunMs > e.longestStallMs && (e.longestStallMs = e.stallRunMs);
429
433
  }
430
434
  }, K = (e) => {
@@ -507,6 +511,7 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
507
511
  clocks: /* @__PURE__ */ new Map(),
508
512
  lastClock: /* @__PURE__ */ new Map(),
509
513
  releasedFrames: 0,
514
+ tailFrames: 0,
510
515
  stalledFrames: 0,
511
516
  stallRunMs: 0,
512
517
  longestStallMs: 0,
@@ -582,7 +587,8 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
582
587
  stalledFrames: i.stalledFrames,
583
588
  longestStallMs: z(i.longestStallMs),
584
589
  pausedAfterRelease: i.pausedAfterRelease,
585
- holdReassertedAtMs: i.holdReassertedAtMs
590
+ holdReassertedAtMs: i.holdReassertedAtMs,
591
+ tailFrames: i.tailFrames
586
592
  },
587
593
  images: H(i),
588
594
  ...a ? { playerGaps: a } : {},
@@ -708,7 +714,8 @@ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = (e) => {
708
714
  stalledFrames: r.stalledFrames,
709
715
  longestStallMs: z(r.longestStallMs),
710
716
  pausedAfterRelease: r.pausedAfterRelease,
711
- holdReassertedAtMs: r.holdReassertedAtMs
717
+ holdReassertedAtMs: r.holdReassertedAtMs,
718
+ tailFrames: r.tailFrames
712
719
  },
713
720
  images: H(r),
714
721
  longTasks: [],
package/dist/noop.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { DevtoolsPanelHandle, DevtoolsPanelOptions } from './panel';
2
+ import { FlightRecorderHandle, FlightRecorderOptions } from './types';
3
+ export * from './anomalies';
4
+ export * from './blindSpots';
5
+ export * from './environment';
6
+ export * from './overrides';
7
+ export * from './sampling';
8
+ export declare const REPORT_SCHEMA_VERSION = "2";
9
+ export declare const attachFlightRecorder: (_options?: FlightRecorderOptions) => FlightRecorderHandle;
10
+ export declare const attachDevtoolsPanel: (_options?: DevtoolsPanelOptions) => DevtoolsPanelHandle;
11
+ export type { FlemoGlobal } from './recorder';
12
+ export type { DevtoolsPanelHandle, DevtoolsPanelOptions } from './panel';
13
+ export type { DriverPolicySection, EnvironmentFingerprint, FlemoReport, FlightDriver, FlightHolds, FlightKind, FlightParticipants, FlightRecord, FlightRecorderHandle, FlightRecorderOptions, FlightTimestamp, FramePhaseStats, FrameSampleStats, LandingAudit, LongTaskSpan, ObservationCapabilities, OverridesSection, PlayerGapStats, UaBrand } from './types';
package/dist/noop.mjs ADDED
@@ -0,0 +1,356 @@
1
+ //#region src/anomalies.ts
2
+ var e = 30, t = 1e4, n = 50, r = 120, i = 100, a = 48, o = (e) => {
3
+ let n = [], { t0Ms: r, driver: i, frameSamples: a, playerGaps: o, longTasks: s, holdLongTasks: c, landing: l } = e, u = r + (e.releasedAtMs ?? 0);
4
+ if (o && o.over30Count > 0 && n.push(`player frame gap up to ${o.maxMs}ms ×${o.over30Count} during flight (the rAF player missed frames — convergence jank on this navigation)`), a.released.over30Count > 0) {
5
+ let e = i === "compiled" ? " (compiled/compositor flights can still present cleanly through main-thread gaps)" : "";
6
+ n.push(`main-thread rAF gap up to ${a.released.maxGapMs}ms ×${a.released.over30Count} during visible motion${e}`);
7
+ }
8
+ for (let e of s) e.startMs <= u + 120 && e.startMs + e.durationMs >= u - 50 ? n.push(`long task ${Math.round(e.durationMs)}ms overlapped the visible-motion start (opening-swallow risk: the first presented frames of the transition may have been lost)`) : e.durationMs >= 100 && n.push(`long task ${Math.round(e.durationMs)}ms mid-flight`);
9
+ for (let e of c) e.durationMs >= 100 && n.push(`long task ${Math.round(e.durationMs)}ms absorbed by the hold (screen posed, not yet moving — the hold doing its job, not user-visible jank)`);
10
+ let { motion: d, images: f } = e;
11
+ return d.holdReassertedAtMs !== null && n.push(`hold re-asserted ${d.holdReassertedAtMs}ms into the flight, after it had already released (an interleaved commit wrote the stale paused hold attribute over a running flight — the flemo 2026-08-18 release-race signature; the motion pauses while every timing metric stays clean)`), d.pausedAfterRelease && n.push("compiled animation reported playState=paused after its release (the flight was posed and then stopped mid-motion, not merely starved of frames)"), d.longestStallMs >= 48 && n.push(`motion stalled ${d.longestStallMs}ms mid-flight (${d.stalledFrames}/${d.sampledFrames} released frames advanced neither the animation clock nor the pose — freeze, or freeze-then-leap if the flight still landed on time)`), d.sampledFrames === 0 && e.releasedAtMs !== null && n.push("no released frames were sampled (the flight ended at or before its own hold release — the visible motion, if any, was never observed)"), f.completedUnheld > 0 && n.push(`${f.completedUnheld} image(s) finished loading mid-flight without a hold (${f.completedDuringFlight} completed, ${f.heldDuringFlight} held; ${f.loadingAtStart} were loading at t0, ${f.addedDuringFlight} arrived mid-flight) — each decode rasters on the moving layer and costs a present; this is the warm-side image-hold regression`), l.orphanedHolds.length > 0 && n.push(`hold markers left on the page at rest: ${l.orphanedHolds.join("; ")} (whatever they hide has no owner left to reveal it — the permanently-blank-avatar class)`), l.residualInlineTransforms.length > 0 && n.push(`residual inline style after COMPLETED: ${l.residualInlineTransforms.join("; ")} (landing cleanup failure — the landed scope belongs to the compiled rest rules)`), l.offViewportAtRest && n.push("screen resting at from-pose while COMPLETED+active (blank-viewport signature — the flemo PR #259 class: a residual pose left the landed screen parked off-viewport)"), l.stuckStatuses.length > 0 && n.push(`transitional status stuck >${t / 1e3}s: ${l.stuckStatuses.join(", ")} (navigation queue lock or missed animationend — later navigations will be swallowed)`), i === "unknown" && n.push("driver could not be classified (no running flemo-* CSSAnimation and no player inline-style signature observed — zero-duration flight, or the sampler attached after motion ended)"), n;
12
+ }, s = (e) => {
13
+ let n = [];
14
+ if (e.forcePin !== null && n.push(`active force pin flemo:motion-driver-force=${e.forcePin} — every transition this session is pinned to one driver (A/B residue? pins expire after 24h; clear the key before judging behavior)`), e.clearedForcePin !== null && e.forcePin === null && n.push(`force pin flemo:motion-driver-force=${e.clearedForcePin} was present at attach and has since been cleared (the library strips malformed/expired pins on sight) — early driver decisions in this session may still have been shaped by it`), e.legacyLocalForcePin !== null && n.push(`legacy localStorage driver pin present (flemo:motion-driver-force=${e.legacyLocalForcePin}) — never honored, but it marks A/B residue on this profile`), e.emulationSuspected) {
15
+ let t = /Win/i.test(e.platform) ? " (Windows touch hardware makes this signal ambiguous — confirm the DevTools device toolbar state)" : "";
16
+ n.push("DevTools device emulation suspected — the page composites to a rescaled surface, so visual reports from this session are untrustworthy; judge motion in a plain window or on a real device" + t);
17
+ }
18
+ return e.stuckFlightOpen && n.push(`a flight is still transitional after ${t / 1e3}s — the navigation queue is likely locked; subsequent navigations will be ignored`), e.flightAnomalies.some((e) => e.some((e) => e.includes("blank-viewport"))) && n.push("at least one flight landed with the blank-viewport signature (see that flight's anomalies)"), n;
19
+ }, c = [
20
+ "macOS Chrome present-pipeline pacing: on 120Hz ProMotion displays Chrome can present frames unevenly even when rAF cadence and this report read perfectly clean (Chromium issues 40062488 / 345275139; proven with a no-script pure-CSS control page that still juddered). Not diagnosable or fixable from page code.",
21
+ "Display-hardware effects: local dimming, backlight modulation, and panel overdrive alter perceived motion and are invisible to every web API.",
22
+ "Compositor-internal present skips: a frame can be produced on time (clean rAF, clean player gaps) yet dropped or delayed inside the compositor's present queue; only platform tools (about:tracing, Instruments) see that layer.",
23
+ "DevTools device emulation composites the page to a rescaled surface: every in-page instrument reads the pre-scale surface and reports clean while the eye watches the post-scale one, so emulated sessions grow shimmer/flash artifacts that do not exist in a plain window or on a real device."
24
+ ], l = () => {
25
+ if (typeof navigator > "u") return null;
26
+ let e = navigator.userAgentData;
27
+ return !e || !Array.isArray(e.brands) ? null : e.brands.map((e) => ({
28
+ brand: String(e.brand ?? ""),
29
+ version: String(e.version ?? "")
30
+ }));
31
+ }, u = () => {
32
+ if (typeof navigator > "u") return "unknown";
33
+ let e = l();
34
+ if (e?.some((e) => /Chromium/i.test(e.brand))) return "blink";
35
+ let t = navigator.userAgent ?? "";
36
+ return /Firefox|FxiOS/i.test(t) ? "gecko" : e || /AppleWebKit/i.test(t) && !/Chrome|Chromium|Android/i.test(t) ? "webkit" : /Android/i.test(t) ? "blink" : "unknown";
37
+ }, d = () => {
38
+ if (typeof navigator > "u" || u() !== "blink") return !1;
39
+ let e = navigator.platform ?? "";
40
+ return /Mac|Win|Linux/i.test(e) ? /Android/i.test(navigator.userAgent ?? "") && !/Mac|Win/i.test(e) ? /Linux (x86_64|i686)/i.test(e) : (navigator.maxTouchPoints ?? 0) > 0 : !1;
41
+ }, f = (e = 20) => new Promise((t) => {
42
+ let n = Number.isFinite(e) ? Math.max(1, Math.floor(e)) : 20;
43
+ if (typeof requestAnimationFrame != "function" || typeof performance > "u") {
44
+ t({
45
+ medianGapMs: null,
46
+ sampleCount: 0
47
+ });
48
+ return;
49
+ }
50
+ let r = [], i = null, a = () => {
51
+ let e = performance.now();
52
+ if (i !== null && r.push(e - i), i = e, r.length >= n) {
53
+ let e = [...r].sort((e, t) => e - t), n = e[Math.floor(e.length / 2)];
54
+ t({
55
+ medianGapMs: Math.round(n * 100) / 100,
56
+ sampleCount: r.length
57
+ });
58
+ return;
59
+ }
60
+ requestAnimationFrame(a);
61
+ };
62
+ requestAnimationFrame(a);
63
+ }), p = () => {
64
+ try {
65
+ return typeof PerformanceObserver < "u" && Array.isArray(PerformanceObserver.supportedEntryTypes) && PerformanceObserver.supportedEntryTypes.includes("longtask");
66
+ } catch {
67
+ return !1;
68
+ }
69
+ }, m = (e) => {
70
+ let t = typeof navigator < "u" ? navigator : null, n = typeof window < "u" ? window : null, r = !1;
71
+ try {
72
+ r = n?.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? !1;
73
+ } catch {
74
+ r = !1;
75
+ }
76
+ return {
77
+ userAgent: t?.userAgent ?? "",
78
+ uaBrands: l(),
79
+ engine: u(),
80
+ platform: t?.platform ?? "",
81
+ maxTouchPoints: t?.maxTouchPoints ?? 0,
82
+ devicePixelRatio: n?.devicePixelRatio ?? 1,
83
+ screen: {
84
+ width: n?.screen?.width ?? 0,
85
+ height: n?.screen?.height ?? 0
86
+ },
87
+ viewport: {
88
+ width: n?.innerWidth ?? 0,
89
+ height: n?.innerHeight ?? 0
90
+ },
91
+ visualViewportScale: n?.visualViewport?.scale ?? null,
92
+ rafCadence: e,
93
+ reducedMotion: r,
94
+ emulationSuspected: d(),
95
+ observation: {
96
+ longTasks: p(),
97
+ elementAnimations: typeof Element < "u" && typeof Element.prototype.getAnimations == "function",
98
+ playerGapMirror: Array.isArray(n?.__flemoPlayerGaps)
99
+ }
100
+ };
101
+ }, h = [
102
+ {
103
+ key: "flemo:motion-driver",
104
+ storage: "local",
105
+ kind: "production-state",
106
+ description: "learned driver ledger ('css' = player demoted on this device, 'raf' = clean probe)"
107
+ },
108
+ {
109
+ key: "flemo:motion-driver-force",
110
+ storage: "session",
111
+ kind: "opt-in-diagnostic",
112
+ description: "hard driver pin ('css@<epoch-ms>' / 'raf@<epoch-ms>', 24h TTL) — pins EVERY transition"
113
+ },
114
+ {
115
+ key: "flemo:lpm",
116
+ storage: "session",
117
+ kind: "production-state",
118
+ description: "low-power-mode cadence seed (learned)"
119
+ },
120
+ {
121
+ key: "flemo:sixty",
122
+ storage: "session",
123
+ kind: "production-state",
124
+ description: "steady-60 display verdict seed ('high' latches, a count seeds the streak)"
125
+ },
126
+ {
127
+ key: "flemo:lat",
128
+ storage: "session",
129
+ kind: "production-state",
130
+ description: "LPM release-latency seed (learned)"
131
+ },
132
+ {
133
+ key: "flemo:landing-snap",
134
+ storage: "session",
135
+ kind: "opt-in-diagnostic",
136
+ description: "Blink landing pixel-snap easing A/B"
137
+ },
138
+ {
139
+ key: "flemo:imghold",
140
+ storage: "session",
141
+ kind: "opt-in-diagnostic",
142
+ description: "flight-scoped <img> reveal hold"
143
+ },
144
+ {
145
+ key: "flemo:settle-gate",
146
+ storage: "session",
147
+ kind: "production-default-with-override",
148
+ description: "render-settle entry gate override"
149
+ },
150
+ {
151
+ key: "flemo:handoff",
152
+ storage: "session",
153
+ kind: "opt-in-diagnostic",
154
+ description: "anchored-opening handoff (POP-scoped)"
155
+ },
156
+ {
157
+ key: "flemo:handoffms",
158
+ storage: "session",
159
+ kind: "opt-in-diagnostic",
160
+ description: "handoff point override (ms)"
161
+ },
162
+ {
163
+ key: "flemo:apply",
164
+ storage: "session",
165
+ kind: "opt-in-diagnostic",
166
+ description: "force the scrub-WAAPI value-application tier"
167
+ },
168
+ {
169
+ key: "flemo:snap",
170
+ storage: "session",
171
+ kind: "production-default-with-override",
172
+ description: "player device-pixel snap policy override"
173
+ },
174
+ {
175
+ key: "flemo:snapband",
176
+ storage: "session",
177
+ kind: "opt-in-diagnostic",
178
+ description: "'hybrid' snap jitter-band width (device px)"
179
+ },
180
+ {
181
+ key: "flemo:layers",
182
+ storage: "session",
183
+ kind: "opt-in-diagnostic",
184
+ description: "resident screen layers at rest"
185
+ },
186
+ {
187
+ key: "flemo:freeze",
188
+ storage: "session",
189
+ kind: "opt-in-diagnostic",
190
+ description: "keep the direct prev screen live"
191
+ },
192
+ {
193
+ key: "flemo:preraster",
194
+ storage: "session",
195
+ kind: "opt-in-diagnostic",
196
+ description: "promote the entering content layer through the hold"
197
+ },
198
+ {
199
+ key: "flemo:imgoffload",
200
+ storage: "session",
201
+ kind: "production-default-with-override",
202
+ description: "image decode offloader override"
203
+ },
204
+ {
205
+ key: "flemo:devtools",
206
+ storage: "session",
207
+ kind: "opt-in-diagnostic",
208
+ description: "arms this flight recorder in the playground (?devtools=on)"
209
+ }
210
+ ], g = "flemo:motion-driver-force", _ = `${g} (localStorage — legacy location)`, v = (e) => {
211
+ try {
212
+ let t = e === "session" ? sessionStorage : localStorage;
213
+ return t.length, t;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }, y = (e, t) => {
218
+ try {
219
+ return e ? e.getItem(t) : null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }, b = new Set(h.map((e) => e.key)), x = () => {
224
+ let e = {}, t = v("session"), n = v("local");
225
+ for (let r of h) {
226
+ let i = y(r.storage === "session" ? t : n, r.key);
227
+ i !== null && (e[r.key] = i);
228
+ }
229
+ let r = y(n, g);
230
+ r !== null && (e[_] = r);
231
+ for (let [r, i] of [[t, "sessionStorage"], [n, "localStorage"]]) if (r) try {
232
+ for (let t = 0; t < r.length; t += 1) {
233
+ let n = r.key(t);
234
+ !n || !n.startsWith("flemo:") || b.has(n) || (e[`${n} (${i}, unknown key)`] = r.getItem(n) ?? "");
235
+ }
236
+ } catch {}
237
+ return e;
238
+ }, S = (e) => {
239
+ let t = [];
240
+ for (let [n, r] of Object.entries(e)) {
241
+ if (n === g) {
242
+ t.push(`${n}=${r} — A DRIVER PIN IS ACTIVE: every transition this session is forced onto one driver, bypassing measurement and demotion. If you did not set it deliberately just now, it is A/B residue (mobile tab restoration resurrects sessionStorage across days; pins expire after 24h). This exact residue once burned a multi-day investigation — clear the key before judging any behavior.`);
243
+ continue;
244
+ }
245
+ if (n === _) {
246
+ t.push(`${n}=${r} — legacy localStorage driver pin present. The library removes it on sight and never honors it, but its existence means an old A/B session left residue on this profile.`);
247
+ continue;
248
+ }
249
+ if (n.startsWith(`${g} `)) {
250
+ t.push(`${n}=${r} — a driver force pin was observed this session (see marker in the key). Even a since-cleared pin may have shaped early driver decisions; treat this session's routing as suspect.`);
251
+ continue;
252
+ }
253
+ let e = h.find((e) => n === e.key || n.startsWith(`${e.key} `));
254
+ !e || e.key === "flemo:devtools" || (e.kind === "opt-in-diagnostic" ? t.push(`${n}=${r} — opt-in diagnostic active (${e.description}); behavior differs from stock. Possible left-over A/B toggle from an earlier debugging session.`) : e.kind === "production-default-with-override" && t.push(`${n}=${r} — production default overridden for this session (${e.description}).`));
255
+ }
256
+ return t;
257
+ }, C = (e) => Math.round(e * 10) / 10, w = (e) => {
258
+ if (e.length === 0) return {
259
+ count: 0,
260
+ medianGapMs: 0,
261
+ maxGapMs: 0,
262
+ over30Count: 0
263
+ };
264
+ let t = [...e].sort((e, t) => e - t);
265
+ return {
266
+ count: e.length,
267
+ medianGapMs: C(t[Math.floor(t.length / 2)]),
268
+ maxGapMs: C(t[t.length - 1]),
269
+ over30Count: e.filter((e) => e >= 30).length
270
+ };
271
+ }, T = (e, t = []) => {
272
+ let n = [...e, ...t];
273
+ return {
274
+ ...n.length === 0 ? {
275
+ count: 0,
276
+ medianGapMs: 0,
277
+ maxGapMs: 0
278
+ } : (() => {
279
+ let e = [...n].sort((e, t) => e - t);
280
+ return {
281
+ count: n.length,
282
+ medianGapMs: C(e[Math.floor(e.length / 2)]),
283
+ maxGapMs: C(e[e.length - 1])
284
+ };
285
+ })(),
286
+ longGaps: n.filter((e) => e >= 30).map(C),
287
+ held: w(e),
288
+ released: w(t)
289
+ };
290
+ }, E = (e) => e.length === 0 ? null : {
291
+ maxMs: C(Math.max(...e)),
292
+ over30Count: e.filter((e) => e >= 30).length
293
+ }, D = (e) => {
294
+ let t = e.playerSuppression || e.playerAdvance;
295
+ return t && e.compiledAnimation ? "mixed" : t ? "player" : e.compiledAnimation ? "compiled" : "unknown";
296
+ }, O = (e) => e === "PUSHING" ? "PUSH" : e === "POPPING" ? "POP" : e === "REPLACING" ? "REPLACE" : null, k = (e) => {
297
+ let t = /^matrix3d\(([^)]+)\)$/.exec(e.trim());
298
+ if (t) {
299
+ let e = t[1].split(",").map((e) => Number(e.trim()));
300
+ return e.length === 16 && Number.isFinite(e[12]) ? e[12] : null;
301
+ }
302
+ let n = /^matrix\(([^)]+)\)$/.exec(e.trim());
303
+ if (n) {
304
+ let e = n[1].split(",").map((e) => Number(e.trim()));
305
+ return e.length === 6 && Number.isFinite(e[4]) ? e[4] : null;
306
+ }
307
+ return null;
308
+ }, A = "2", j = "@flemo/devtools resolved to its production entry: nothing was recorded. Run a development build to collect a real report.", M = () => {}, N = () => ({
309
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
310
+ version: "inert",
311
+ environment: {
312
+ userAgent: "",
313
+ uaBrands: null,
314
+ engine: "unknown",
315
+ platform: "",
316
+ maxTouchPoints: 0,
317
+ devicePixelRatio: 0,
318
+ screen: {
319
+ width: 0,
320
+ height: 0
321
+ },
322
+ viewport: {
323
+ width: 0,
324
+ height: 0
325
+ },
326
+ visualViewportScale: null,
327
+ rafCadence: {
328
+ medianGapMs: null,
329
+ sampleCount: 0
330
+ },
331
+ reducedMotion: !1,
332
+ emulationSuspected: !1,
333
+ observation: {
334
+ longTasks: !1,
335
+ elementAnimations: !1,
336
+ playerGapMirror: !1
337
+ }
338
+ },
339
+ overrides: {
340
+ active: {},
341
+ warnings: [j]
342
+ },
343
+ driverPolicy: {
344
+ demotion: null,
345
+ forcePin: null
346
+ },
347
+ flights: [],
348
+ anomalies: [j],
349
+ blindSpots: [],
350
+ judgingProtocol: [j]
351
+ }), P = (e) => ({
352
+ detach: M,
353
+ report: N
354
+ }), F = (e) => ({ detach: M });
355
+ //#endregion
356
+ export { c as BLIND_SPOTS, h as FLAG_REGISTRY, _ as LEGACY_LOCAL_PIN_KEY, e as LONG_GAP_MS, i as MID_FLIGHT_TASK_MS, n as OPENING_WINDOW_LEAD_MS, r as OPENING_WINDOW_TAIL_MS, A as REPORT_SCHEMA_VERSION, a as STALL_MS, t as STUCK_STATUS_MS, F as attachDevtoolsPanel, P as attachFlightRecorder, m as captureEnvironment, D as classifyDriver, T as computeFrameStats, w as computePhaseStats, E as computePlayerGapStats, o as deriveFlightAnomalies, S as deriveOverrideWarnings, s as deriveReportAnomalies, u as detectEngine, d as isEmulationSuspected, O as kindFromStatus, k as parseTranslateX, f as sampleRafCadence, x as snapshotOverrides };
package/dist/types.d.ts CHANGED
@@ -85,6 +85,15 @@ export interface MotionProgress {
85
85
  pausedAfterRelease: boolean;
86
86
  /** Offset from t0 at which a hold was re-asserted AFTER the release. */
87
87
  holdReassertedAtMs: number | null;
88
+ /**
89
+ * Released frames sampled AFTER every tracked animation reported "finished".
90
+ * The motion is over there and the pose is legitimately still; the flight is
91
+ * simply not closed yet. Counted separately because folding them into
92
+ * `stalledFrames` made a ~50ms "stall" fire on EVERY healthy flight — a
93
+ * constant that masks the real ones (measured on plen, 2026-08-20: 10 of 10
94
+ * flights, always exactly 3 frames).
95
+ */
96
+ tailFrames: number;
88
97
  }
89
98
  /**
90
99
  * Images inside the flight's participants. A still-loading <img> that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flemo/devtools",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Zero-config flight recorder for flemo screen transitions: captures per-flight driver routing, frame pacing, long tasks, landing residues, active debug overrides, and environment/observation-trap fingerprints into one JSON report readable by humans and coding agents.",
5
5
  "main": "./dist/index.mjs",
6
6
  "module": "./dist/index.mjs",
@@ -48,8 +48,21 @@
48
48
  "publishConfig": {
49
49
  "access": "public"
50
50
  },
51
+ "exports": {
52
+ ".": {
53
+ "types": "./dist/index.d.ts",
54
+ "development": "./dist/index.mjs",
55
+ "production": "./dist/noop.mjs",
56
+ "default": "./dist/index.mjs"
57
+ },
58
+ "./force": {
59
+ "types": "./dist/index.d.ts",
60
+ "default": "./dist/index.mjs"
61
+ },
62
+ "./package.json": "./package.json"
63
+ },
51
64
  "scripts": {
52
- "build": "vite build",
65
+ "build": "vite build && DEVTOOLS_ENTRY=noop vite build",
53
66
  "watch": "vite build --watch",
54
67
  "dev": "vite build --watch",
55
68
  "lint": "eslint \"**/*.{js,mjs,ts,jsx,tsx,mts}\"",