@lightworkai.official/debug-capture 0.19.4 → 0.19.6

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,104 @@
1
+ /**
2
+ * WHERE THE BUBBLE MAY STAND (0.19.6) — out of the host page's way, and where its desk says for a page.
3
+ *
4
+ * The owner, 2026-11-02, of the ERP's เอกสาร page: "our chat bubble is over the action button on some page or even i move
5
+ * it out it still over on this menu … but for most of the page it worked fine … any idea to solve this?" — the page's own
6
+ * toolbar (`fixed right-4 z-40`, a column up most of the right edge) sits where the bubble does, and wherever it was let
7
+ * go on the right it landed on it again.
8
+ *
9
+ * THE PAGE'S OWN FLOATING CONTROLS ARE OBSTACLES. What is topmost under the bubble's column and stays put while the
10
+ * page scrolls: inside a small `fixed` box (or a `sticky` one, stuck) — a toolbar, a FAB, a bottom bar, a sidebar —
11
+ * whatever shows, each piece by its own box (the ERP's tray opens as an absolute child above its `fixed` toggle, so
12
+ * the fixed box alone is only the toggle); inside a box as big as the window (an app's fixed frame, a full-screen
13
+ * sheet) or a page that does not scroll, a control that is not in a scroller (a sheet's บันทึก, a footer's links).
14
+ * Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
15
+ * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
+ *
17
+ * THE NEAREST FREE PLACE. Up or down its own side first; the other side when that is nearer than a long climb
18
+ * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. Once it
19
+ * stepped aside on a page it stays there while that place is free (`settleSpot`): a toolbar opening and closing
20
+ * does not send it back and forth across the screen.
21
+ *
22
+ * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
+ * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
24
+ *
25
+ * The arithmetic is pure (bubblePlace.test.ts); `scanColumns` reads the page.
26
+ */
27
+ export interface Rect {
28
+ top: number;
29
+ bottom: number;
30
+ left: number;
31
+ right: number;
32
+ }
33
+ export type Side = "right" | "left";
34
+ /** Clear space kept between the bubble and an obstacle, px. */
35
+ export declare const CLEAR = 8;
36
+ /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
37
+ export declare const SWITCH_COST = 160;
38
+ /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
39
+ export declare const OBSTACLE_MAX_AREA = 0.34;
40
+ /**
41
+ * The bubble's heights (px up from the bottom to its lower edge) an obstacle rules out, as [lo, hi] — for a bubble of
42
+ * `size` in the column `col`: every obstacle across the column, widened by CLEAR. Pure.
43
+ */
44
+ export declare function blockedRanges(obstacles: readonly Rect[], col: {
45
+ left: number;
46
+ right: number;
47
+ }, viewH: number, size: number): [number, number][];
48
+ /** The free height nearest `want` within [min, max], outside every range (open ends) — null when none. Up wins a tie. Pure. */
49
+ export declare function nearestFreeY(want: number, ranges: readonly [number, number][], min: number, max: number): number | null;
50
+ /**
51
+ * Where the bubble stands: `want` on its side when free, else the nearest free height there — or the other side's when
52
+ * that is cheaper (SWITCH_COST plus its own move). Nothing free anywhere: `want`, as it was. Pure.
53
+ */
54
+ export declare function chooseSpot(want: {
55
+ side: Side;
56
+ y: number;
57
+ }, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
58
+ side: Side;
59
+ y: number;
60
+ };
61
+ /**
62
+ * Where it stands now, given where it stood: kept while that place is still free and it was chosen for the same wish
63
+ * (`prev` is null when the wish, the page or the person's own place changed) — else `chooseSpot`. Pure.
64
+ */
65
+ export declare function settleSpot(prev: {
66
+ side: Side;
67
+ y: number;
68
+ } | null, want: {
69
+ side: Side;
70
+ y: number;
71
+ }, free: Record<Side, readonly [number, number][]>, min: number, max: number): {
72
+ side: Side;
73
+ y: number;
74
+ };
75
+ export interface PlaceRule {
76
+ /** "/saraban/edoc-management" — the page and every page under it; `*` one segment, `**` any. */
77
+ path: string;
78
+ show: "hide" | Side;
79
+ /** Up from the bottom, px. */
80
+ y: number;
81
+ }
82
+ /** A rule's path as a test of a page's path: from its start, ending at a segment's end. Pure; null when malformed. */
83
+ export declare function pathTest(pattern: string): RegExp | null;
84
+ /** The rule for this page — the most specific (the longest path) of those that match; null when none. Pure. */
85
+ export declare function placeFor(pathname: string, rules: readonly PlaceRule[] | null | undefined): PlaceRule | null;
86
+ /**
87
+ * The page's pieces the bubble must not cover, for each column — sampled every `step` px down it at three points
88
+ * across, the topmost piece of the page at each (this library's own skipped); one pass, the styles read once:
89
+ * - a host's selector (`extra`): what it names, whole, wherever it is;
90
+ * - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
91
+ * the control it is part of, else itself;
92
+ * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller;
93
+ * - anything else scrolls with the page, and is not.
94
+ */
95
+ export declare function scanColumns(cols: readonly {
96
+ left: number;
97
+ right: number;
98
+ }[], view: {
99
+ w: number;
100
+ h: number;
101
+ }, opts?: {
102
+ step?: number;
103
+ extra?: string | null;
104
+ }): Rect[][];
@@ -1,3 +1,4 @@
1
+ import { type PlaceRule } from "./bubblePlace";
1
2
  /** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
2
3
  export declare function supportFrameUrl(host: string, realmKey: string): string;
3
4
  /** Where the panel's เปิดในระบบแจ้งปัญหา may land: the person's chat or their เรื่อง — anything else, their chat. Pure. */
