@flemo/devtools 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -1,69 +1,41 @@
1
1
  # @flemo/devtools
2
2
 
3
- Zero-config flight recorder for [flemo](https://flemo.dev) screen transitions.
3
+ Zero-dependency flight recorder, on-device readout and visual panel for [flemo](https://flemo.dev) transitions. It observes existing `data-flemo-*` surfaces, leftover `flemo:*` keys, CSS animation events, pointer events, `MutationObserver`, `PerformanceObserver("longtask")`, and rAF. It imports neither `@flemo/core` nor `@flemo/react` and does not alter measured motion.
4
4
 
5
- It watches the DOM surfaces flemo already exposes`data-flemo-*` attributes,
6
- `window.__flemoPlayerGaps`, the `flemo:*` storage registry — plus standard
7
- observers (`MutationObserver`, `PerformanceObserver("longtask")`, rAF), and
8
- condenses every navigation into one structured JSON report. The goal: a coding
9
- agent can diagnose a transition problem from the report alone, without
10
- round-tripping through the user ("which browser? was DevTools open? did you
11
- leave a toggle set?").
5
+ Everything in it is one probe module per question pacing, motion, images, shared elements, one-frame events, landing residue — behind a small orchestrator. Adding a measurement means adding a probe.
12
6
 
13
- Zero dependencies. No imports from `@flemo/core` or `@flemo/react`; attaching
14
- the recorder never changes the motion it measures.
7
+ ## Quickstart
8
+
9
+ ```ts
10
+ import { attachFlightRecorder } from "@flemo/devtools";
11
+
12
+ const recorder = attachFlightRecorder({ log: true });
13
+ // ...navigate...
14
+ const report = recorder.report(); // JSON-serializable FlemoReport
15
+ recorder.mark("A"); // label the flights that follow, for a comparison
16
+ recorder.detach();
17
+ ```
18
+
19
+ Read `report.verdict` first. It is the recorder's own reading of the session in plain sentences, and it refuses to summarise data from a session that was not allowed to produce evidence.
20
+
21
+ Unless `installGlobal: false` or the name is already owned, the recorder installs `window.flemo`:
22
+
23
+ ```js
24
+ copy(JSON.stringify(window.flemo.report(), null, 2));
25
+ ```
26
+
27
+ Use `/playground?devtools=on` to enable the playground recorder and its surfaces. `flemo:devtools` persists in `sessionStorage`; `?devtools=off` disables it. Armed, the playground imports `@flemo/devtools/force`, and that is the part worth copying: a plain import of this package resolves to the inert production entry, so an instrument wired the ordinary way exists only in the builds whose numbers the judging protocol below says do not count. `attachFlightRecorder()` is idempotent while attached and returns an inert handle during SSR.
15
28
 
16
29
  ## Production safety
17
30
 
18
- `@flemo/devtools` resolves to an inert entry when your bundler builds for
19
- production, so the ordinary import is the safe one:
31
+ Normal imports use `development` and `production` export conditions. The production implementation is inert and records nothing:
20
32
 
21
33
  ```ts
22
34
  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
35
  const { detach } = attachFlightRecorder({ log: true });
27
36
  ```
28
37
 
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
-
56
- ## Keeping it out of your production bundle
57
-
58
- Install it as a **devDependency** — and know that this alone is not enough.
59
- `devDependencies` decides what gets INSTALLED (consumers of _your_ package do
60
- not receive it); it does not decide what gets BUNDLED. A plain top-level import
61
- of a package you call at runtime ships to every visitor regardless of which
62
- dependency field it sits in. We measured exactly that on the flemo docs site
63
- before fixing it.
64
-
65
- What actually removes it is a **dynamic import behind a build-time constant**,
66
- which bundlers replace before dead-code elimination:
38
+ Vite and Next set these conditions. For bundlers that do not, guard a dynamic import with a build-time constant:
67
39
 
68
40
  ```ts
69
41
  // Vite
@@ -79,270 +51,113 @@ if (process.env.NODE_ENV !== "production") {
79
51
  }
80
52
  ```
81
53
 
82
- The package ships `"sideEffects": false` and has no import-time side effects,
83
- so nothing is pulled in by the import statement itself — but a binding you
84
- actually call cannot be shaken out. Verify with a production build: the string
85
- `present-pipeline pacing` (from the blind-spot list) must not appear in it.
54
+ Install as a devDependency, but do not rely on dependency fields for bundle exclusion. A used top-level import can ship even though the package has `"sideEffects": false` and no import-time effects. `dist/index.mjs` is self-contained; an internal `process.env.NODE_ENV` substitution did not reduce measured esbuild bundles when no export condition was set. Use the guarded import and verify production output lacks `present-pipeline pacing`. See `apps/web/app/[lang]/playground/_hooks/useDevtoolsRecorder`.
86
55
 
87
- `apps/web/app/[lang]/playground/_hooks/useDevtoolsRecorder` in this repo is a
88
- working reference.
56
+ `@flemo/devtools/force` always loads the recorder. Import it dynamically behind an explicit opt-in only for staging or production-build E2E.
89
57
 
90
- ## Quickstart
58
+ ## On-device readout
91
59
 
92
60
  ```ts
93
- import { attachFlightRecorder } from "@flemo/devtools";
94
-
95
- const recorder = attachFlightRecorder({ log: true });
96
-
97
- // ...navigate around...
98
-
99
- const report = recorder.report(); // JSON-serializable FlemoReport
100
- recorder.detach();
61
+ import { attachDevtoolsHud } from "@flemo/devtools";
62
+ const hud = attachDevtoolsHud({ position: "top" });
63
+ // hud.detach();
101
64
  ```
102
65
 
103
- The recorder also installs `window.flemo` (unless something else already owns
104
- that name, or you pass `installGlobal: false`):
66
+ A phone has no console. The readout is one monospaced line, high contrast and readable in a photograph of the device:
105
67
 
106
- ```js
107
- copy(JSON.stringify(window.flemo.report(), null, 2)); // DevTools console
68
+ ```
69
+ POP 412ms gap 33.4 drop 1 !2
108
70
  ```
109
71
 
110
- In the flemo playground it is pre-wired: visit `/playground?devtools=on` (the
111
- toggle is stored in `sessionStorage` under `flemo:devtools`, so it survives
112
- navigation; `?devtools=off` disarms it).
72
+ Tap it for the detail block (frames, motion, holds, shared elements, what drove the navigation, and the flight's anomalies); long-press to cycle the comparison bucket. Options are `recorder`, `position` (`"top"` or `"bottom"`), `initialExpanded` (`false`) and `buckets` (`["A", "B"]`).
113
73
 
114
- `attachFlightRecorder()` is idempotent while a recorder is attached, further
115
- calls return the same handle. In non-DOM environments (SSR) it returns an
116
- inert handle.
74
+ It obeys the same rules the panel does: it repaints only between flights, its stylesheet carries no transition and no keyframe, and its host is a zero-sized fixed element that cannot join a flight.
117
75
 
118
76
  ## Visual panel
119
77
 
120
- The same data, on screen. `attachDevtoolsPanel()` mounts a floating `flemo`
121
- toggle (flight count, plus a red dot when any flight carries an anomaly) and a
122
- bottom drawer with the flight list, the per-flight detail, the active
123
- overrides, and the blind-spot list.
124
-
125
78
  ```ts
126
79
  import { attachDevtoolsPanel } from "@flemo/devtools";
127
-
128
- const panel = attachDevtoolsPanel(); // reads window.flemo, or attaches its own
80
+ const panel = attachDevtoolsPanel();
129
81
  // panel.detach();
130
82
  ```
131
83
 
132
- The panel is opt-in and mounted by the consumer: nothing in flemo attaches it
133
- for you, and the flemo playground deliberately arms the recorder only (the
134
- site should not ship a debug UI). Mount it behind your own dev-only flag.
135
-
136
- Options: `recorder` (a handle from `attachFlightRecorder`; defaults to this
137
- package's `window.flemo`, otherwise the panel attaches — and owns — one),
138
- `initialOpen` (default false), `position` (`"bottom-right"` default, or
139
- `"bottom-left"`). Zero dependencies, no framework: vanilla DOM inside an open
140
- shadow root, so no consumer CSS reaches in and none of the panel's reaches
141
- out. The host is `position: fixed`, zero-sized, marked
142
- `data-flemo-devtools-panel`, and carries no `data-flemo-*` screen attributes —
143
- the recorder never sees the panel as a flight participant. Drag the drawer's
144
- top edge to resize; the height persists in `sessionStorage` under
145
- `flemo:devtools-panel-height`.
146
-
147
- **It never repaints during a flight.** That is the design constraint, not a
148
- nicety: this project once spent weeks chasing stutter that turned out to be
149
- _DevTools being open_, and a measurement surface
150
- that repaints mid-transition reproduces that artifact and then reports it as a
151
- finding. So the panel
152
-
153
- - refreshes on a `setTimeout` chain (~3×/s open, once per 2s closed) never a
154
- persistent `requestAnimationFrame` loop competing with the motion,
155
- - **skips any refresh while a screen carries a transitional
156
- `data-flemo-status`** and retries on the next tick — deferred user actions
157
- (open/close, row selection) included,
158
- - renders only the toggle button while closed,
159
- - ships no CSS transitions and no keyframes at all.
160
-
161
- Do not turn it into a live-updating dashboard.
162
-
163
- ## Report schema (version "2")
164
-
165
- ```jsonc
166
- {
167
- "generatedAt": "2026-08-17T09:00:00.000Z",
168
- "version": "2",
169
- "environment": {
170
- "userAgent": "…",
171
- "uaBrands": [{ "brand": "Chromium", "version": "126" }],
172
- "engine": "blink", // blink | webkit | gecko | unknown
173
- "platform": "MacIntel",
174
- "maxTouchPoints": 0,
175
- "devicePixelRatio": 2,
176
- "screen": { "width": 1728, "height": 1117 },
177
- "viewport": { "width": 1280, "height": 720 },
178
- "visualViewportScale": 1,
179
- "rafCadence": { "medianGapMs": 16.67, "sampleCount": 20 }, // idle sample at attach
180
- "reducedMotion": false,
181
- "emulationSuspected": false, // DevTools device-toolbar signature
182
- "observation": { "longTasks": true, "elementAnimations": true, "playerGapMirror": false }
183
- },
184
- "overrides": {
185
- // Every flemo:* storage key currently set (both storages), including
186
- // unknown keys and keys present at attach but cleared since (marked).
187
- // Retired keys still persisted on the device are listed too, marked
188
- // "(retired — the library no longer reads this)", so residue is ruled out
189
- // rather than chased.
190
- "active": { "flemo:layers": "resident" },
191
- // Read these FIRST. A non-empty warnings list means the session does not
192
- // run stock behavior.
193
- "warnings": ["flemo:layers=resident opt-in diagnostic active ()"]
194
- },
195
- "flights": [
196
- {
197
- "id": "flight-1",
198
- "routerId": "…", // when the screen stamps data-flemo-router
199
- "kind": "PUSH", // PUSH | POP | REPLACE
200
- "t0": { "ms": 1234.5, "iso": "…" }, // performance.now + wall clock
201
- "t1": { "ms": 1834.5, "iso": "…" },
202
- "durationMs": 600,
203
- // compiled | player | mixed | unknown — detected per flight from the
204
- // DOM signature (inline animation suppression + advancing inline pose
205
- // = player; a running flemo-* CSSAnimation = compiled). Never assume a
206
- // platform always routes one tier; routing policies evolve.
207
- "driver": "compiled",
208
- "participants": { "screens": 2, "bars": 0, "decorators": 1, "parts": 0 },
209
- // data-flemo-anim-hold: releasedAtMs = when the LAST hold released,
210
- // relative to t0. The engine absorbs heavy commits INTO the hold (the
211
- // screen is posed, not moving), so everything below is segmented on
212
- // this boundary.
213
- "holds": { "kind": "park-under", "releasedAtMs": 120 },
214
- "frameSamples": {
215
- "count": 36,
216
- "medianGapMs": 16.7,
217
- "maxGapMs": 17.2,
218
- "longGaps": [],
219
- // held-phase gaps are absorbed by design — no anomaly ever fires on them;
220
- "held": { "count": 7, "medianGapMs": 18.1, "maxGapMs": 45.0, "over30Count": 1 },
221
- // released-phase gaps are visible motion — the anomaly rules key on these.
222
- "released": { "count": 29, "medianGapMs": 16.7, "maxGapMs": 17.2, "over30Count": 0 }
223
- },
224
- // Did it MOVE — a different question from "did frames arrive". A
225
- // compiled flight is read off its own animation clock, a player flight
226
- // off the inline pose it writes; neither forces a style flush.
227
- "motion": {
228
- "sampledFrames": 29,
229
- "stalledFrames": 0, // released frames where neither clock nor pose moved
230
- "longestStallMs": 0, // >= 48ms raises an anomaly
231
- "pausedAfterRelease": false, // playState went "paused" mid-motion
232
- "holdReassertedAtMs": null // a hold went back ON after releasing
233
- },
234
- // Images inside the participants. One still-loading <img> completing
235
- // mid-flight costs one skipped present (glass-measured 1:1), which is
236
- // why the engine holds them; an unheld completion is that regression.
237
- // completedUnheld is the number that matters, counted PER IMAGE: a
238
- // held-but-still-loading image must not cancel out an unheld completed
239
- // one. addedDuringFlight covers images a data commit inserts mid-
240
- // navigation, which is the case core's image hold also watches for.
241
- "images": {
242
- "loadingAtStart": 12,
243
- "addedDuringFlight": 0,
244
- "completedDuringFlight": 0,
245
- "heldDuringFlight": 12,
246
- "completedUnheld": 0
247
- },
248
- "playerGaps": { "maxMs": 42.3, "over30Count": 1 }, // only if the player mirror grew
249
- "longTasks": [{ "startMs": 1200.0, "durationMs": 180.0 }], // intersecting visible motion
250
- "holdLongTasks": [], // fully absorbed by the hold — engine working as designed
251
- "landing": {
252
- // Audited 2 rAF after COMPLETED:
253
- "residualInlineTransforms": [], // inline transform/opacity leftovers
254
- "offViewportAtRest": false, // the blank-viewport (PR #259) signature
255
- "stuckStatuses": [], // transitional statuses >10s old
256
- // Hold markers still on the page at rest. Skipped (left empty) when
257
- // another flight is already running: two frames after a landing, a
258
- // fast back-to-back navigation legitimately owns holds of its own.
259
- "orphanedHolds": []
260
- },
261
- "anomalies": ["long task 180ms overlapped flight start (opening-swallow risk)"]
262
- }
263
- ],
264
- "anomalies": [], // session-level: active pins, emulation, stuck flights
265
- "blindSpots": ["…"], // constant — see below
266
- "judgingProtocol": ["…"] // constant — the preconditions a verdict needs
267
- }
268
- ```
84
+ Consumers must opt in behind a development-only flag; flemo and its playground neither attach nor ship the panel automatically. The floating toggle shows flight count and an anomaly dot. The drawer shows flights, details, active overrides, and blind spots.
85
+
86
+ Options are `recorder`, `initialOpen` (`false`), `position` (`"bottom-right"` or `"bottom-left"`) and `buckets` (`["A", "B"]`). Without `recorder`, the panel reuses this package's `window.flemo` or owns a new recorder. It is idempotent while mounted and inert without a DOM. The header leads with the verdict and every failed precondition, and carries an A/B button that arms the comparison buckets.
87
+
88
+ The framework-free panel uses an open shadow root. Its fixed, zero-sized host has `data-flemo-devtools-panel`, no screen `data-flemo-*` attributes, and cannot join a flight. Drawer height persists as `flemo:devtools-panel-height` in `sessionStorage`.
89
+
90
+ The panel must not repaint during a flight:
91
+
92
+ - Refresh with a `setTimeout` chain about three times per second while open and once every two seconds while closed; never keep an rAF loop.
93
+ - Skip refreshes and deferred actions while any screen has transitional `data-flemo-status`, then retry next tick.
94
+ - Render only the toggle while closed.
95
+ - Add no CSS transitions, keyframes, or live-dashboard behavior.
96
+
97
+ ## Report schema v3
98
+
99
+ Reports contain:
100
+
101
+ - `generatedAt`, `version: "3"`, and `verdict`: the session read back in sentences, most important first.
102
+ - `preconditions[]`: the observable half of the judging protocol, each `ok`, `violated` or `unknown` with the reasoning and its numbers. The traps a page cannot see stay `unknown` forever rather than being guessed at.
103
+ - `environment`: user agent and brands, engine, platform, touch count, DPR, hardware concurrency, screen and viewport sizes, visual viewport scale, idle `rafCadence`, reduced motion, development-server globals, emulation suspicion, and what the recorder could observe (long tasks, element animations, and whether its own animation channel ever fired).
104
+ - `overrides.active`: all `flemo:*` keys in both storages, unknown keys, keys cleared since attachment, and retired persisted keys marked inert. Since 2026-08-31, flemo reads no `flemo:*` engine key; `overrides.warnings` explains each residue key.
105
+ - `flights[]`: identity, router, bucket, navigation kind, timestamps, duration, detected driver, participants, holds, frame and phase statistics, motion, images, shared elements, tripwire hits, what drove it, long tasks, landing checks, and stable anomaly strings.
106
+ - `comparison[]`: per-bucket medians, worst gaps, drops, anomalies and stalls; empty until `mark()` arms a label.
107
+ - `previousSession`: flights carried across the last full page load, kept apart from the live ones.
108
+ - Session `anomalies`, constant `blindSpots`, and constant `judgingProtocol`.
109
+
110
+ `driver` is classified per flight as `compiled`, `inline`, `mixed`, or `unknown`; never infer it from platform policy. flemo compiles every animation, so `inline` means something else is writing frames onto a participant. `holds.releasedAtMs` is the last release relative to `t0`. Held work is intentionally absorbed, so frame gaps and long tasks are separated into held and released phases; held gaps do not raise anomalies.
111
+
112
+ `motion` measures pose advancement independently of frame arrival. It records sampled and stalled frames, `longestStallMs` with an anomaly threshold of 48 ms, `pausedAfterRelease`, and `holdReassertedAtMs`, using animation clocks or inline poses without a style flush. Stationary closing frames are tails, not mid-flight stalls.
113
+
114
+ `morphs` answers the question a shared element cannot answer for itself. A morph that does not pair produces no error, no attribute and no animation, so the runtime writes the pairing key onto every registered morph (`data-flemo-morph-id`) and this section groups the ends: `pairable` had everything they needed, `flew` were stamped with a flight role, and `skipped` is the difference. It also reports duplicate keys inside one screen (a consumer mistake, not a runtime one) and the residue a landing left behind: roles, stand-ins, ghosts, elements stranded in a flight layer, and keyframe rules never dropped.
115
+
116
+ `tripwires` are events the browser REPORTED rather than samples the recorder took: a cancelled flemo animation, an `animationend` carrying `elapsedTime` 0, a hold re-asserted after its release, a ghost cut inside a frame. A sampler cannot see a defect that lasts one frame; a listener cannot miss it.
117
+
118
+ `input` records the trusted and synthetic pointer events around the flight and the pointer types among them. A session driven only by script never fires the gesture machinery, and a session driven only by a mouse never exercises the touch path.
119
+
120
+ `images` records loading at start, additions, completions, held images, and `completedUnheld`; count per image so a held loading image cannot cancel an unheld completion. `longTasks` covers visible motion and `holdLongTasks` covers absorbed work. Landing is audited two rAFs after `COMPLETED` for residual inline transforms, off-viewport rest, statuses stuck over 10 seconds, and orphaned holds. Skip orphan auditing when another flight is running.
121
+
122
+ ## Detected defects
123
+
124
+ Stable anomaly signatures include:
125
+
126
+ - `hold re-asserted …ms into the flight`: a stale paused hold was rewritten during motion.
127
+ - `motion stalled …ms mid-flight`: motion froze or froze then leapt despite continuing rAF.
128
+ - `playState=paused`: posed motion stopped rather than suffering frame starvation.
129
+ - `image(s) finished loading mid-flight without a hold`: decode rastered on the moving layer.
130
+ - `hold markers left on the page at rest`: hidden content has no owner to reveal it.
131
+ - `screen resting at from-pose while COMPLETED+active`: blank-viewport landing.
132
+ - `long task …ms overlapped the visible-motion start`: swallowed opening.
133
+ - `transitional status stuck >10s`: the navigation queue remains locked.
134
+ - `shared element(s) did not fly`: both ends were registered on two screens and neither took a flight role.
135
+ - `morph element(s) still carry a flight role at rest`: a stranded participant that poisons the next pairing.
136
+ - `tripwire zero-length-animation-end`: something landed on an animation that never ran.
137
+ - `active force pin flemo:motion-driver-force=…`: diagnostic residue pins a driver.
138
+
139
+ These defects have occurred with clean frame timing.
140
+
141
+ ## Judging protocol and blind spots
142
+
143
+ A valid verdict requires DevTools closed, no capture, real input, emulation off, a production build, an idle machine, and known display, refresh rate, HiDPI scaling and Low Power Mode state. The ones a page CAN check are checked and appear in `preconditions` — emulation, display cadence, foreground, machine contention, build mode, real and touch input, reduced motion. The rest stay `unknown` there and are stated in `judgingProtocol`. Open DevTools caused 2026-08 residual stutter; capture can suppress symptoms; synthetic dispatch bypasses `pointerdown` gesture behavior.
144
+
145
+ In-page tools cannot observe macOS Chrome present-pipeline pacing on 120 Hz ProMotion (Chromium issues 40062488/345275139), display hardware, compositor-internal present skips, or the post-scale DevTools emulation surface. These remain in `blindSpots`. If a correctly judged report is clean but jank is visible, investigate those layers instead of adding in-page instrumentation.
146
+
147
+ ## Hand a report to an agent
148
+
149
+ 1. Reproduce once with the recorder attached.
150
+ 2. Run `copy(JSON.stringify(window.flemo.report(), null, 2))`.
151
+ 3. Paste the JSON into the issue or conversation.
269
152
 
270
- ## What it is built to catch
271
-
272
- Every rule below exists because the matching defect actually shipped, was
273
- lived with, and cost days. They share one property: **frame timing was clean
274
- through all of them.** A recorder that only measured gaps would have called
275
- each of these sessions healthy.
276
-
277
- | Signature in `anomalies` | The defect it guards against |
278
- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
279
- | `hold re-asserted …ms into the flight` | An interleaved commit writing the stale paused hold attribute over a running flight — froze motion ~250ms, intermittently |
280
- | `motion stalled …ms mid-flight` | Any freeze / freeze-then-leap: the pose stopped advancing while rAF kept ticking |
281
- | `playState=paused` | The flight was posed and then stopped, rather than starved of frames |
282
- | `image(s) finished loading mid-flight without a hold` | Warm-side image decode rastering on the sliding layer — one skipped present per decode |
283
- | `hold markers left on the page at rest` | Orphaned image/arrival holds: content hidden with no owner left to reveal it (the permanently-blank-avatar class) |
284
- | `screen resting at from-pose while COMPLETED+active` | The blank-viewport landing (PR #259) |
285
- | `long task …ms overlapped the visible-motion start` | The swallowed opening (씹힘) |
286
- | `transitional status stuck >10s` | A locked navigation queue — every later navigation is silently ignored |
287
- | `active force pin flemo:motion-driver-force=…` | A/B residue pinning the whole session to one driver (this one burned a multi-day investigation) |
288
-
289
- ## The judging protocol
290
-
291
- Every report also carries `judgingProtocol`. It is not derived — the page
292
- cannot check it — so it is stated instead:
293
-
294
- - **DevTools closed.** The 2026-08 campaign's entire residual "stutter" was
295
- the open inspector, verified bidirectionally on the reporting machine. A
296
- clean report from a DevTools-open session proves nothing.
297
- - **No screen capture running.** A capture client forces the compositor to a
298
- steady cadence and _suppresses_ the symptom.
299
- - **Real input.** Synthetic dispatch never fires `pointerdown`, so it bypasses
300
- the gesture machinery a real navigation goes through.
301
- - **Known viewing configuration.** Emulation off; which display, refresh rate,
302
- HiDPI scaling, Low Power Mode.
303
-
304
- ## Blind spots
305
-
306
- Every report ends with a constant `blindSpots` list: the layers **no in-page
307
- instrument can see**, each of which once consumed a real investigation:
308
-
309
- - macOS Chrome present-pipeline pacing (Chromium issues 40062488/345275139) —
310
- frames judder on 120Hz ProMotion while every in-page metric reads clean;
311
- proven with a no-script pure-CSS control page.
312
- - Display-hardware effects (local dimming, backlight modulation).
313
- - Compositor-internal present skips invisible to rAF.
314
- - DevTools device emulation composites to a rescaled surface — instruments
315
- read the pre-scale surface, the eye watches the post-scale one.
316
-
317
- If the report is clean and the user still sees jank, the cause lives in one of
318
- these. Do not chase them with in-page tooling.
319
-
320
- ## Handing a report to an agent
321
-
322
- 1. Reproduce the problem once with the recorder attached (playground:
323
- `/playground?devtools=on`).
324
- 2. `copy(JSON.stringify(window.flemo.report(), null, 2))` in the console.
325
- 3. Paste the JSON into the issue/conversation.
326
-
327
- Reading order for the agent: `overrides.warnings` (is this session even
328
- stock?) → `environment.emulationSuspected` + `rafCadence` (is the observation
329
- trustworthy? what display cadence?) → per-flight `driver` + `anomalies`
330
- (which tier ran, what went wrong, when) → `blindSpots` (what not to chase).
331
- All anomaly strings are stable, grep-friendly signatures.
153
+ Read `verdict`, then `preconditions`, then each flight's `anomalies`, then `blindSpots`. A number from a session with a violated precondition is not evidence.
332
154
 
333
155
  ## API
334
156
 
335
- - `attachFlightRecorder(options?)` `{ report(), detach() }` options:
336
- `maxFlights` (default 50), `log` (default false), `installGlobal`
337
- (default true).
338
- - `attachDevtoolsPanel(options?)` `{ detach() }` options: `recorder`,
339
- `initialOpen` (default false), `position` (default `"bottom-right"`).
340
- Idempotent while mounted; inert without a DOM.
341
- - Pure helpers (unit-testable, no DOM): `deriveFlightAnomalies`,
342
- `deriveReportAnomalies`, `deriveOverrideWarnings`, `classifyDriver`,
343
- `computeFrameStats`, `computePlayerGapStats`, `parseTranslateX`,
344
- `kindFromStatus`.
345
- - Constants/registries: `BLIND_SPOTS`, `FLAG_REGISTRY`, `LONG_GAP_MS`,
346
- `STUCK_STATUS_MS`, `REPORT_SCHEMA_VERSION`.
347
- - Environment probes: `captureEnvironment`, `detectEngine`,
348
- `isEmulationSuspected`, `sampleRafCadence`.
157
+ - `attachFlightRecorder(options?)` returns `{ report(), mark(), detach() }`; options: `maxFlights` (50), `log` (`false`), `installGlobal` (`true`), `persist` (`true`).
158
+ - `attachDevtoolsHud(options?)` returns `{ detach() }`; options: `recorder`, `position` (`"top"`), `initialExpanded` (`false`), `buckets`.
159
+ - `attachDevtoolsPanel(options?)` returns `{ detach() }`; options: `recorder`, `initialOpen` (`false`), `position` (`"bottom-right"`), `buckets`.
160
+ - Pure helpers: `deriveFlightAnomalies`, `deriveReportAnomalies`, `deriveOverrideWarnings`, `derivePreconditions`, `deriveVerdict`, `summariseBuckets`, `classifyDriver`, `computeFrameStats`, `parseTranslateX`, `kindFromStatus`.
161
+ - Constants and registries: `BLIND_SPOTS`, `JUDGING_PROTOCOL`, `FLAG_REGISTRY`, `LONG_GAP_MS`, `STALL_MS`, `STUCK_STATUS_MS`, `REPORT_SCHEMA_VERSION`.
162
+ - Environment probes: `captureEnvironment`, `detectEngine`, `developmentHints`, `isEmulationSuspected`, `sampleRafCadence`.
163
+ - Trace storage: `loadTrace`, `saveTrace`, `clearTrace`, `TRACE_KEY`.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
- import { FlightDriver, FrameSampleStats, ImageActivity, LandingAudit, LongTaskSpan, MotionProgress, PlayerGapStats } from './types';
1
+ import { FlightDriver, FrameSampleStats, ImageActivity, LandingAudit, LongTaskSpan, MorphActivity, MotionProgress, TripwireHit } from './types';
2
2
  /** A gap at/over this is at least one missed 60Hz frame (mirrors core). */
3
3
  export declare const LONG_GAP_MS = 30;
4
4
  /** A transitional status older than this is a stuck flight. */
@@ -19,7 +19,6 @@ export interface FlightAnomalyInput {
19
19
  t1Ms: number;
20
20
  driver: FlightDriver;
21
21
  frameSamples: FrameSampleStats;
22
- playerGaps: PlayerGapStats | null;
23
22
  /** Long tasks intersecting the RELEASED (visible-motion) phase. */
24
23
  longTasks: LongTaskSpan[];
25
24
  /** Long tasks fully absorbed by the hold phase (informational only). */
@@ -29,6 +28,8 @@ export interface FlightAnomalyInput {
29
28
  landing: LandingAudit;
30
29
  motion: MotionProgress;
31
30
  images: ImageActivity;
31
+ morphs: MorphActivity;
32
+ tripwires: TripwireHit[];
32
33
  }
33
34
  export declare const deriveFlightAnomalies: (input: FlightAnomalyInput) => string[];
34
35
  export interface ReportAnomalyInput {
@@ -0,0 +1,2 @@
1
+ import { BucketSummary, FlightRecord } from './types';
2
+ export declare const summariseBuckets: (flights: readonly FlightRecord[]) => BucketSummary[];
@@ -10,6 +10,28 @@ export declare const DECORATOR_ATTR = "data-flemo-decorator";
10
10
  export declare const BAR_ATTR = "data-flemo-bar";
11
11
  export declare const BAR_STATUS_ATTR = "data-flemo-bar-status";
12
12
  export declare const BAR_RIDING_ATTR = "data-flemo-bar-riding";
13
+ /** A registered morph. The value is the role while it flies: "enter"/"exit". */
14
+ export declare const MORPH_ATTR = "data-flemo-morph";
15
+ /** The pairing key (the binding's `layoutId`), so the two ends can be grouped. */
16
+ export declare const MORPH_ID_ATTR = "data-flemo-morph-id";
17
+ /** The registered morph-transition name, absent/empty meaning the default preset. */
18
+ export declare const MORPH_NAME_ATTR = "data-flemo-morph-name";
19
+ /** The per-Router flight layer a staged morph is lifted into. */
20
+ export declare const MORPH_LAYER_ATTR = "data-flemo-morph-layer";
21
+ /** The copy left in the layout holding the flying element's place. */
22
+ export declare const MORPH_STAND_IN_ATTR = "data-flemo-morph-stand-in";
23
+ /** The copy of the replaced element carried inside the flight. */
24
+ export declare const MORPH_GHOST_ATTR = "data-flemo-morph-ghost";
25
+ /** A screen being driven as a camera by a morph, stamped with the flight id. */
26
+ export declare const MORPH_CAMERA_ATTR = "data-flemo-morph-camera";
27
+ /** The `<style>` element a morph writes its per-flight keyframes into. */
28
+ export declare const MORPH_SHEET_ATTR = "data-flemo-morph-sheet";
29
+ /** The values MORPH_ATTR takes while an element is in the air. */
30
+ export declare const MORPH_ROLES: readonly ["enter", "exit"];
31
+ /** Every flemo keyframe name starts with this. */
32
+ export declare const FLEMO_ANIMATION_PREFIX = "flemo-";
33
+ /** A morph's per-flight keyframes are namespaced again under this. */
34
+ export declare const MORPH_ANIMATION_PREFIX = "flemo-morph-";
13
35
  /** This package's own marker — core reserves the name but never writes it. */
14
36
  export declare const DEVTOOLS_PANEL_ATTR = "data-flemo-devtools-panel";
15
37
  /** The statuses during which a flight is moving. */
@@ -25,7 +25,10 @@ export declare const sampleRafCadence: (frames?: number) => Promise<{
25
25
  medianGapMs: number | null;
26
26
  sampleCount: number;
27
27
  }>;
28
+ export declare const developmentHints: () => string[];
28
29
  export declare const captureEnvironment: (rafCadence: {
29
30
  medianGapMs: number | null;
30
31
  sampleCount: number;
31
- }) => EnvironmentFingerprint;
32
+ },
33
+ /** Whether the tripwires have seen a flemo animation event (see types). */
34
+ animationEvents?: boolean) => EnvironmentFingerprint;
@@ -0,0 +1,40 @@
1
+ import { FrameProbeState } from './frameProbe';
2
+ import { ImageProbeState } from './imageProbe';
3
+ import { MorphProbeState } from './morphProbe';
4
+ import { FlightKind, FlightRecord, TripwireHit } from './types';
5
+ /**
6
+ * The mutable state of one flight while it is in the air.
7
+ *
8
+ * Composed of one field per probe rather than a flat bag: every probe owns its
9
+ * own state shape, the orchestrator owns only the lifecycle fields, and adding
10
+ * a measurement means adding a field and a module beside it rather than
11
+ * growing a closure nobody can hold in their head.
12
+ */
13
+ export interface ActiveFlight {
14
+ id: string;
15
+ kind: FlightKind;
16
+ routerId?: string;
17
+ /** The comparison bucket armed when this flight opened, if any. */
18
+ bucket: string | null;
19
+ t0Ms: number;
20
+ t0Iso: string;
21
+ elements: Element[];
22
+ participants: FlightRecord["participants"];
23
+ holdKind: string | null;
24
+ holdReleasedAtMs: number | null;
25
+ /**
26
+ * When the first flemo keyframe actually STARTED, relative to t0.
27
+ *
28
+ * The status flip and the first moving frame are not the same moment: a
29
+ * commit, a style recalculation and a present sit between them, and on a
30
+ * phone that gap has measured 90-165ms while every other number stayed
31
+ * clean. It is reported rather than judged, because the gap is React's and
32
+ * the browser's, not the transition's.
33
+ */
34
+ firstAnimationAtMs: number | null;
35
+ frames: FrameProbeState;
36
+ images: ImageProbeState;
37
+ morphs: MorphProbeState;
38
+ tripwires: TripwireHit[];
39
+ rafId: number | null;
40
+ }