summaryrefslogtreecommitdiffstats
path: root/themes/luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js
blob: 67224e46e2b9f235daff4fc9b7390abc8d42a2a3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
'use strict';
'require baseclass';
'require ui';

/* fs-fit — the theme's one "does it still fit?" engine and the one place the reader's scroll
 * position is corrected. Add fit logic here, never a second observer: no CSS query can ask what the
 * content needs (media = viewport, container = container). Three rules, each a bug that was hit:
 * rule 1, measure uncollapsed, since a collapsed thing always fits; rule 2, re-fit synchronously on
 * a mutation, since deferring a poll tick to rAF paints one frame of a stacked table at full width
 * (19-109px of overflow); rule 3, coalesce on resize, since every fit forces a layout. The reader's
 * place is docs/anchoring.md, "The corrections"; the measurement behind each rule is
 * docs/anchoring-log.md. */

/* Exported, not armed at module eval: only fs-select.js clears the rule this raises, and arming
 * without it left every data table invisible. docs/anchoring-log.md, "The arm belongs to the disarm". */
function armGate() {
	if (!fittersEnabled()) return;
	/* WRITTEN AS THE LITERAL `dataset.fsFit`, never through a helper: tools/table-contract.mjs
	 * reads this file for exactly that spelling to prove the gate rule is still armed, and an
	 * indirection hides the write from it. The behaviour survives being factored out; the
	 * contract does not. */
	try { document.documentElement.dataset.fsFit = '1'; }
	catch (e) { /* no document, no flag to write */ }
}

const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null, _moFlag = null, _moTabs = null;

function runAll(list, what) {
	for (const fit of list) {
		try { fit(); }
		catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
	}
}

/* dev switch — docs/anchoring.md, "The dev switches" */
function fittersEnabled() {
	try { return localStorage.getItem('fsFit') !== 'off'; }
	catch (e) { return true; }
}
function run(records) {
	if (!fittersEnabled()) return;
	runAll(_fitters, 'fitter');
	holdFloor(records);
	if (!_anchorPending) rememberRest();
}

/* ---- the document may not get shorter while a tick is in flight ----
 * A layout taken while `dom.content()` has the container empty clamps the reader's offset into a
 * document that was never that short. Written before the tick (a pin in the same statement
 * sequence is never laid out: 1882px still clamped away) and on the containers, never the column,
 * where it suppresses the engine's own anchoring (css-scroll-anchoring-1 §2.2.2) and cost 120px of
 * growth, all 120px. The selector is `dom.content()`'s own three: a section body, a table, and a
 * table's body — the third was missing and a poll emptying it took 58px from under the reader on
 * 24.10's Overview. docs/anchoring.md, "The document may not get shorter: `holdFloor()`". */
const SHRINKS = '.cbi-section > div, .table';

const FLOORED = '[data-fs-floor]';

/* One floor per container, cleared and re-measured in one batched pass: not while the reader
 * scrolls, and not on a table box — `min-height` is undefined there (CSS 2.1 §10.7) and a 313px
 * floor still collapsed to 30px on WebKit. The clear is what keeps the answer honest (issue #41). */
function holdFloor(records) {
	if (scrolling()) return;
	const host = document.getElementById('view');
	if (!host) return;			/* the login page has no view */
	const boxes = [], hs = [];
	host.querySelectorAll(SHRINKS).forEach((el) => {
		let box = el, cs = window.getComputedStyle(el);
		while (box && box !== host && cs.display.startsWith('table')) {
			box = box.parentElement;
			if (box) cs = window.getComputedStyle(box);
		}
		if (!box || box === host || boxes.indexOf(box) !== -1) return;
		boxes.push(box);
	});
	host.querySelectorAll(FLOORED).forEach((box) => { if (boxes.indexOf(box) === -1) boxes.push(box); });

	let dirty = boxes;
	if (records && records.length) {
		const targets = [];
		for (const r of records) if (r.target && targets.indexOf(r.target) === -1) targets.push(r.target);
		dirty = boxes.filter((box) => targets.some((t) => box.contains(t) || t.contains(box)));
	}
	if (!dirty.length) return;

	/* The sweep puts back the offset its own clear pass took: 8 of 38 sweeps took the document down a
	 * pixel and the offset with it, losing a 60px correction. docs/anchoring-log.md, "The sweep's own clamp". */
	const sc = scroller(), page = sc || document.documentElement;
	const at = scrollTop(), tall = page.scrollHeight;
	dirty.forEach((box) => { box.style.minHeight = ''; });
	/* The box's own rect height, not `offsetHeight`'s rounding: 22 floors half a pixel too tall made
	 * the document 2px taller, and the next clear handed that back as a clamp — 6 of 12 refills
	 * corrected against 12 of 12. docs/anchoring-log.md, "The floor is written half a pixel too tall". */
	dirty.forEach((box) => hs.push(box.getBoundingClientRect().height));
	dirty.forEach((box, i) => {
		if (hs[i] > 0) { box.style.minHeight = hs[i] + 'px'; box.setAttribute('data-fs-floor', ''); }
		else box.removeAttribute('data-fs-floor');
	});
	/* And where the scroller stayed shorter the shrink is real: the clamp gave back only the 60px the
	 * document lost at its bottom. docs/anchoring-log.md, "A clamp is not the reader". */
	const landed = scrollTop();
	if (landed >= at) return;
	if (page.scrollHeight >= tall) writeOffset(sc, at); else _clampedTo = landed;
}