@@ -65,6 +66,18 @@ export declare function bubbleLabel(s: {
65
66
  shown: boolean;
66
67
  isNew: boolean;
67
68
  } | null;
69
+ export interface DeskLook {
70
+ label?: string;
71
+ color?: string;
72
+ /** A path on the support host; null: the chat glyph on the colour. */
73
+ faceUrl?: string | null;
74
+ /** Step aside from the page's own floating controls (0.19.6) — the desk's switch; on unless false. */
75
+ avoid?: boolean;
76
+ /** A page's own place (0.19.6): hidden there, or started in a corner at a height — the desk's rules. */
77
+ places?: PlaceRule[];
78
+ }
79
+ /** The face's address for a desk's look: its own path on the support host, the shipped one, or none (the glyph). Pure. */
80
+ export declare function lookFaceUrl(host: string, look: DeskLook | null): string | null;
68
81
  /** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
69
82
  export declare function openSupportChat(): void;
70
83
  /** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
@@ -72,4 +85,6 @@ export declare function closeSupportChat(refocus?: boolean): void;
72
85
  /** The bubble, once — hidden until the host's token says somebody is signed in. */
73
86
  export declare function mountSupportChat(): void;
74
87
  /** Take it away — for a host that unmounts, and for the tests. */
88
+ /** Ask again whether there is a bubble, and what waits (0.19.5) — after a sign-in or a sign-out, or `show` changed. */
89
+ export declare function refreshSupportChat(): Promise<void>;
75
90
  export declare function unmountSupportChat(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.19.4",
3
+ "version": "0.19.6",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
package/src/config.ts CHANGED
@@ -69,8 +69,11 @@ export interface DebugCaptureConfig {
69
69
  * { algorithm: 'HS256', expiresIn: '1h' })
70
70
  *
71
71
  * The SECRET NEVER REACHES THE BROWSER. This callback fetches the finished
72
- * token from your own backend; it is called per request, so cache it on your
73
- * side until it is close to expiring.
72
+ * token from your own backend. Mint a FRESH one on every call — never hand
73
+ * back a kept one: besides its own reads, the widget signs the person in with
74
+ * it (the chat panel, the portal, a call), and a sign-in takes a token only
75
+ * while it is two minutes old, and only once. Sharing one request already in
76
+ * flight is fine.
74
77
  *
75
78
  * Returning null means "nobody is signed in" — the panel says so rather than
76
79
  * failing.
@@ -232,6 +235,18 @@ export interface ChatOptions {
232
235
  * of "แชทกับทีมงาน", or `false` for the button alone.
233
236
  */
234
237
  label?: string | false;
238
+ /**
239
+ * Whether the bubble is for whoever is signed in now (0.19.5) — asked before every read; false: no bubble. For a
240
+ * host whose own staff should not chat with its support desk, or not on every page. Ask again after a sign-in with
241
+ * `refreshSupportChat()`.
242
+ */
243
+ show?: () => boolean;
244
+ /**
245
+ * Out of the page's own way (0.19.6): the bubble steps aside from the page's floating controls — a toolbar, a FAB, a
246
+ * bottom bar, a fixed sidebar — to the nearest free place. On unless `false`; a CSS selector names more of your own
247
+ * to keep clear of, whatever their position (`"[data-floating], .my-dock"`). The desk can turn it off too (ปุ่มแชท).
248
+ */
249
+ avoid?: boolean | string;
235
250
  }
236
251
 
237
252
  /** The bubble's place, from `chat`: null when it is off. */
package/src/index.ts CHANGED
@@ -77,7 +77,7 @@ export type { OpenPortalOptions } from "./portal";
77
77
  * แชทกับทีม (0.19): `chat` in the config mounts the bubble on install; a host with its own button opens and closes
78
78
  * the same panel. The panel is the support host's page, framed — as the tickets are, maintained where they live.
79
79
  */
80
- export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText, chatLift, panelHeight } from "./ui/chatBubble";
80
+ export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, refreshSupportChat, supportFrameUrl, badgeText, chatLift, panelHeight } from "./ui/chatBubble";
81
81
  export type { ChatOptions } from "./config";
