@broberg/lens-daemon 0.0.0-stage → 0.1.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.
@@ -0,0 +1,653 @@
1
+ // F312.1 — the in-page motion probe.
2
+ //
3
+ // THE PROBE IS THE GROUND TRUTH; the video is the illustration. That ordering is
4
+ // the whole point of F312 and it is the lesson of the night that produced it: I
5
+ // diagnosed a scroll bug from source three times, each diagnosis internally
6
+ // consistent and mutation-proven, and three of them made the product worse. What
7
+ // none of them had was a measurement of the thing being complained about.
8
+ //
9
+ // The complaint is never "the view moved". It is "the view moved AND I DID NOT DO
10
+ // IT". So the probe records both halves — the position, and when a real human
11
+ // input last happened — because only the pair can tell a defect from normal use.
12
+
13
+ /** One reading of the scroller, taken inside the page. */
14
+ export interface MotionSample {
15
+ /** ms since the probe started. */
16
+ t: number;
17
+ /** scrollTop at that instant. */
18
+ top: number;
19
+ /** scrollHeight — a jump with a height change is content arriving, not a jump. */
20
+ height: number;
21
+ /** F312.7 — the viewport height, so `height - client` gives the MAXIMUM scrollTop.
22
+ * Without it a move cannot be told apart from the browser CLAMPING scrollTop
23
+ * after the content above shrank, which is a completely different cause with a
24
+ * completely different fix. */
25
+ client: number;
26
+ /** How many rows are rendered; distinguishes a re-render from a scroll. */
27
+ rows: number;
28
+ /** The first row whose bottom clears the viewport top — "what he is looking at". */
29
+ firstRow: string | null;
30
+ /** F312.14 — WHERE that row sits, in px from the viewport top. Together with
31
+ * `firstRow` this is what the eye actually sees; `top` alone is not. */
32
+ firstRowY: number;
33
+ /** F312.15 — every row currently on screen, with its offset. The FIRST row can
34
+ * differ between two samples while the reader's own message has not moved, so
35
+ * the comparison needs a row that exists in both. */
36
+ visible: { id: string | null; y: number }[];
37
+ /** F312.23 — THE READER'S OWN ROW, which is the only one the question is about.
38
+ *
39
+ * `firstRow` is whatever the render window happens to start with and changes
40
+ * identity on every prepend. The app remembers his place in localStorage under
41
+ * `chat-anchor:<edge>:<channel>`; the probe reads the same key, so this costs
42
+ * the product nothing and exposes nothing new on `window`.
43
+ *
44
+ * `anchorY - anchorWant` is the whole question in one number: 0 means his row
45
+ * did not move, whatever `top` did. It is the measurement `visibleShift` cannot
46
+ * make, because that one needs a row present in BOTH samples and a jump larger
47
+ * than the viewport guarantees there is none — blind exactly where it matters. */
48
+ anchorId: string | null;
49
+ /** Where his row actually sits, in px from the scroller's top. */
50
+ anchorY: number | null;
51
+ /** Where the app PROMISED to hold it — the stored `offset`. */
52
+ anchorWant: number | null;
53
+ /** `held` his row is rendered · `bottom` he is parked at the live end ·
54
+ * `not-rendered` he has a place, it is outside the window · `none` no key.
55
+ * `bottom` is deliberately NOT `none`: being at the live end IS a remembered
56
+ * place, and collapsing the two would report "nothing to hold" for a reader
57
+ * who has one. */
58
+ anchorState: 'held' | 'bottom' | 'not-rendered' | 'none';
59
+ /** ms since the last input that CAN legitimately move the view — a wheel, a
60
+ * touch, an arrow/page key. */
61
+ sinceInput: number | null;
62
+ /** F312.5 — ms since the last KEYSTROKE into a field. Deliberately separate:
63
+ * typing is input, and typing must NEVER move the view. Folding it into
64
+ * `sinceInput` made the detector blind to the worst version of this defect —
65
+ * the one where he is mid-sentence in a table cell and the ground moves. */
66
+ sinceTyping: number | null;
67
+ /** ms since the driver last acted, or null. Its own actions are not defects. */
68
+ sinceDriver: number | null;
69
+ }
70
+
71
+ /** A move nobody asked for. */
72
+ export interface MotionFinding {
73
+ t: number;
74
+ from: number;
75
+ to: number;
76
+ /** Signed: negative means he was carried BACKWARDS, which is the worse direction. */
77
+ delta: number;
78
+ firstRowBefore: string | null;
79
+ firstRowAfter: string | null;
80
+ /** F312.23 — HOW FAR HIS OWN ROW SAT FROM WHERE THE APP PROMISED TO HOLD IT,
81
+ * before and after. `null` when the anchor is not readable at that instant
82
+ * (parked at the live end, outside the render window, or no key).
83
+ *
84
+ * This is the measure `visibleShift` cannot make. That one needs a row present
85
+ * in BOTH samples, so a jump larger than the viewport leaves it null — blind
86
+ * exactly where the move is biggest. This one is per-sample and independent of
87
+ * the pair, so a 1852px change in `top` with `anchorDrift` steady at 0 is
88
+ * compensation that kept him still, and the same change with `anchorDrift`
89
+ * jumping is a move. The recording that motivated this could not tell them
90
+ * apart, and two opposite fixes both looked justified. */
91
+ anchorDriftBefore: number | null;
92
+ anchorDriftAfter: number | null;
93
+ /** Which of the four anchor states each sample was in, so a `null` drift is
94
+ * never mistaken for "measured, and it was fine". */
95
+ anchorStateBefore: MotionSample['anchorState'];
96
+ anchorStateAfter: MotionSample['anchorState'];
97
+ /** F312.24 — DID THE VIEW MOVE, OR THE LAYOUT?
98
+ *
99
+ * The spread (`max − min`) of how far each row present in BOTH samples moved.
100
+ * Near 0 means every shared row travelled the same distance — the layout is
101
+ * rigid and the VIEW scrolled. Large means they moved by different amounts, so
102
+ * content between them changed height and the LAYOUT moved under him.
103
+ *
104
+ * `null` with fewer than TWO shared rows, never 0: one row cannot tell the two
105
+ * cases apart, and 0 would read as "the layout was rigid".
106
+ *
107
+ * `visibleShift` asks how far the picture moved using ONE shared row; this
108
+ * asks a different question of the same data and needs two. Both are reported,
109
+ * because a ±24px jitter has the same `visibleShift` whichever mechanism
110
+ * produced it, and the two need opposite fixes. */
111
+ layoutSpread: number | null;
112
+ /** True when the rendered row count changed too — content arrived, so the move
113
+ * may be a consequence rather than a cause. Reported, never used to hide it. */
114
+ contentChanged: boolean;
115
+ /** F312.5 — he was TYPING when it happened. Not a separate defect: the same
116
+ * move, in the circumstance where it costs the most. */
117
+ whileTyping: boolean;
118
+ /**
119
+ * F312.6 — WHY this move is (or is not) counted, and how long since his last
120
+ * scroll. Nothing is discarded any more.
121
+ *
122
+ * Christian's third case: "jeg scroller op for lige at læse noget og vupti så
123
+ * er den hoppet et nyt sted hen". That move lands SHORTLY AFTER his scroll —
124
+ * inside the momentum grace — so the first version dropped it silently. A
125
+ * dropped observation cannot be reviewed, and this is the same lesson as
126
+ * everything else tonight: classify, never discard.
127
+ */
128
+ cause: 'none' | 'his-scroll' | 'driver';
129
+ /** ms since his last scroll-intent at the moment of the move; null = never. */
130
+ sinceInput: number | null;
131
+ /**
132
+ * F312.7 — WAS IT A CLAMP? The content above shrank far enough that the old
133
+ * scrollTop no longer existed, and the browser pulled it down to the new
134
+ * maximum. That is a different defect with a different fix from "something
135
+ * called scrollTo", and the two are indistinguishable without this.
136
+ *
137
+ * True when the move was BACKWARDS onto the new maximum from a position that
138
+ * the shrink had just made impossible. A forward move is never a clamp, and
139
+ * neither is a backwards one from a position still inside the new range.
140
+ */
141
+ clamped: boolean;
142
+ /** scrollHeight − clientHeight, before and after. */
143
+ maxBefore: number;
144
+ maxAfter: number;
145
+ /**
146
+ * F312.14 — HOW FAR THE PICTURE MOVED, which is a different number from `delta`.
147
+ *
148
+ * Same row still at the top, same offset → 0: the reader saw nothing, however
149
+ * far scrollTop travelled. That is the normal case for an anchor holding a
150
+ * message steady while content is inserted above it, and calling it movement
151
+ * points the next fix at the one mechanism that is working.
152
+ *
153
+ * null when the top row CHANGED — then the picture certainly moved, and by more
154
+ * than a pixel count can express, so no number is offered rather than a
155
+ * misleading one.
156
+ */
157
+ visibleShift: number | null;
158
+ /** F312.15 — how many rows were rendered either side. A top row that changed
159
+ * because rows were REMOVED above is the render window dropping the message
160
+ * under the reader; one that changed with the count steady is something
161
+ * scrolling him. Same symptom, opposite fix. */
162
+ rowsBefore: number;
163
+ rowsAfter: number;
164
+ /** Where the top row sat, either side — so a null visibleShift still shows its
165
+ * working rather than only its verdict. */
166
+ firstRowYBefore: number;
167
+ firstRowYAfter: number;
168
+ }
169
+
170
+ export interface ProbeConfig {
171
+ /** CSS selector of the scroller. Omitted, the probe picks the tallest scrollable. */
172
+ selector?: string;
173
+ /** Sampling interval in ms. 50 = 20 Hz: fast enough to catch one flush, slow
174
+ * enough that the probe is not itself the jank it is measuring. */
175
+ intervalMs?: number;
176
+ /** A row's stable identity attribute. */
177
+ rowAttr?: string;
178
+ }
179
+
180
+ /**
181
+ * The script injected into the page. Written as a string-returning function so it
182
+ * can be handed to `page.evaluate` without a bundler, and so the browser half is
183
+ * readable next to the Node half that interprets it.
184
+ *
185
+ * READ-ONLY. It attaches passive listeners and reads geometry; it never writes
186
+ * style, never scrolls, never forces a layout beyond the rect reads the sampling
187
+ * already needs. A probe that changes what it measures is worse than no probe.
188
+ */
189
+ export function probeSource(cfg: ProbeConfig = {}): string {
190
+ const interval = cfg.intervalMs ?? 50;
191
+ const rowAttr = cfg.rowAttr ?? 'data-ev-uuid';
192
+ const sel = cfg.selector ? JSON.stringify(cfg.selector) : 'null';
193
+ return `(() => {
194
+ const SEL = ${sel};
195
+ const ROW = ${JSON.stringify(rowAttr)};
196
+ const pick = () => {
197
+ if (SEL) return document.querySelector(SEL);
198
+ // The tallest thing that can actually scroll. Deliberately not "the first
199
+ // overflow:auto": a page has several, and the one the human is reading is the
200
+ // one with content to spare.
201
+ let best = null, bestOver = 0;
202
+ for (const el of document.querySelectorAll('*')) {
203
+ const over = el.scrollHeight - el.clientHeight;
204
+ if (over > bestOver && el.clientHeight > 200) { best = el; bestOver = over; }
205
+ }
206
+ return best;
207
+ };
208
+ let el = pick();
209
+ const state = {
210
+ started: performance.now(),
211
+ lastInput: null,
212
+ lastTyping: null,
213
+ lastDriver: null,
214
+ samples: [],
215
+ selector: el ? (el.getAttribute('data-testid') ? '[data-testid="' + el.getAttribute('data-testid') + '"]' : el.tagName.toLowerCase()) : null,
216
+ error: el ? null : 'no scrollable element found',
217
+ };
218
+ window.__lensMotion = state;
219
+ // A DRIVER action stamps this; the Node half then knows the move was ours.
220
+ window.__lensDriverActed = () => { state.lastDriver = performance.now(); };
221
+ if (!el) return 'no-scroller';
222
+ // Passive + capture: we must not delay or alter the very interaction we measure.
223
+ // F312.5 — TWO CLOCKS, and the split is the whole correction.
224
+ //
225
+ // A wheel, a touch or an arrow key is a request to MOVE THE VIEW, so a move
226
+ // right after one is his and not a defect. A character typed into a field is
227
+ // input that must move NOTHING. Counting it as scroll-intent meant that while
228
+ // he was typing — continuously, one keystroke every ~150ms — every single move
229
+ // fell inside the grace window and the detector reported nothing at all. It was
230
+ // blind in exactly the case he described: "jeg kan ikke stå og svare på en
231
+ // tekst i et felt på en tabel uden at den rykker væk fra mig".
232
+ const NAV_KEYS = new Set(['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' ','Spacebar']);
233
+ const markScroll = () => { state.lastInput = performance.now(); };
234
+ const markType = () => { state.lastTyping = performance.now(); };
235
+ for (const ev of ['wheel', 'touchstart', 'touchmove', 'pointerdown'])
236
+ window.addEventListener(ev, markScroll, { capture: true, passive: true });
237
+ window.addEventListener('keydown', (e) => {
238
+ // A navigation key IN a text field still moves the caret, not the page — but
239
+ // outside one it scrolls. Editable target decides, which is the same question
240
+ // the browser itself asks.
241
+ const t = e.target;
242
+ const editable = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
243
+ if (NAV_KEYS.has(e.key) && !editable) markScroll();
244
+ else markType();
245
+ }, { capture: true, passive: true });
246
+ // F312.14 — WHAT THE EYE SEES, not what scrollTop says.
247
+ //
248
+ // These are not the same quantity, and conflating them makes the probe report
249
+ // the CORRECT behaviour as a defect. When content is inserted above the
250
+ // viewport, holding the reader's message still REQUIRES scrollTop to change by
251
+ // exactly the inserted height — the number moves so that the picture does not.
252
+ // Measured 6 Sept: a run flagged eight 'prepend-hold' moves of −92px to −456px,
253
+ // every one of them the anchor doing its job.
254
+ //
255
+ // So the probe also records WHERE the first visible row sits. Same row at the
256
+ // same offset = the reader saw nothing move, whatever scrollTop did.
257
+ // F312.23 — read the reader's OWN remembered place out of the app's storage.
258
+ // Several channels can each have a key; the right one is the channel currently
259
+ // rendered, so the uuid must be present in the DOM. Picking the first key would
260
+ // silently report ANOTHER conversation's row and look exactly like working.
261
+ const anchorAt = () => {
262
+ let sawBottom = false;
263
+ let sawPlace = false;
264
+ try {
265
+ for (let i = 0; i < localStorage.length; i++) {
266
+ const k = localStorage.key(i);
267
+ if (!k || k.indexOf('chat-anchor:') !== 0) continue;
268
+ let v = null;
269
+ try { v = JSON.parse(localStorage.getItem(k)); } catch (e) { continue; }
270
+ if (!v) continue;
271
+ if (v.bottom) { sawBottom = true; continue; }
272
+ if (!v.uuid) continue;
273
+ sawPlace = true;
274
+ const row = el.querySelector('[' + ROW + '="' + (window.CSS && CSS.escape ? CSS.escape(v.uuid) : v.uuid) + '"]');
275
+ if (!row) continue;
276
+ return {
277
+ id: v.uuid,
278
+ y: Math.round(row.getBoundingClientRect().top - el.getBoundingClientRect().top),
279
+ want: typeof v.offset === 'number' ? -Math.round(v.offset) : 0,
280
+ state: 'held',
281
+ };
282
+ }
283
+ } catch (e) { /* storage can throw in a private context; report none */ }
284
+ if (sawPlace) return { id: null, y: null, want: null, state: 'not-rendered' };
285
+ if (sawBottom) return { id: null, y: null, want: null, state: 'bottom' };
286
+ return { id: null, y: null, want: null, state: 'none' };
287
+ };
288
+ const firstRowAt = () => {
289
+ const top = el.getBoundingClientRect().top;
290
+ const bottom = top + el.clientHeight;
291
+ const rows = el.querySelectorAll('[' + ROW + ']');
292
+ // F312.15 — record SEVERAL visible rows, not only the first.
293
+ //
294
+ // Comparing "the first visible row before" against "the first visible row
295
+ // after" answers a different question from the one being asked. Those can be
296
+ // two different messages while the one the reader is actually looking at has
297
+ // not moved a pixel — a message above it merely grew into view. The honest
298
+ // measure is: take a row present in BOTH samples and ask where it went.
299
+ const vis = [];
300
+ let first = { id: null, y: 0 };
301
+ for (let i = 0; i < rows.length; i++) {
302
+ const b = rows[i].getBoundingClientRect();
303
+ if (b.bottom <= top + 4) continue;
304
+ if (b.top >= bottom) break;
305
+ const id = rows[i].getAttribute(ROW);
306
+ const y = Math.round(b.top - top);
307
+ if (first.id === null) first = { id: id, y: y };
308
+ if (vis.length < 8) vis.push({ id: id, y: y });
309
+ }
310
+ return { id: first.id, y: first.y, vis: vis };
311
+ };
312
+ // F312.16 — WHEN DID IT LAND? A marker planted in the fed message is looked for
313
+ // on every tick, and the first tick that sees it is its arrival. Paired with the
314
+ // send time on the Node side this gives the end-to-end latency of the live
315
+ // stream — and, more to the point here, a defensible answer to "was the surface
316
+ // exercised by REAL agent output, or only by its own echo?".
317
+ const seen = {};
318
+ window.__lensMarkers = seen;
319
+ const scanMarkers = (nowMs) => {
320
+ const t = el.textContent || '';
321
+ let i = t.indexOf('LENSPROBE-');
322
+ while (i !== -1) {
323
+ const m = t.slice(i, i + 24).split(/[^A-Za-z0-9-]/)[0];
324
+ if (m && seen[m] === undefined) seen[m] = Math.round(nowMs - state.started);
325
+ i = t.indexOf('LENSPROBE-', i + 10);
326
+ }
327
+ };
328
+ const tick = () => {
329
+ // F312.22 — RE-RESOLVE A REPLACED SCROLLER.
330
+ //
331
+ // The probe held the element it found at start. A tab switch REPLACES that
332
+ // node, so from that moment the probe was measuring a detached element:
333
+ // scrollHeight 0, no samples, and the return — the thing being measured —
334
+ // was invisible. Measured 6 Sept on the first tab-switch run that actually
335
+ // ran: heightEnd 0, and nothing recorded after the switch.
336
+ //
337
+ // Only when the node has genuinely left the document. A live element is
338
+ // never swapped, so this cannot make the probe follow the wrong scroller
339
+ // mid-measurement; and if the re-pick finds nothing, the old reference is
340
+ // kept rather than replaced with null, so a transient re-render does not
341
+ // blind the run.
342
+ if (el && !el.isConnected) {
343
+ const again = pick();
344
+ if (again) el = again;
345
+ }
346
+ const now = performance.now();
347
+ scanMarkers(now);
348
+ const fr = firstRowAt();
349
+ const an = anchorAt();
350
+ state.samples.push({
351
+ t: Math.round(now - state.started),
352
+ top: Math.round(el.scrollTop),
353
+ height: Math.round(el.scrollHeight),
354
+ client: Math.round(el.clientHeight),
355
+ rows: el.querySelectorAll('[' + ROW + ']').length,
356
+ firstRow: fr.id,
357
+ firstRowY: fr.y,
358
+ visible: fr.vis,
359
+ anchorId: an.id,
360
+ anchorY: an.y,
361
+ anchorWant: an.want,
362
+ anchorState: an.state,
363
+ sinceInput: state.lastInput == null ? null : Math.round(now - state.lastInput),
364
+ sinceTyping: state.lastTyping == null ? null : Math.round(now - state.lastTyping),
365
+ sinceDriver: state.lastDriver == null ? null : Math.round(now - state.lastDriver),
366
+ });
367
+ };
368
+ tick();
369
+ state.timer = setInterval(tick, ${interval});
370
+ return 'ok';
371
+ })()`;
372
+ }
373
+
374
+ /** Read the samples back out. */
375
+ export const PROBE_READ = `(() => {
376
+ const s = window.__lensMotion;
377
+ if (!s) return { error: 'probe never started', samples: [] };
378
+ if (s.timer) clearInterval(s.timer);
379
+ // F312.12 — bring the page's own scroll ledger back, REBASED onto the probe's
380
+ // clock so an entry can be lined up with the millisecond of a finding. Absent
381
+ // on a surface that does not keep one; that is reported as an empty list, never
382
+ // as a failure, since most surfaces have nothing to declare.
383
+ // Entries from BEFORE the probe started keep a negative t rather than being
384
+ // dropped: a scroll the page performed while it was loading is still evidence,
385
+ // and silently discarding it makes an empty ledger ambiguous between "nothing
386
+ // wrote" and "everything wrote too early to count".
387
+ const ledger = Array.isArray(window.__chatScrollLog)
388
+ ? window.__chatScrollLog.map((n) => ({ ...n, t: Math.round(n.t - s.started) }))
389
+ : null;
390
+ // null = the surface keeps NO ledger (the property was never created).
391
+ // [] = it keeps one and nothing wrote to it. Those are different answers and
392
+ // collapsing them is how a broken read looks like a clean result.
393
+ return {
394
+ error: s.error,
395
+ selector: s.selector,
396
+ samples: s.samples,
397
+ ledger,
398
+ markers: window.__lensMarkers || {},
399
+ };
400
+ })()`;
401
+
402
+ /** F312.12 — one entry from the page's own scroll ledger, on the probe's clock. */
403
+ export interface ScrollNote {
404
+ t: number;
405
+ why: string;
406
+ from: number;
407
+ to: number;
408
+ force: boolean;
409
+ refused?: string;
410
+ }
411
+
412
+ export interface FindOptions {
413
+ /** A move smaller than this is noise — sub-pixel settling, a scrollbar, a font. */
414
+ minDeltaPx?: number;
415
+ /**
416
+ * How long after a real input a move is still HIS. Momentum on a trackpad keeps
417
+ * moving the page for a while after the last wheel event, and reporting that as
418
+ * a defect would bury the real ones.
419
+ */
420
+ inputGraceMs?: number;
421
+ /** The same grace for the driver's own actions. */
422
+ driverGraceMs?: number;
423
+ }
424
+
425
+ /**
426
+ * Moves that no input preceded.
427
+ *
428
+ * THE NEGATIVE CONTROL IS BUILT IN, not bolted on: a move within `inputGraceMs`
429
+ * of a human input, or `driverGraceMs` of our own driving, is NOT a finding. A
430
+ * detector that reports every move reports itself, and would have "found" a bug
431
+ * in every recording ever made — the most expensive kind of green.
432
+ */
433
+ /** F312.23 — how far his row is from where the app said it would hold it.
434
+ *
435
+ * `null` whenever the anchor is not readable, and that is not the same as 0: a
436
+ * reader at the live end, or one whose message is outside the render window, has
437
+ * a place we simply cannot see this instant. Returning 0 there would report "his
438
+ * row did not move" for a row nobody looked at. */
439
+ export function anchorDrift(s: MotionSample): number | null {
440
+ if (s.anchorState !== 'held') return null;
441
+ if (s.anchorY == null || s.anchorWant == null) return null;
442
+ return s.anchorY - s.anchorWant;
443
+ }
444
+
445
+ export function findUncausedMoves(samples: MotionSample[], opts: FindOptions = {}): MotionFinding[] {
446
+ const minDelta = opts.minDeltaPx ?? 24;
447
+ const inputGrace = opts.inputGraceMs ?? 700;
448
+ const driverGrace = opts.driverGraceMs ?? 900;
449
+ const out: MotionFinding[] = [];
450
+ for (let i = 1; i < samples.length; i++) {
451
+ const a = samples[i - 1]!;
452
+ const b = samples[i]!;
453
+ const delta = b.top - a.top;
454
+ if (Math.abs(delta) < minDelta) continue;
455
+ // CLASSIFIED, NOT DROPPED (F312.6). His finger and ours are still not
456
+ // defects — but they are RECORDED as such, so "he had just stopped
457
+ // scrolling" is visible instead of invisible.
458
+ //
459
+ // NOTE what does not appear here: `sinceTyping`. Typing is not a reason to
460
+ // move the view, so it can never excuse one — it only makes the move worse,
461
+ // which `whileTyping` records.
462
+ const cause: MotionFinding['cause'] =
463
+ b.sinceDriver != null && b.sinceDriver <= driverGrace
464
+ ? 'driver'
465
+ : b.sinceInput != null && b.sinceInput <= inputGrace
466
+ ? 'his-scroll'
467
+ : 'none';
468
+ out.push({
469
+ cause,
470
+ sinceInput: b.sinceInput,
471
+ t: b.t,
472
+ from: a.top,
473
+ to: b.top,
474
+ delta,
475
+ firstRowBefore: a.firstRow,
476
+ firstRowAfter: b.firstRow,
477
+ anchorDriftBefore: anchorDrift(a),
478
+ anchorDriftAfter: anchorDrift(b),
479
+ anchorStateBefore: a.anchorState,
480
+ anchorStateAfter: b.anchorState,
481
+ contentChanged: a.rows !== b.rows || a.height !== b.height,
482
+ whileTyping: b.sinceTyping != null && b.sinceTyping <= 1500,
483
+ maxBefore: a.height - a.client,
484
+ maxAfter: b.height - b.client,
485
+ visibleShift: sharedShift(a, b),
486
+ layoutSpread: layoutSpread(a, b),
487
+ rowsBefore: a.rows,
488
+ rowsAfter: b.rows,
489
+ firstRowYBefore: a.firstRowY,
490
+ firstRowYAfter: b.firstRowY,
491
+ // A CLAMP is the browser having nowhere to put you: the content above
492
+ // shrank, so the position you were at STOPPED EXISTING and you were pushed
493
+ // back to the new bottom. That means it is necessarily BACKWARDS and it
494
+ // requires `from` to be past the new maximum.
495
+ //
496
+ // The first version only asked "did the max fall, and are you at it now?",
497
+ // which is true of ANY move that lands at the bottom. Measured 6 Sept: a
498
+ // +5806px yank from a reading position down to the bottom — the single
499
+ // worst finding in the run — was labelled a clamp while the maximum had
500
+ // moved 31px. A clamp cannot account for more travel than the maximum lost,
501
+ // and calling a forced scroll a clamp points the next diagnosis at the
502
+ // wrong mechanism entirely.
503
+ clamped: a.top > b.height - b.client && b.top >= b.height - b.client - 2 && delta < 0,
504
+ });
505
+ }
506
+ return out;
507
+ }
508
+
509
+ /**
510
+ * F312.15 — HOW FAR THE PICTURE MOVED, measured on a row that is on screen in
511
+ * BOTH samples.
512
+ *
513
+ * The first version compared the top row's identity, and answered "unknown"
514
+ * whenever it changed. That is too pessimistic in the exact case that matters:
515
+ * a message ABOVE the one being read can grow into view, making it the new top
516
+ * row, while the message under the reader's eye has not moved at all. Measured 6
517
+ * Sept — the anchor holding a message steady was reported as an unknown-sized
518
+ * jump nine times.
519
+ *
520
+ * So: find a row present on screen in both, and report where it went. Null only
521
+ * when they share NO row, which genuinely means the reader is looking at
522
+ * different content and no pixel count would describe it.
523
+ */
524
+ /** F312.24 — how much the shared rows DISAGREE about how far they moved.
525
+ *
526
+ * A pure scroll moves every row by the same amount, so the spread is 0. Content
527
+ * above one of them growing moves them by different amounts, and the spread is
528
+ * that growth. It is the one number that separates "the view moved" from "the
529
+ * ground moved", and the drift column cannot: both produce the same drift.
530
+ *
531
+ * Fewer than two shared rows is `null`. Returning 0 there would assert rigidity
532
+ * from a sample that cannot see it. */
533
+ export function layoutSpread(a: MotionSample, b: MotionSample): number | null {
534
+ const before = new Map<string, number>();
535
+ for (const v of a.visible) if (v.id) before.set(v.id, v.y);
536
+ const shifts: number[] = [];
537
+ for (const v of b.visible) {
538
+ if (!v.id) continue;
539
+ const y = before.get(v.id);
540
+ if (y == null) continue;
541
+ shifts.push(v.y - y);
542
+ }
543
+ if (shifts.length < 2) return null;
544
+ return Math.round(Math.max(...shifts) - Math.min(...shifts));
545
+ }
546
+
547
+ export function sharedShift(a: MotionSample, b: MotionSample): number | null {
548
+ const after = new Map<string, number>();
549
+ for (const r of b.visible ?? []) if (r.id != null) after.set(r.id, r.y);
550
+ for (const r of a.visible ?? []) {
551
+ if (r.id == null) continue;
552
+ const y = after.get(r.id);
553
+ if (y != null) return y - r.y;
554
+ }
555
+ return null;
556
+ }
557
+
558
+ /** The moves nobody caused. Still reported — classify, never discard (F312.6). */
559
+ export function uncaused(findings: MotionFinding[]): MotionFinding[] {
560
+ return findings.filter((f) => f.cause === 'none');
561
+ }
562
+
563
+ /**
564
+ * F312.19 — WHAT A DEFECT ACTUALLY IS: nobody caused it AND the reader could see
565
+ * it.
566
+ *
567
+ * Raised by the trail session from a real run of ours. Two findings, both
568
+ * `cause: 'none'`, both scrollTop −92px — and both with `visible_shift` 0 and the
569
+ * top row at the same offset before and after. Content above the reading position
570
+ * had shrunk and the browser adjusted scrollTop to match. That is scroll
571
+ * anchoring doing its job perfectly, reported as a defect.
572
+ *
573
+ * Their sentence is the specification: the complaint is "the picture moved and I
574
+ * did not do it" — and there, the picture did not move. Two false ones a minute
575
+ * in an ordinary chat would drown the real one.
576
+ *
577
+ * `null` COUNTS AS VISIBLE, and that asymmetry is the load-bearing part: null
578
+ * means the two samples shared no row at all, so the reader is looking at
579
+ * different content and the move was as visible as a move can be. Treating an
580
+ * unknown as "not seen" would hide the largest jumps this tool exists to catch.
581
+ */
582
+ export function defects(findings: MotionFinding[], minVisiblePx = 8): MotionFinding[] {
583
+ return findings.filter(
584
+ (f) => f.cause === 'none' && (f.visibleShift === null || Math.abs(f.visibleShift) > minVisiblePx),
585
+ );
586
+ }
587
+
588
+ /**
589
+ * F312.8 — WAS THE THING EVEN EXERCISED?
590
+ *
591
+ * Measured the hard way: two identical 120s recordings of the same build gave 4
592
+ * findings and then 0. Nothing was fixed in between — the second window simply
593
+ * had less arriving in the stream. A zero from a quiet window is indistinguishable
594
+ * from a zero from a fixed product, and reporting the first as the second is the
595
+ * false green this whole feature exists to prevent.
596
+ *
597
+ * So a run reports how much the surface actually CHANGED. A zero with no content
598
+ * events is "not exercised"; a zero with forty is a result.
599
+ */
600
+ export interface Exercise {
601
+ /** Samples where the row count or the height changed — i.e. content arrived. */
602
+ contentEvents: number;
603
+ rowsStart: number;
604
+ rowsEnd: number;
605
+ heightStart: number;
606
+ heightEnd: number;
607
+ /** The honest verdict on whether a zero means anything. */
608
+ exercised: boolean;
609
+ }
610
+
611
+ export function measureExercise(samples: MotionSample[]): Exercise {
612
+ if (samples.length === 0)
613
+ return { contentEvents: 0, rowsStart: 0, rowsEnd: 0, heightStart: 0, heightEnd: 0, exercised: false };
614
+ let events = 0;
615
+ for (let i = 1; i < samples.length; i++) {
616
+ const a = samples[i - 1]!;
617
+ const b = samples[i]!;
618
+ if (a.rows !== b.rows || a.height !== b.height) events++;
619
+ }
620
+ const first = samples[0]!;
621
+ const last = samples[samples.length - 1]!;
622
+ return {
623
+ contentEvents: events,
624
+ rowsStart: first.rows,
625
+ rowsEnd: last.rows,
626
+ heightStart: first.height,
627
+ heightEnd: last.height,
628
+ // Deliberately strict, but on the RIGHT quantity. The first version required
629
+ // the row count to GROW — measured wrong: the render window drops old
630
+ // messages as new ones arrive, so a busy 120s window ended with FEWER rows
631
+ // (123 → 121) and the run reported itself as not exercised while 13 content
632
+ // events and 900px of growth had gone past. Height is the honest signal;
633
+ // rows are a window, not a total.
634
+ exercised: events >= 10 && last.height !== first.height,
635
+ };
636
+ }
637
+
638
+ /** Worst first — and BACKWARDS beats forwards at equal size, because being carried
639
+ * into the past is the complaint that started this. An uncaused move always
640
+ * outranks one he or the driver caused; the caused ones stay in the list so a
641
+ * "vupti, den hoppede" right after a scroll is READABLE rather than dropped. */
642
+ export function rankFindings(findings: MotionFinding[]): MotionFinding[] {
643
+ return [...findings].sort((x, y) => {
644
+ const own = (f: MotionFinding) => (f.cause === 'none' ? 1 : 0);
645
+ if (own(y) !== own(x)) return own(y) - own(x);
646
+ // Worst first: moving the ground while he is MID-SENTENCE beats everything.
647
+ const typing = (f: MotionFinding) => (f.whileTyping ? 1 : 0);
648
+ if (typing(y) !== typing(x)) return typing(y) - typing(x);
649
+ const back = (f: MotionFinding) => (f.delta < 0 ? 1 : 0);
650
+ if (back(y) !== back(x)) return back(y) - back(x);
651
+ return Math.abs(y.delta) - Math.abs(x.delta);
652
+ });
653
+ }