/* Is the page moving? asked of the position, never of the events: on iOS momentum carries the page
 * long after the finger has gone, and one offset read per frame sees it. SCROLL_IDLE is how long the
 * page must hold still before put-off work may run: 137-256px of roughness at 200ms against 59px —
 * one pixel of rounding per frame, the floor — at 250ms and above.
 * docs/anchoring.md, "Is the page moving: `scrolling()`". */
const SCROLL_IDLE = 400;
let _deferred = false;
function deferMeasurement() { _deferred = true; }
let _movingUntil = 0;
let _lastOffset = null;
let _sampling = false;
/* This file's own write is a scroll event too, and counting it as motion blocked the floor, the
 * reference and the correction for 400ms — 59px uncorrected, and two of them distrusted a correct
 * engine. docs/anchoring-log.md, "A correction's own write reads as the reader moving". */
let _ownWrite = null;
function sawOwnWrite(y) {
	if (_ownWrite === null) return true;
	if (Math.abs(y - _ownWrite) < 1) return false;
	_ownWrite = null;
	return true;
}
/* Where the browser's clamp last put the offset, read by `applyAnchor()` alone — `scrolling()`
 * keeps answering for the whole theme. docs/anchoring-log.md, "A clamp is not the reader". */
let _clampedTo = null;
function sawClamp() {
	if (scrollTop() === _clampedTo) return true;
	_clampedTo = null;
	return false;
}
function writeOffset(sc, value) {
	if (sc) sc.scrollTop = value; else window.scrollTo(0, value);
	_ownWrite = sc ? sc.scrollTop : window.scrollY;
}

/* Which element scrolls, cached per width: a probe per frame is a forced layout mid-flick, and the
 * stylesheet decides it. docs/anchoring-log.md, "Which element scrolls, asked once per width". */
let _scroller = null, _scrollerAt = -1, _scrollerKey = null;
function layoutKey() {
	const root = document.documentElement;
	return (root.getAttribute('data-layout') || '') + (root.hasAttribute('data-narrow') ? '|narrow' : '');
}
function scroller() {
	const key = layoutKey();
	if (_scrollerAt === _resizeSeq && _scrollerKey === key &&
	    (_scroller === null || _scroller.isConnected))
		return _scroller;
	const sc = document.getElementById('maincontent');
	const flow = sc ? window.getComputedStyle(sc).overflowY : '';
	_scroller = (flow === 'auto' || flow === 'scroll') ? sc : null;
	_scrollerAt = _resizeSeq;
	_scrollerKey = key;
	return _scroller;
}
function scrollTop() {
	const sc = scroller();
	return sc ? sc.scrollTop : window.scrollY;
}