82
82
 
83
83
  export type * from "./types";
@@ -0,0 +1,258 @@
1
+ /**
2
+ * WHERE THE BUBBLE MAY STAND (0.19.6) — out of the host page's way, and where its desk says for a page.
3
+ *
4
+ * The owner, 2026-11-02, of the ERP's เอกสาร page: "our chat bubble is over the action button on some page or even i move
5
+ * it out it still over on this menu … but for most of the page it worked fine … any idea to solve this?" — the page's own
6
+ * toolbar (`fixed right-4 z-40`, a column up most of the right edge) sits where the bubble does, and wherever it was let
7
+ * go on the right it landed on it again.
8
+ *
9
+ * THE PAGE'S OWN FLOATING CONTROLS ARE OBSTACLES. What is topmost under the bubble's column and stays put while the
10
+ * page scrolls: inside a small `fixed` box (or a `sticky` one, stuck) — a toolbar, a FAB, a bottom bar, a sidebar —
11
+ * whatever shows, each piece by its own box (the ERP's tray opens as an absolute child above its `fixed` toggle, so
12
+ * the fixed box alone is only the toggle); inside a box as big as the window (an app's fixed frame, a full-screen
13
+ * sheet) or a page that does not scroll, a control that is not in a scroller (a sheet's บันทึก, a footer's links).
14
+ * Content that scrolls under is not: a floating button over content is what a floating button is. A host may name
15
+ * more (`chat.avoid: "<selector>"`) or turn it off (`false`).
16
+ *
17
+ * THE NEAREST FREE PLACE. Up or down its own side first; the other side when that is nearer than a long climb
18
+ * (`SWITCH_COST`) — a column the height of the window sends it to the other corner, a FAB lifts it above. Once it
19
+ * stepped aside on a page it stays there while that place is free (`settleSpot`): a toolbar opening and closing
20
+ * does not send it back and forth across the screen.
21
+ *
22
+ * A PAGE'S OWN PLACE. The desk's rules (ปุ่มแชท › ตำแหน่งปุ่ม): pages whose path starts so — hidden there, or
23
+ * started in a corner at a height. The most specific rule wins; a person who moved it keeps their place.
24
+ *
25
+ * The arithmetic is pure (bubblePlace.test.ts); `scanColumns` reads the page.
26
+ */
27
+
28
+ export interface Rect {
29
+ top: number;
30
+ bottom: number;
31
+ left: number;
32
+ right: number;
33
+ }
34
+ export type Side = "right" | "left";
35
+
36
+ /** Clear space kept between the bubble and an obstacle, px. */
37
+ export const CLEAR = 8;
38
+ /** What moving to the other side costs, in px of climbing — a short lift stays on its side, a long one crosses. */
39
+ export const SWITCH_COST = 160;
40
+ /** An obstacle is smaller than this share of the window — a box as big as the page is the page. */
41
+ export const OBSTACLE_MAX_AREA = 0.34;
42
+
43
+ /**
44
+ * The bubble's heights (px up from the bottom to its lower edge) an obstacle rules out, as [lo, hi] — for a bubble of
45
+ * `size` in the column `col`: every obstacle across the column, widened by CLEAR. Pure.
46
+ */
47
+ export function blockedRanges(obstacles: readonly Rect[], col: { left: number; right: number }, viewH: number, size: number): [number, number][] {
48
+ const out: [number, number][] = [];
49
+ for (const o of obstacles) {
50
+ if (o.right <= col.left || o.left >= col.right || o.bottom <= o.top) continue;
51
+ // The bubble spans viewport [viewH - y - size, viewH - y]; it meets [o.top - CLEAR, o.bottom + CLEAR] when
52
+ // viewH - y > o.top - CLEAR and viewH - y - size < o.bottom + CLEAR.
53
+ out.push([viewH - o.bottom - CLEAR - size, viewH - o.top + CLEAR]);
54
+ }
55
+ return out.sort((a, b) => a[0] - b[0]);
56
+ }
57
+
58
+ /** The free height nearest `want` within [min, max], outside every range (open ends) — null when none. Up wins a tie. Pure. */
59
+ export function nearestFreeY(want: number, ranges: readonly [number, number][], min: number, max: number): number | null {
60
+ if (max < min) return null;
61
+ const inside = (y: number) => ranges.some(([lo, hi]) => y > lo && y < hi);
62
+ const clamp = (y: number) => Math.min(Math.max(y, min), max);
63
+ const w = clamp(want);
64
+ if (!inside(w)) return w;
65
+ // The candidates are the ranges' edges (and the bounds) — the nearest free one is among them.
66
+ const edges = [min, max, ...ranges.flatMap(([lo, hi]) => [lo, hi])].map(clamp).filter((y) => !inside(y));
67
+ if (!edges.length) return null;
68
+ return edges.reduce((best, y) => {
69
+ const d = Math.abs(y - w);
70
+ const bd = Math.abs(best - w);
71
+ return d < bd || (d === bd && y > best) ? y : best;
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Where the bubble stands: `want` on its side when free, else the nearest free height there — or the other side's when
77
+ * that is cheaper (SWITCH_COST plus its own move). Nothing free anywhere: `want`, as it was. Pure.
78
+ */
79
+ export function chooseSpot(want: { side: Side; y: number }, free: Record<Side, readonly [number, number][]>, min: number, max: number): { side: Side; y: number } {
80
+ const other: Side = want.side === "right" ? "left" : "right";
81
+ const here = nearestFreeY(want.y, free[want.side], min, max);
82
+ const there = nearestFreeY(want.y, free[other], min, max);
83
+ if (here === null && there === null) return want;
84
+ const costHere = here === null ? Infinity : Math.abs(here - want.y);
85
+ const costThere = there === null ? Infinity : SWITCH_COST + Math.abs(there - want.y);
86
+ return costHere <= costThere ? { side: want.side, y: here! } : { side: other, y: there! };
87
+ }
88
+
89
+ /**
90
+ * Where it stands now, given where it stood: kept while that place is still free and it was chosen for the same wish
91
+ * (`prev` is null when the wish, the page or the person's own place changed) — else `chooseSpot`. Pure.
92
+ */
93
+ export function settleSpot(
94
+ prev: { side: Side; y: number } | null,
95
+ want: { side: Side; y: number },
96
+ free: Record<Side, readonly [number, number][]>,
97
+ min: number,
98
+ max: number,
99
+ ): { side: Side; y: number } {
100
+ if (prev && nearestFreeY(prev.y, free[prev.side], min, max) === prev.y) return prev;
101
+ return chooseSpot(want, free, min, max);
102
+ }
103
+
104
+ /* ── A page's own place: the desk's rules ───────────────────────────────── */
105
+
106
+ export interface PlaceRule {
107
+ /** "/saraban/edoc-management" — the page and every page under it; `*` one segment, `**` any. */
108
+ path: string;
109
+ show: "hide" | Side;
110
+ /** Up from the bottom, px. */
111
+ y: number;
112
+ }
113
+
114
+ /** A rule's path as a test of a page's path: from its start, ending at a segment's end. Pure; null when malformed. */
115
+ export function pathTest(pattern: string): RegExp | null {
116
+ if (typeof pattern !== "string" || !pattern.startsWith("/") || pattern.length > 200) return null;
117
+ const trimmed = pattern.length > 1 ? pattern.replace(/\/+$/, "") : "";
118
+ const body = trimmed
119
+ .split(/(\*\*|\*)/)
120
+ .map((part) => (part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")))
121
+ .join("");
122
+ try {
123
+ return new RegExp(`^${body}(?:/.*)?$`);
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ /** The rule for this page — the most specific (the longest path) of those that match; null when none. Pure. */
130
+ export function placeFor(pathname: string, rules: readonly PlaceRule[] | null | undefined): PlaceRule | null {
131
+ let best: PlaceRule | null = null;
132
+ for (const r of rules ?? []) {
133
+ const test = pathTest(r.path);
134
+ if (!test || !test.test(pathname)) continue;
135
+ if (!best || r.path.replace(/\*/g, "").length > best.path.replace(/\*/g, "").length) best = r;
136
+ }
137
+ return best;
138
+ }
139
+
140
+ /* ── Reading the page ──────────────────────────────────────────────────── */
141
+
142
+ /** This library's own boxes (the bubble, the call's ring and frame, the toasts): never obstacles. */
143
+ const OURS = /^LW-/;
144
+ /** What a hand presses — a piece of a page that must never be covered. */
145
+ const CONTROL =
146
+ 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[role="checkbox"],[role="switch"],[role="radio"],[role="option"],[role="slider"],[role="combobox"],[role="textbox"],[contenteditable=""],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
147
+ const SCROLLS = /(auto|scroll|overlay)/;
148
+
149
+ /**
150
+ * The page's pieces the bubble must not cover, for each column — sampled every `step` px down it at three points
151
+ * across, the topmost piece of the page at each (this library's own skipped); one pass, the styles read once:
152
+ * - a host's selector (`extra`): what it names, whole, wherever it is;
153
+ * - in a small floating box (its nearest `fixed`, or `sticky` stuck at its edge): what shows there, by its own box —
154
+ * the control it is part of, else itself;
155
+ * - in a box as big as the window, or on a page that does not scroll: a control not inside a scroller;
156
+ * - anything else scrolls with the page, and is not.
157
+ */
158
+ export function scanColumns(cols: readonly { left: number; right: number }[], view: { w: number; h: number }, opts: { step?: number; extra?: string | null } = {}): Rect[][] {
159
+ if (typeof document === "undefined" || typeof document.elementsFromPoint !== "function") return cols.map(() => []);
160
+ const step = opts.step ?? 12;
161
+ const html = document.documentElement;
162
+ const body = document.body;
163
+ const styles = new Map<Element, CSSStyleDeclaration>();
164
+ const style = (el: Element) => styles.get(el) ?? (styles.set(el, getComputedStyle(el)), styles.get(el)!);
165
+ const area = view.w * view.h;
166
+ const big = (r: { width: number; height: number }) => r.width * r.height > OBSTACLE_MAX_AREA * area || (r.width >= view.w * 0.9 && r.height >= view.h * 0.9);
167
+ const scrolls = (n: Element) => {
168
+ const s = style(n);
169
+ return (SCROLLS.test(s.overflowY) && n.scrollHeight > n.clientHeight + 1) || (SCROLLS.test(s.overflowX) && n.scrollWidth > n.clientWidth + 1);
170
+ };
171
+ const se = document.scrollingElement ?? html;
172
+ const pageScrolls = se.scrollHeight > se.clientHeight + 1 && style(html).overflowY !== "hidden" && (!body || style(body).overflowY !== "hidden");
173
+ /** A sticky box at the edge it sticks to — of its own scroller, or of the window. */
174
+ const stuck = (n: Element, s: CSSStyleDeclaration) => {
175
+ let port = { top: 0, bottom: view.h };
176
+ for (let p = n.parentElement; p && p !== html && p !== body; p = p.parentElement) {
177
+ const ps = style(p);
178
+ if (ps.overflowY !== "visible" && ps.overflowY !== "clip") {
179
+ const r = p.getBoundingClientRect();
180
+ port = { top: r.top, bottom: r.bottom };
181
+ break;
182
+ }
183
+ }
184
+ const r = n.getBoundingClientRect();
185
+ const top = parseFloat(s.top);
186
+ const bottom = parseFloat(s.bottom);
187
+ return (!Number.isNaN(bottom) && r.bottom >= port.bottom - bottom - 1) || (!Number.isNaN(top) && r.top <= port.top + top + 1);
188
+ };
189
+ /** Its nearest floating box, whether a scroller lies between, and how much of it shows. */
190
+ const pinOf = (el: Element) => {
191
+ let float: Element | null = null;
192
+ let scrolled = false;
193
+ let opacity = 1;
194
+ for (let n: Element | null = el; n && n !== html && n !== body; n = n.parentElement) {
195
+ const s = style(n);
196
+ const o = parseFloat(s.opacity);
197
+ if (!Number.isNaN(o)) opacity *= o;
198
+ if (s.position === "fixed" || (s.position === "sticky" && stuck(n, s))) {
199
+ float = n;
200
+ break;
201
+ }
202
+ if (n !== el && scrolls(n)) scrolled = true;
203
+ }
204
+ if (!float && pageScrolls) scrolled = true;
205
+ return { float, scrolled, opacity };
206
+ };
207
+ const pins = new Map<Element, ReturnType<typeof pinOf>>();
208
+ return cols.map((col) => {
209
+ const seen = new Set<Element>();
210
+ const kept = new Set<Element>();
211
+ const out: Rect[] = [];
212
+ const keep = (el: Element) => {
213
+ if (kept.has(el)) return;
214
+ kept.add(el);
215
+ const r = el.getBoundingClientRect();
216
+ if (r.width > 0 && r.height > 0)
217
+ out.push({
218
+ top: r.top,
219
+ bottom: r.bottom,
220
+ left: r.left,
221
+ right: r.right,
222
+ });
223
+ };
224
+ const xs = [col.left + 6, (col.left + col.right) / 2, col.right - 6];
225
+ for (let y = step / 2; y < view.h; y += step) {
226
+ for (const x of xs) {
227
+ const top = document.elementsFromPoint(x, y).find((e) => !OURS.test(e.tagName));
228
+ if (!top || top === html || top === body || seen.has(top)) continue;
229
+ seen.add(top);
230
+ if (opts.extra) {
231
+ let named: Element | null = null;
232
+ try {
233
+ named = top.closest(opts.extra);
234
+ } catch {
235
+ named = null;
236
+ }
237
+ if (named) {
238
+ keep(named);
239
+ continue;
240
+ }
241
+ }
242
+ const { float, scrolled, opacity } = pins.get(top) ?? (pins.set(top, pinOf(top)), pins.get(top)!);
243
+ if (opacity < 0.05) continue;
244
+ const control = top.closest(CONTROL);
245
+ const own = control && (!float || float.contains(control)) ? control : null;
246
+ if (float && !big(float.getBoundingClientRect())) {
247
+ const piece = own ?? top;
248
+ if (!big(piece.getBoundingClientRect())) keep(piece);
249
+ continue;
250
+ }
251
+ // The page itself — a scrolling page, or what is in a frame as big as the window: only a control that stays put.
252
+ if (scrolled || !own || big(own.getBoundingClientRect())) continue;
253
+ keep(own);
254
+ }
255
+ }
256
+ return out;
257
+ });
258
+ }