function scrolling() { return Date.now() < _movingUntil; }
function sampleMotion() {
	const y = scrollTop();
	if (_lastOffset === null || y !== _lastOffset) {
		const own = !sawOwnWrite(y);
		_lastOffset = y;
		if (!own) _movingUntil = Date.now() + SCROLL_IDLE;
	}
	if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
	_sampling = false;
	/* The offset's own response to the write below, not a reference element's drift: one re-established
	 * on wrong ground shows none. docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
	const target = _deferredFloor;
	_deferredFloor = null;
	const floorBefore = (target && target.isConnected) ? (parseFloat(target.style.minHeight) || 0) : 0;
	const offsetBefore = scrollTop();
	holdFloor();
	if (target && target.isConnected) {
		const shrink = floorBefore - (parseFloat(target.style.minHeight) || 0);
		if (shrink > 1) settleDeferredFloor(offsetBefore, shrink);
	}
	rememberRest();
	if (_deferred) {
		_deferred = false;
		run();
	}
}

function noteMotion() {
	if (!sawOwnWrite(scrollTop())) return;
	_movingUntil = Date.now() + SCROLL_IDLE;
	if (_sampling) return;
	_sampling = true;
	requestAnimationFrame(sampleMotion);
}

let _userUntil = 0;
function noteIntent() {
	_userUntil = Date.now() + SCROLL_IDLE;
}
function noteUser() {
	noteIntent();
	noteMotion();
}

(function watchMotion() {
	const opts = { passive: true, capture: true };
	window.addEventListener('scroll', noteMotion, opts);
	for (const name of [ 'wheel', 'touchmove' ])
		window.addEventListener(name, noteUser, opts);
	for (const name of [ 'mousedown', 'keydown', 'touchstart' ])
		window.addEventListener(name, noteIntent, opts);
})();

function schedule() {
	if (_rafPending) return;
	_rafPending = true;
	requestAnimationFrame(() => { _rafPending = false; run(); });
}

/* Width only: iOS resizes the viewport height as the URL bar slides away, and twenty height-only
 * steps rewrote 1054 class attributes. docs/anchoring-log.md, "Width only: the URL bar is a resize". */
let _resizeSeq = 0;
const _lastWidth = new WeakMap();
function onResize(entries) {
	let widthMoved = false;
	for (const e of entries) {
		const w = Math.round(e.contentRect.width);
		if (_lastWidth.get(e.target) !== w) {
			_lastWidth.set(e.target, w);
			widthMoved = true;
		}
	}
	if (widthMoved) { _resizeSeq++; schedule(); }
}

function watch(el) {
	if (!el) return;
	if (!_ro) _ro = new ResizeObserver(onResize);
	_ro.observe(el);
}

/* ---- scroll anchoring, where the engine has none ----
 * An older WebKit absorbs none of a tick's growth and the page moved on every one, measured on the
 * reporter's own router: +133px, +134px, +123px, +108px. Computed from the reference, never the
 * offset: measuring the offset corrects an anchoring engine's own adjustment (16 movements, 1827px).
 * Whether there is one to leave anything behind is asked of the platform, never of a browser name —
 * with anchoring suppressed, a 120px growth moves the reader 120px.
 * docs/anchoring.md, "Who is responsible: `ENGINE_ANCHORS`" and "The corrections". */
const ENGINE_ANCHORS = (() => {
	/* dev switch — docs/anchoring.md, "The dev switches" */
	try { if (localStorage.getItem('fsEngineAnchor') === 'off') return false; }
	catch (e) { /* no storage, no switch */ }
	try { return typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
		? CSS.supports('overflow-anchor', 'auto') : true; }
	catch (e) { return true; }
})();

/* A load-time check cannot see an engine that declines on a given refill: two residuals written
 * back 419-420ms late move the observer to the 7-36ms path until trust returns. Never a browser
 * name, only a count. docs/anchoring.md, "When the platform check is not enough: `_engineTrusted`". */
const LATE_MISS_LIMIT = 2;
let _lateMisses = 0;
let _engineTrusted = ENGINE_ANCHORS;
/* The witness's own rounding on a 32-36 row lease table at 390 wide is 8-12.25px, none of them a
 * residual. docs/anchoring-log.md, "The witness is not safe to write on its own". */
const LATE_ROUND_TOLERANCE = 16;

/* Trust returns after this many refills where `_rest.el` itself held: the two cheaper witnesses
 * tried recovered on an engine still 48px wrong. docs/anchoring-log.md, "Trust that comes back". */
const TRUST_RECOVERY_LIMIT = 2;
let _lateHits = 0;

let _rest = null;
let _restAt = null, _restPage = null;
/* The floored box `holdFloor()` refused for: wired to nothing, a growth-then-shrink refill inside
 * one motion window left `_restAt` 59px high.
 * docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
let _deferredFloor = null;
function pageStamp() {
	return (document.body && document.body.getAttribute('data-page')) || '';
}
function forgetRest() {
	_rest = null;
	_restAt = null;
	_restPage = null;
}
/* `force`: only for the caller that has just written the offset itself, since the engine's own
 * compensation leaves `scrolling()` true for milliseconds after it — `moved 0, 0, -59` without,
 * `0, 0, 0` with it. docs/anchoring-log.md, "The reference after this file's own write must be
 * re-taken forced". */
function rememberRest(force) {
	if (scrolling() && !force) return;
	if (ENGINE_ANCHORS && scrollTop() <= 0) {
		_rest = null;
		_restAt = 0;
		_restPage = pageStamp();
		return;
	}
	const ref = anchorRef();
	_restAt = scrollTop();
	_restPage = pageStamp();
	_rest = ref ? { el: ref.el, top: ref.top, at: _restAt, sec: ref.sec, secTop: ref.secTop } : null;
}

function anchorFor() {
	const at = scrollTop();
	const clamped = (_restAt != null && at < _restAt && !scrolling() && _restPage === pageStamp());
	if (!_rest || !_rest.el.isConnected) {
		if (clamped) return { by: _restAt - at };
		if (_rest && _rest.sec && _rest.sec.isConnected && at === _restAt)
			return { el: _rest.sec, top: _rest.secTop, slack: 0 };
		return anchorRef();
	}
	if (at !== _rest.at && !clamped) return null;
	return { el: _rest.el, top: _rest.top, slack: Math.max(0, _rest.at - at) };
}

function anchorRef() {
	if (scrolling()) return null;

	const host = document.getElementById('view');
	if (!host) return null;
	const box = host.getBoundingClientRect();
	const x = Math.round(box.left + (Math.min(box.width, window.innerWidth || box.width) / 2));
	let y = 1;
	let el = document.elementFromPoint(x, y);
	const chrome = el && el.closest ? el.closest('[data-fs-chrome]') : null;
	if (chrome) y = Math.max(1, Math.round(chrome.getBoundingClientRect().bottom) + 1);

	const floor = Math.max(1, Math.round(window.innerHeight || 800));
	const pick = (yy) => {
		if (typeof document.elementsFromPoint === 'function') {
			for (const cand of document.elementsFromPoint(x, yy))
				if (cand !== host && host.contains(cand)) return cand;
			return null;
		}
		const one = document.elementFromPoint(x, yy);
		return (one && one !== host && host.contains(one)) ? one : null;
	};
	el = null;
	for (let step = 0; step < 5 && !el; step++)
		el = pick(Math.min(floor - 1, y + (Math.round(floor * 0.12) * step)));
	if (!el) return null;
	const table = el.closest('.table.fs-dt');
	if (table) {
		const up = table.parentElement;
		el = (up && up !== host && host.contains(up)) ? up : table;
	}
	if (!el || el === host || !host.contains(el)) return null;
	if (!el.getClientRects().length) return null;
	let keep = el.parentElement;
	while (keep && keep !== host && !keep.classList.contains('cbi-section')
			&& !keep.classList.contains('cbi-map') && !keep.classList.contains('fs-ovl'))
		keep = keep.parentElement;
	if (!keep || keep === host || !host.contains(keep)) keep = null;
	return { el, top: el.getBoundingClientRect().top,
		sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
}

let _anchorPending = null;
let _anchorWhy = null;
const _anchorTrail = [];
function awhy(w) {
	_anchorWhy = w;
	_anchorTrail.push(w + '@' + Math.round(performance.now()));
	if (_anchorTrail.length > 8) _anchorTrail.shift();
}
let _anchorFrame = 0;
function anchorEnabled() {
	try { return localStorage.getItem('fsAnchor') !== 'off'; }
	catch (e) { return true; }
}
/* ---- what the engine's own anchoring leaves behind ----
 * It keeps a reference still; it does not promise that a section can vanish and come back. An
 * older WebKit moved 180px for 120px of growth, so the element is asked where it is now. */
let _lateFrame = 0;
/* why the last late correction did or did not write — eight exits, one symptom from outside */
let _lateWhy = null;
const _lateTrail = [];
function why(w) {
	_lateWhy = w;
	_lateTrail.push(w + '@' + Math.round(performance.now()));
	if (_lateTrail.length > 8) _lateTrail.shift();
}

function lateDrift(ref, grow, floorShrink) {
	if (_lateFrame) return why('busy');
	if (!ref) return why('no-reference');
	why('armed');
	_lateFrame = requestAnimationFrame(() => {
		why('frame');
		const seen = scrollTop();
		const settle = () => {
			_lateFrame = 0;
			why('settle');
			if (!anchorEnabled()) return why('anchoring-off');
			if (Date.now() < _userUntil) return why('reader-intent');
			if (_restPage !== pageStamp()) return why('page-changed');
			/* The offset, not the event stream: the engine's own compensation starts the motion sampler, and
			 * in WebKit the event arrives up to 1.2s late. `ref.at`, since run() has re-taken `_restAt` and the
			 * comparison becomes a value against itself (320px through, @1440 side). */
			if (scrollTop() !== seen) return why('offset-moved');
			let el = ref.el, was = ref.top;
			if (!el || !el.isConnected) {
				if (!ref.sec || !ref.sec.isConnected || ref.secTop == null) return why('reference-gone');
				el = ref.sec; was = ref.secTop;
			}
			let drift = el.getBoundingClientRect().top - was;
			/* The witness can be blind: the fold's element need not sit below the container this tick refilled,
			 * 13 of 13 refills reading 0px with the reader 120px off, and a gap under the tolerance is `grow`'s
			 * own rounding. docs/anchoring-log.md, "A witness at the fold can be blind". */
			if (Math.abs(drift) < 1 && grow > 1) {
				const compensated = seen - ref.at;
				if (Math.abs(compensated) < 1) drift = grow - compensated;
				else if (Math.abs(grow - compensated) > LATE_ROUND_TOLERANCE) {
					if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
					rememberRest(true);
					return why('engine-partly-' + Math.round(compensated) + '-of-' + Math.round(grow));
				}
			}
			if (Math.abs(drift) < 1) {
				if (grow > 1 || floorShrink > 1) rememberRest(true);
				return why('no-drift-grow-' + Math.round(grow));	/* the engine put it back */
			}
			if (Math.abs(drift) > (window.innerHeight || 800)) return why('drift-too-big');
			const sc = scroller();
			const at = sc ? sc.scrollTop : window.scrollY;
			writeOffset(sc, at + drift);
			why('wrote-' + Math.round(drift));
			/* A fresh, forced rememberRest(): `_rest` was taken mid-transition, and left standing it is what
			 * the next refill measures against. docs/anchoring-log.md, "The reference after this file's
			 * own write must be re-taken forced". */
			rememberRest(true);
			/* A drop in this box's own floor says nothing about the engine: every run() rewrites `min-height`,
			 * an invalidation in itself. docs/anchoring-log.md, "A floor shrink is not evidence about the engine". */
			if (floorShrink > 1) return;
			/* A direct drift inside the blind branch's tolerance is that same table rounding: 1px against a
			 * 132px growth counted as a miss, twice over.
			 * docs/anchoring-log.md, "One pixel of direct drift is the same table rounding". */
			if (Math.abs(drift) <= LATE_ROUND_TOLERANCE) return;
			if (++_lateMisses >= LATE_MISS_LIMIT) { _engineTrusted = false; _lateHits = 0; }
		};
		/* How long to wait is a question about the reader (docs/anchoring.md): where nothing is driving,
		 * the engine is the only mover and its window is 7-36ms — 404-422ms became 8-61ms. */
		if (!scrolling() && Date.now() >= _userUntil) { why('now'); settle(); }
		else { why(scrolling() ? 'wait-idle-moving' : 'wait-idle-intent'); _lateFrame = window.setTimeout(settle, SCROLL_IDLE); }
	});
}

/* A floor `holdFloor()` could not clear at mutation time, cleared later with nobody watching: its
 * own frame slot, and the offset's own response to the write below as the check. Not gated on
 * `scrolling()` — the belated write is what the engine reacts to, and the reaction re-arms the
 * motion window, still reading true one rAF later on all three cells.
 * docs/anchoring-log.md, "A floor that shrinks with nobody watching". */
let _floorLateFrame = 0;
function settleDeferredFloor(offsetBefore, shrink) {
	if (_floorLateFrame) return;
	_floorLateFrame = requestAnimationFrame(() => {
		const seen = scrollTop();
		_floorLateFrame = requestAnimationFrame(() => {
			_floorLateFrame = 0;
			if (!anchorEnabled() || Date.now() < _userUntil) return;
			if (_restPage !== pageStamp()) return;
			if (scrollTop() !== seen) return;		/* still settling, or the reader has moved */
			const wanted = offsetBefore - shrink;
			const gap = wanted - seen;
			if (Math.abs(gap) <= LATE_ROUND_TOLERANCE) return;		/* the engine already gave it back */
			if (Math.abs(gap) > (window.innerHeight || 800)) return;
			const sc = scroller();
			const at = sc ? sc.scrollTop : window.scrollY;
			writeOffset(sc, at + gap);
			if (_rest) { _rest.top -= gap; if (_rest.sec) _rest.secTop -= gap; _rest.at = scrollTop(); }
			_restAt = scrollTop();
		});
	});
}

function scheduleAnchor(ref) {
	if (!ref) return awhy('no-reference');
	if (!anchorEnabled()) return awhy('anchoring-off');
	if (_anchorPending) return awhy('pending-kept-first');
	_anchorPending = ref;
	if (_anchorFrame) return;
	_anchorFrame = requestAnimationFrame(() => {
		_anchorFrame = 0;
		const pending = _anchorPending;
		_anchorPending = null;
		const wrote = applyAnchor(pending);
		rememberRest(wrote);
	});
}
function applyAnchor(ref) {
	if (!ref) return;
	/* Not into a moving page — unless the motion IS the clamp this correction is for, which held a
	 * correct -60px correction unwritten. docs/anchoring-log.md, "A clamp is not the reader". */
	if (scrolling() && !sawClamp()) return awhy('refused-moving');
	const sc = scroller();
	const at = sc ? sc.scrollTop : window.scrollY;
	if (ref.by != null) {
		if (ref.by < 1) return awhy('by-under-1');
		writeOffset(sc, at + ref.by);
		awhy('wrote-by-' + Math.round(ref.by));
		return true;
	}
	if (at <= 0) return awhy('at-top');
	if (!ref.el.isConnected) return awhy('reference-gone');
	const drift = ref.el.getBoundingClientRect().top - ref.top;
	if (Math.abs(drift) < 1) return awhy('no-drift');			/* nothing needed correcting here */
	_lateHits = 0;
	if (Math.abs(drift) > (window.innerHeight || 0) + 200 + (ref.slack || 0)) return awhy('drift-too-big');
	writeOffset(sc, at + drift);
	awhy('wrote-' + Math.round(drift));
	return true;
}

function observeContent() {
	if (_mo) return;
	const hosts = [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]
		.filter(Boolean);
	const viewHost = hosts[0];
	_mo = new MutationObserver((records) => {
		const settled = _rest;
		const trustEngine = _engineTrusted;
		const ref = trustEngine ? null : anchorFor();
		/* The record's own target, read before run() rewrites its floor: the growth in pixels, rather than
		 * whichever element the fold happened to hit, and read on every tick so recovery can tell a tick
		 * that tested the engine from one that did not. The floored box the record sits IN, since
		 * `dom.content()` refills a node inside the pinned box on 1 of 12 nodes (docs/anchoring-log.md,
		 * "A witness that cannot see the box it is measuring"). */
		let r = null, box = null;
		for (const m of records) {
			if (m.type !== 'childList' || !m.target.closest) continue;
			const b = m.target.closest(FLOORED);
			if (b) { r = m; box = b; break; }
		}
		const before = box && (parseFloat(box.style.minHeight) || 0);
		let grew = before ? box.offsetHeight - before : 0;
		/* only growth above the reader's reference: counting a box below it wrote the whole growth back,
		 * offset +120 and reader -120 (docs/anchoring-log.md, "Growth below the reader") */
		if (grew > 1 && settled && settled.el && settled.el.isConnected
				&& box.getBoundingClientRect().top >= settled.el.getBoundingClientRect().top) grew = 0;
		if (!trustEngine && grew > 1 && _rest && _rest.el.isConnected && Date.now() >= _userUntil
				&& !scrolling() && _restPage === pageStamp()
				&& Math.abs(_rest.el.getBoundingClientRect().top - _rest.top) < 1
				&& ++_lateHits >= TRUST_RECOVERY_LIMIT) {
			_engineTrusted = true;
			_lateMisses = _lateHits = 0;
		}
		const wasScrolling = scrolling();
		if (r && before && wasScrolling) _deferredFloor = box;
		run(records);
		if (!wasScrolling) _deferredFloor = null;
		const floorShrink = (r && before) ? Math.max(0, before - (parseFloat(box.style.minHeight) || 0)) : 0;
		/* `#view` itself emptied and refilled is a page swap: a reference measured mid-swap is read 431px
		 * out and written over a correct restoreScroll(). docs/anchoring.md, "The commit is not a refill". */
		let gone = 0, came = 0, took = 0, gave = 0;
		for (const m of records) {
			if (m.target === viewHost) { gone += m.removedNodes.length; came += m.addedNodes.length; }
			if (m.type === 'childList') { took += m.removedNodes.length; gave += m.addedNodes.length; }
		}
		if (gone && came) {
			forgetRest();
			return;
		}
		/* Not on a batch that only took nodes away: arming on the empty half wrote -834px, and superseding
		 * with the later batch cost 12 findings. docs/anchoring-log.md, "The later batch must not win". */
		if (trustEngine) {
			if (took && !gave && grew <= 0 && floorShrink <= 1) why('emptying');
			else lateDrift(settled, grew, floorShrink);
		}
		else scheduleAnchor(ref);
	});
	for (const host of hosts) {
		_mo.observe(host, { childList: true, subtree: true });
		watch(host);
	}
	_moFlag = new MutationObserver(() => run());
	_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });

	/* A tab switch, a closing fold and a `depends()` row mutate no node, and `min-height` beats the
	 * `height: 0` they collapse with: 1299px of blank left standing (docs/anchoring-log.md, "A tab
	 * switch moves no node"). A same-value write is not a change, though: the fitters re-apply classes
	 * every pass, and an unguarded `class` watch froze the tab, 391 callbacks in 432ms
	 * (docs/anchoring-log.md, "A same-value class write is a feedback loop"). */
	_moTabs = new MutationObserver((records) =>
		records.some((r) => r.oldValue !== r.target.getAttribute(r.attributeName)
			&& (r.attributeName !== 'class' || r.target.dataset.field)) && run());
	for (const host of hosts)
		_moTabs.observe(host, { attributes: true, attributeOldValue: true,
			attributeFilter: [ 'data-tab-active', 'hidden', 'aria-expanded', 'class' ], subtree: true });

}

return baseclass.extend({
	add(fit) {
		if (typeof fit !== 'function') return;
		_fitters.push(fit);
		observeContent();
		try { fit(); }
		catch (e) { console.error('fs-fit: a fitter threw on registration', e); }
	},

	scrolling,
	/* unmarked, for tools/scroll-anchor.mjs — see `_lateWhy` */
	lateWhy: () => _lateWhy,
	lateTrail: () => _lateTrail.slice(),
	anchorWhy: () => _anchorWhy,
	anchorTrail: () => _anchorTrail.slice(),
	deferMeasurement,

	/* -> the offset this file last took a reference at, so a probe does not measure the guard instead
	 * of the anchor. No probe marker: packaging strips those, and the sweep reads the installed package. */
	restAt: () => _restAt,

	/* -> whether the engine is still trusted with a refill; unmarked, like `restAt` above */
	engineTrusted: () => _engineTrusted,

	forgetRest,

	armGate,

	schedule,

	frame(fn) {
		let pending = false;
		return () => {
			if (pending) return;
			pending = true;
			requestAnimationFrame(() => { pending = false; fn(); });
		};
	},

	touches(mutations, sel) {
		for (const m of mutations)
			for (const n of m.addedNodes) {
				if (n.nodeType !== 1) continue;
				if (n.matches(sel) || n.querySelector(sel)) return true;
			}
		return false;
	},

	roomFor(el) {
		const p = el && el.parentElement;
		if (!p) return Infinity;
		const cs = getComputedStyle(p);
		return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
	},

	/* The browser's own answer is the whole test — a canvas approximation cost ~1ms per pass and claimed
	 * 144px where the engine's floor is 93 on a 114-row table — and the box's own width is the half
	 * `scrollWidth` cannot see (2px at 1440). */
	overflows(el) {
		const room = this.roomFor(el);
		const grown = el.getBoundingClientRect().width;
		return Math.max(el.scrollWidth, grown) > room + 1;	/* +1: sub-pixel rounding */
	},

	/* Is somebody else already scrolling this? A 598px `div.resizeable` came out as cards on a 1280px
	 * screen, where the page had 1224px of room. The walk stops at the content root. */
	inScroller(el) {
		for (let p = el.parentElement; p && p.id !== 'view' && p.id !== 'modal_overlay'; p = p.parentElement)
			if ((/(auto|scroll)/).test(window.getComputedStyle(p).overflowX)) return true;
		return false;
	}

});