mindweave 3.2.6 → 3.2.7
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 +113 -113
- package/dist/cli/clipboard.js +67 -26
- package/dist/cli/clipboard.js.map +1 -1
- package/dist/cli/components/PromptInput.js +16 -0
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/ToolLine.js +67 -5
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/WorkGroup.js +2 -1
- package/dist/cli/components/WorkGroup.js.map +1 -1
- package/dist/cli/diffRows.js +112 -0
- package/dist/cli/diffRows.js.map +1 -0
- package/dist/cli/expandHits.js +4 -0
- package/dist/cli/expandHits.js.map +1 -1
- package/dist/cli/shellNotes.js +5 -0
- package/dist/cli/shellNotes.js.map +1 -1
- package/dist/cli/theme.js +16 -0
- package/dist/cli/theme.js.map +1 -1
- package/dist/cli/transcript.js +23 -6
- package/dist/cli/transcript.js.map +1 -1
- package/dist/tools/cdp.js +24 -0
- package/dist/tools/cdp.js.map +1 -1
- package/dist/tools/detail.js +114 -0
- package/dist/tools/detail.js.map +1 -1
- package/dist/tools/edit.js +12 -5
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/guard.js +5 -1
- package/dist/tools/guard.js.map +1 -1
- package/dist/tools/killTree.js +30 -8
- package/dist/tools/killTree.js.map +1 -1
- package/dist/tools/lineDiff.js +152 -0
- package/dist/tools/lineDiff.js.map +1 -0
- package/dist/tools/replaceSymbol.js +3 -3
- package/dist/tools/replaceSymbol.js.map +1 -1
- package/dist/tools/uiPage.js +294 -293
- package/dist/tools/uiPage.js.map +1 -1
- package/dist/tools/writeFile.js +20 -13
- package/dist/tools/writeFile.js.map +1 -1
- package/package.json +1 -1
package/dist/tools/uiPage.js
CHANGED
|
@@ -17,318 +17,318 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { writeFile } from "node:fs/promises";
|
|
20
|
-
import { CdpConnection, listTargets, pageTargets } from "./cdp.js";
|
|
20
|
+
import { CdpConnection, listTargets, listTargetsPatiently, pageTargets } from "./cdp.js";
|
|
21
21
|
// ── the script that runs inside the page ────────────────────────────────────
|
|
22
22
|
/**
|
|
23
23
|
* Reads the page: every control a person could use, its name and state, and the text
|
|
24
24
|
* around them. Returns plain data. Kept as one string so it is sent whole each time and
|
|
25
25
|
* never depends on anything the page defined.
|
|
26
26
|
*/
|
|
27
|
-
const READ_SCRIPT = String.raw `(() => {
|
|
28
|
-
const S = window.__mwui || (window.__mwui = { ids: new WeakMap(), els: new Map(), next: 1 });
|
|
29
|
-
const idOf = (el) => { let id = S.ids.get(el); if (!id) { id = S.next++; S.ids.set(el, id); } S.els.set(id, new WeakRef(el)); return id; };
|
|
30
|
-
const txt = (s) => (s || "").replace(/\s+/g, " ").trim();
|
|
31
|
-
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
32
|
-
const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK"]);
|
|
33
|
-
const ROLE = { button: "button", link: "hyperlink", tab: "tabitem", menuitem: "menuitem", menuitemcheckbox: "menuitem",
|
|
34
|
-
menuitemradio: "menuitem", checkbox: "checkbox", switch: "checkbox", radio: "radiobutton", option: "listitem",
|
|
35
|
-
treeitem: "treeitem", combobox: "combobox", textbox: "edit", searchbox: "edit", slider: "slider", spinbutton: "edit" };
|
|
36
|
-
const styleOf = (el) => { try { return getComputedStyle(el); } catch { return null; } };
|
|
37
|
-
function kindOf(el) {
|
|
38
|
-
const role = (el.getAttribute("role") || "").trim().split(/\s+/)[0];
|
|
39
|
-
if (role && ROLE[role]) return ROLE[role];
|
|
40
|
-
const tag = el.tagName;
|
|
41
|
-
if (tag === "A" && el.hasAttribute("href")) return "hyperlink";
|
|
42
|
-
if (tag === "BUTTON" || tag === "SUMMARY") return "button";
|
|
43
|
-
if (tag === "SELECT") return "combobox";
|
|
44
|
-
if (tag === "TEXTAREA") return "edit";
|
|
45
|
-
if (tag === "INPUT") {
|
|
46
|
-
const t = (el.type || "text").toLowerCase();
|
|
47
|
-
if (t === "hidden") return null;
|
|
48
|
-
if (["button", "submit", "reset", "image", "file", "color"].includes(t)) return "button";
|
|
49
|
-
if (t === "checkbox") return "checkbox";
|
|
50
|
-
if (t === "radio") return "radiobutton";
|
|
51
|
-
if (t === "range") return "slider";
|
|
52
|
-
return "edit";
|
|
53
|
-
}
|
|
54
|
-
if (el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable)) return "edit";
|
|
55
|
-
if (tag === "LABEL") return null; // its input is listed instead
|
|
56
|
-
// Anything the page made clickable itself (a div with a click handler shows a pointer):
|
|
57
|
-
// listed once, at the outermost element with the pointer, not at every child inheriting it.
|
|
58
|
-
const st = styleOf(el);
|
|
59
|
-
if (st && st.cursor === "pointer") {
|
|
60
|
-
const ps = el.parentElement && styleOf(el.parentElement);
|
|
61
|
-
if (!ps || ps.cursor !== "pointer") return "clickable";
|
|
62
|
-
}
|
|
63
|
-
if (el.hasAttribute("onclick")) return "clickable";
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
const shown = (el) => {
|
|
67
|
-
const r = el.getBoundingClientRect();
|
|
68
|
-
if (r.width < 1 || r.height < 1) return null;
|
|
69
|
-
if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
|
|
70
|
-
return r;
|
|
71
|
-
};
|
|
72
|
-
function nameOf(el, kind) {
|
|
73
|
-
const by = el.getAttribute("aria-labelledby");
|
|
74
|
-
if (by) { const t = txt(by.split(/\s+/).map((id) => (document.getElementById(id) || {}).textContent || "").join(" ")); if (t) return t; }
|
|
75
|
-
const al = txt(el.getAttribute("aria-label")); if (al) return al;
|
|
76
|
-
if (el.labels && el.labels.length) { const t = txt([...el.labels].map((l) => l.innerText).join(" ")); if (t) return t; }
|
|
77
|
-
if (el.tagName === "INPUT" && ["button", "submit", "reset"].includes(el.type)) { const v = txt(el.value); if (v) return v; }
|
|
78
|
-
if (el.tagName === "INPUT" && el.type === "image") { const a = txt(el.alt); if (a) return a; }
|
|
79
|
-
if (kind !== "edit" && kind !== "combobox") {
|
|
80
|
-
const inner = txt(el.innerText); if (inner) return inner;
|
|
81
|
-
const img = el.querySelector("img[alt]"); if (img && txt(img.alt)) return txt(img.alt);
|
|
82
|
-
const st = el.querySelector("svg title"); if (st && txt(st.textContent)) return txt(st.textContent);
|
|
83
|
-
}
|
|
84
|
-
const title = txt(el.getAttribute("title")); if (title) return title;
|
|
85
|
-
const ph = txt(el.getAttribute("placeholder") || el.getAttribute("data-placeholder")); if (ph) return ph;
|
|
86
|
-
return "";
|
|
87
|
-
}
|
|
88
|
-
function valueOf(el, kind) {
|
|
89
|
-
if (el.tagName === "INPUT" && el.type === "password") return "";
|
|
90
|
-
if (el.tagName === "SELECT") return txt(el.selectedOptions[0] ? el.selectedOptions[0].text : "");
|
|
91
|
-
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
92
|
-
if (["checkbox", "radio", "button", "submit", "reset", "image", "file", "color"].includes(el.type)) return "";
|
|
93
|
-
return txt(el.value);
|
|
94
|
-
}
|
|
95
|
-
if (kind === "edit" && el.isContentEditable) return txt(el.innerText);
|
|
96
|
-
return "";
|
|
97
|
-
}
|
|
98
|
-
function stateOf(el) {
|
|
99
|
-
const s = [];
|
|
100
|
-
const a = (n) => el.getAttribute(n);
|
|
101
|
-
if (el.type === "checkbox" || el.type === "radio") s.push(el.checked ? "checked" : "unchecked");
|
|
102
|
-
else if (a("aria-checked") === "true") s.push("checked");
|
|
103
|
-
else if (a("aria-checked") === "false") s.push("unchecked");
|
|
104
|
-
if (a("aria-selected") === "true") s.push("selected");
|
|
105
|
-
if (a("aria-pressed") === "true") s.push("pressed");
|
|
106
|
-
if (a("aria-expanded") === "true") s.push("expanded");
|
|
107
|
-
else if (a("aria-expanded") === "false") s.push("collapsed");
|
|
108
|
-
if (a("aria-current") && a("aria-current") !== "false") s.push("current");
|
|
109
|
-
if (el.disabled || a("aria-disabled") === "true") s.push("disabled");
|
|
110
|
-
if (el.tagName === "INPUT" && el.type === "password") s.push("password");
|
|
111
|
-
if (el.required || a("aria-required") === "true") s.push("required");
|
|
112
|
-
if (a("aria-invalid") === "true" || (el.validity && el.willValidate && !el.validity.valid && el.value)) s.push("invalid");
|
|
113
|
-
return s;
|
|
114
|
-
}
|
|
115
|
-
const inView = (r) => r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth;
|
|
116
|
-
|
|
117
|
-
const controls = [];
|
|
118
|
-
const listed = new Set();
|
|
119
|
-
let walked = 0, more = 0;
|
|
120
|
-
const MAX = 400;
|
|
121
|
-
function consider(el) {
|
|
122
|
-
const kind = kindOf(el);
|
|
123
|
-
if (!kind) return;
|
|
124
|
-
let r = shown(el);
|
|
125
|
-
let viaLabel = false;
|
|
126
|
-
// A styled checkbox is often an invisible input behind its visible label.
|
|
127
|
-
if (!r && (el.type === "checkbox" || el.type === "radio") && el.labels && el.labels[0]) { r = shown(el.labels[0]); viaLabel = true; }
|
|
128
|
-
if (!r) return;
|
|
129
|
-
if (controls.length >= MAX) { more++; return; }
|
|
130
|
-
listed.add(el);
|
|
131
|
-
const state = stateOf(el);
|
|
132
|
-
if (!inView(r)) state.push("out of view");
|
|
133
|
-
// Something else is on top of it at its centre: a person could not press it (a dialog's backdrop, an overlay).
|
|
134
|
-
if (!viaLabel && inView(r)) {
|
|
135
|
-
const cx = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
|
|
136
|
-
const root = el.getRootNode();
|
|
137
|
-
const hit = root.elementFromPoint ? root.elementFromPoint(cx, cy) : document.elementFromPoint(cx, cy);
|
|
138
|
-
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) state.push("covered");
|
|
139
|
-
}
|
|
140
|
-
const actions = kind === "edit" || kind === "combobox" && el.tagName === "SELECT" ? ["type"] : ["click"];
|
|
141
|
-
if (kind === "combobox" && el.tagName !== "SELECT") actions.push("type");
|
|
142
|
-
controls.push({ id: String(idOf(el)), window: 0, kind, name: clip(nameOf(el, kind), 80), value: clip(valueOf(el, kind), 80), actions, state });
|
|
143
|
-
}
|
|
144
|
-
function walk(root) {
|
|
145
|
-
const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
|
|
146
|
-
acceptNode: (n) => (SKIP.has(n.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
147
|
-
});
|
|
148
|
-
for (let el = tw.nextNode(); el; el = tw.nextNode()) {
|
|
149
|
-
if (++walked > 12000) return;
|
|
150
|
-
consider(el);
|
|
151
|
-
if (el.shadowRoot) walk(el.shadowRoot);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (document.body) walk(document.body);
|
|
155
|
-
|
|
156
|
-
// The words on the page that are not controls, one line per block, in reading order.
|
|
157
|
-
const texts = [];
|
|
158
|
-
const blockCache = new Map();
|
|
159
|
-
const blockOf = (el) => {
|
|
160
|
-
let e = el;
|
|
161
|
-
while (e && e !== document.body) {
|
|
162
|
-
if (blockCache.has(e)) return blockCache.get(e);
|
|
163
|
-
const st = styleOf(e);
|
|
164
|
-
if (st && st.display !== "inline" && st.display !== "contents") { blockCache.set(e, e); return e; }
|
|
165
|
-
e = e.parentElement;
|
|
166
|
-
}
|
|
167
|
-
return document.body;
|
|
168
|
-
};
|
|
169
|
-
const insideControl = (el) => { for (let e = el, i = 0; e && i < 12; e = e.parentElement, i++) if (listed.has(e)) return true; return false; };
|
|
170
|
-
let cur = null, buf = "", total = 0;
|
|
171
|
-
const flush = () => { const t = txt(buf); if (t && texts.length < 80) { texts.push({ window: 0, text: clip(t, 200) }); total += t.length; } buf = ""; };
|
|
172
|
-
if (document.body) {
|
|
173
|
-
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
174
|
-
for (let n = tw.nextNode(); n && texts.length < 80 && total < 6000; n = tw.nextNode()) {
|
|
175
|
-
const t = n.data;
|
|
176
|
-
if (!t || !t.trim()) continue;
|
|
177
|
-
const p = n.parentElement;
|
|
178
|
-
if (!p || SKIP.has(p.tagName) || insideControl(p) || !shown(p)) continue;
|
|
179
|
-
const b = blockOf(p);
|
|
180
|
-
if (b !== cur) { flush(); cur = b; }
|
|
181
|
-
buf += " " + t;
|
|
182
|
-
}
|
|
183
|
-
flush();
|
|
184
|
-
}
|
|
185
|
-
// What "scrolled" means here: the page itself, or, in an app whose content scrolls
|
|
186
|
-
// inside a panel (a chat, a feed), the biggest such panel.
|
|
187
|
-
let se = document.scrollingElement || document.documentElement;
|
|
188
|
-
if (se.scrollHeight - innerHeight < 2) {
|
|
189
|
-
let best = null, area = 0;
|
|
190
|
-
for (const el of document.querySelectorAll("body *")) {
|
|
191
|
-
if (el.scrollHeight - el.clientHeight < 2) continue;
|
|
192
|
-
const oy = (styleOf(el) || {}).overflowY;
|
|
193
|
-
if (oy !== "auto" && oy !== "scroll") continue;
|
|
194
|
-
const a = el.clientWidth * el.clientHeight;
|
|
195
|
-
if (a > area) { area = a; best = el; }
|
|
196
|
-
}
|
|
197
|
-
if (best) se = best;
|
|
198
|
-
}
|
|
199
|
-
const scrollMax = se === document.scrollingElement || se === document.documentElement ? se.scrollHeight - innerHeight : se.scrollHeight - se.clientHeight;
|
|
200
|
-
return {
|
|
201
|
-
controls, texts, more,
|
|
202
|
-
page: { url: location.href, title: document.title, scrollY: Math.round(se.scrollTop), scrollMax: Math.max(0, Math.round(scrollMax)), width: innerWidth, height: innerHeight },
|
|
203
|
-
};
|
|
27
|
+
const READ_SCRIPT = String.raw `(() => {
|
|
28
|
+
const S = window.__mwui || (window.__mwui = { ids: new WeakMap(), els: new Map(), next: 1 });
|
|
29
|
+
const idOf = (el) => { let id = S.ids.get(el); if (!id) { id = S.next++; S.ids.set(el, id); } S.els.set(id, new WeakRef(el)); return id; };
|
|
30
|
+
const txt = (s) => (s || "").replace(/\s+/g, " ").trim();
|
|
31
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
32
|
+
const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK"]);
|
|
33
|
+
const ROLE = { button: "button", link: "hyperlink", tab: "tabitem", menuitem: "menuitem", menuitemcheckbox: "menuitem",
|
|
34
|
+
menuitemradio: "menuitem", checkbox: "checkbox", switch: "checkbox", radio: "radiobutton", option: "listitem",
|
|
35
|
+
treeitem: "treeitem", combobox: "combobox", textbox: "edit", searchbox: "edit", slider: "slider", spinbutton: "edit" };
|
|
36
|
+
const styleOf = (el) => { try { return getComputedStyle(el); } catch { return null; } };
|
|
37
|
+
function kindOf(el) {
|
|
38
|
+
const role = (el.getAttribute("role") || "").trim().split(/\s+/)[0];
|
|
39
|
+
if (role && ROLE[role]) return ROLE[role];
|
|
40
|
+
const tag = el.tagName;
|
|
41
|
+
if (tag === "A" && el.hasAttribute("href")) return "hyperlink";
|
|
42
|
+
if (tag === "BUTTON" || tag === "SUMMARY") return "button";
|
|
43
|
+
if (tag === "SELECT") return "combobox";
|
|
44
|
+
if (tag === "TEXTAREA") return "edit";
|
|
45
|
+
if (tag === "INPUT") {
|
|
46
|
+
const t = (el.type || "text").toLowerCase();
|
|
47
|
+
if (t === "hidden") return null;
|
|
48
|
+
if (["button", "submit", "reset", "image", "file", "color"].includes(t)) return "button";
|
|
49
|
+
if (t === "checkbox") return "checkbox";
|
|
50
|
+
if (t === "radio") return "radiobutton";
|
|
51
|
+
if (t === "range") return "slider";
|
|
52
|
+
return "edit";
|
|
53
|
+
}
|
|
54
|
+
if (el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable)) return "edit";
|
|
55
|
+
if (tag === "LABEL") return null; // its input is listed instead
|
|
56
|
+
// Anything the page made clickable itself (a div with a click handler shows a pointer):
|
|
57
|
+
// listed once, at the outermost element with the pointer, not at every child inheriting it.
|
|
58
|
+
const st = styleOf(el);
|
|
59
|
+
if (st && st.cursor === "pointer") {
|
|
60
|
+
const ps = el.parentElement && styleOf(el.parentElement);
|
|
61
|
+
if (!ps || ps.cursor !== "pointer") return "clickable";
|
|
62
|
+
}
|
|
63
|
+
if (el.hasAttribute("onclick")) return "clickable";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const shown = (el) => {
|
|
67
|
+
const r = el.getBoundingClientRect();
|
|
68
|
+
if (r.width < 1 || r.height < 1) return null;
|
|
69
|
+
if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
|
|
70
|
+
return r;
|
|
71
|
+
};
|
|
72
|
+
function nameOf(el, kind) {
|
|
73
|
+
const by = el.getAttribute("aria-labelledby");
|
|
74
|
+
if (by) { const t = txt(by.split(/\s+/).map((id) => (document.getElementById(id) || {}).textContent || "").join(" ")); if (t) return t; }
|
|
75
|
+
const al = txt(el.getAttribute("aria-label")); if (al) return al;
|
|
76
|
+
if (el.labels && el.labels.length) { const t = txt([...el.labels].map((l) => l.innerText).join(" ")); if (t) return t; }
|
|
77
|
+
if (el.tagName === "INPUT" && ["button", "submit", "reset"].includes(el.type)) { const v = txt(el.value); if (v) return v; }
|
|
78
|
+
if (el.tagName === "INPUT" && el.type === "image") { const a = txt(el.alt); if (a) return a; }
|
|
79
|
+
if (kind !== "edit" && kind !== "combobox") {
|
|
80
|
+
const inner = txt(el.innerText); if (inner) return inner;
|
|
81
|
+
const img = el.querySelector("img[alt]"); if (img && txt(img.alt)) return txt(img.alt);
|
|
82
|
+
const st = el.querySelector("svg title"); if (st && txt(st.textContent)) return txt(st.textContent);
|
|
83
|
+
}
|
|
84
|
+
const title = txt(el.getAttribute("title")); if (title) return title;
|
|
85
|
+
const ph = txt(el.getAttribute("placeholder") || el.getAttribute("data-placeholder")); if (ph) return ph;
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
function valueOf(el, kind) {
|
|
89
|
+
if (el.tagName === "INPUT" && el.type === "password") return "";
|
|
90
|
+
if (el.tagName === "SELECT") return txt(el.selectedOptions[0] ? el.selectedOptions[0].text : "");
|
|
91
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
92
|
+
if (["checkbox", "radio", "button", "submit", "reset", "image", "file", "color"].includes(el.type)) return "";
|
|
93
|
+
return txt(el.value);
|
|
94
|
+
}
|
|
95
|
+
if (kind === "edit" && el.isContentEditable) return txt(el.innerText);
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
function stateOf(el) {
|
|
99
|
+
const s = [];
|
|
100
|
+
const a = (n) => el.getAttribute(n);
|
|
101
|
+
if (el.type === "checkbox" || el.type === "radio") s.push(el.checked ? "checked" : "unchecked");
|
|
102
|
+
else if (a("aria-checked") === "true") s.push("checked");
|
|
103
|
+
else if (a("aria-checked") === "false") s.push("unchecked");
|
|
104
|
+
if (a("aria-selected") === "true") s.push("selected");
|
|
105
|
+
if (a("aria-pressed") === "true") s.push("pressed");
|
|
106
|
+
if (a("aria-expanded") === "true") s.push("expanded");
|
|
107
|
+
else if (a("aria-expanded") === "false") s.push("collapsed");
|
|
108
|
+
if (a("aria-current") && a("aria-current") !== "false") s.push("current");
|
|
109
|
+
if (el.disabled || a("aria-disabled") === "true") s.push("disabled");
|
|
110
|
+
if (el.tagName === "INPUT" && el.type === "password") s.push("password");
|
|
111
|
+
if (el.required || a("aria-required") === "true") s.push("required");
|
|
112
|
+
if (a("aria-invalid") === "true" || (el.validity && el.willValidate && !el.validity.valid && el.value)) s.push("invalid");
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
const inView = (r) => r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth;
|
|
116
|
+
|
|
117
|
+
const controls = [];
|
|
118
|
+
const listed = new Set();
|
|
119
|
+
let walked = 0, more = 0;
|
|
120
|
+
const MAX = 400;
|
|
121
|
+
function consider(el) {
|
|
122
|
+
const kind = kindOf(el);
|
|
123
|
+
if (!kind) return;
|
|
124
|
+
let r = shown(el);
|
|
125
|
+
let viaLabel = false;
|
|
126
|
+
// A styled checkbox is often an invisible input behind its visible label.
|
|
127
|
+
if (!r && (el.type === "checkbox" || el.type === "radio") && el.labels && el.labels[0]) { r = shown(el.labels[0]); viaLabel = true; }
|
|
128
|
+
if (!r) return;
|
|
129
|
+
if (controls.length >= MAX) { more++; return; }
|
|
130
|
+
listed.add(el);
|
|
131
|
+
const state = stateOf(el);
|
|
132
|
+
if (!inView(r)) state.push("out of view");
|
|
133
|
+
// Something else is on top of it at its centre: a person could not press it (a dialog's backdrop, an overlay).
|
|
134
|
+
if (!viaLabel && inView(r)) {
|
|
135
|
+
const cx = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1);
|
|
136
|
+
const root = el.getRootNode();
|
|
137
|
+
const hit = root.elementFromPoint ? root.elementFromPoint(cx, cy) : document.elementFromPoint(cx, cy);
|
|
138
|
+
if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) state.push("covered");
|
|
139
|
+
}
|
|
140
|
+
const actions = kind === "edit" || kind === "combobox" && el.tagName === "SELECT" ? ["type"] : ["click"];
|
|
141
|
+
if (kind === "combobox" && el.tagName !== "SELECT") actions.push("type");
|
|
142
|
+
controls.push({ id: String(idOf(el)), window: 0, kind, name: clip(nameOf(el, kind), 80), value: clip(valueOf(el, kind), 80), actions, state });
|
|
143
|
+
}
|
|
144
|
+
function walk(root) {
|
|
145
|
+
const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
|
|
146
|
+
acceptNode: (n) => (SKIP.has(n.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
|
|
147
|
+
});
|
|
148
|
+
for (let el = tw.nextNode(); el; el = tw.nextNode()) {
|
|
149
|
+
if (++walked > 12000) return;
|
|
150
|
+
consider(el);
|
|
151
|
+
if (el.shadowRoot) walk(el.shadowRoot);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (document.body) walk(document.body);
|
|
155
|
+
|
|
156
|
+
// The words on the page that are not controls, one line per block, in reading order.
|
|
157
|
+
const texts = [];
|
|
158
|
+
const blockCache = new Map();
|
|
159
|
+
const blockOf = (el) => {
|
|
160
|
+
let e = el;
|
|
161
|
+
while (e && e !== document.body) {
|
|
162
|
+
if (blockCache.has(e)) return blockCache.get(e);
|
|
163
|
+
const st = styleOf(e);
|
|
164
|
+
if (st && st.display !== "inline" && st.display !== "contents") { blockCache.set(e, e); return e; }
|
|
165
|
+
e = e.parentElement;
|
|
166
|
+
}
|
|
167
|
+
return document.body;
|
|
168
|
+
};
|
|
169
|
+
const insideControl = (el) => { for (let e = el, i = 0; e && i < 12; e = e.parentElement, i++) if (listed.has(e)) return true; return false; };
|
|
170
|
+
let cur = null, buf = "", total = 0;
|
|
171
|
+
const flush = () => { const t = txt(buf); if (t && texts.length < 80) { texts.push({ window: 0, text: clip(t, 200) }); total += t.length; } buf = ""; };
|
|
172
|
+
if (document.body) {
|
|
173
|
+
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
174
|
+
for (let n = tw.nextNode(); n && texts.length < 80 && total < 6000; n = tw.nextNode()) {
|
|
175
|
+
const t = n.data;
|
|
176
|
+
if (!t || !t.trim()) continue;
|
|
177
|
+
const p = n.parentElement;
|
|
178
|
+
if (!p || SKIP.has(p.tagName) || insideControl(p) || !shown(p)) continue;
|
|
179
|
+
const b = blockOf(p);
|
|
180
|
+
if (b !== cur) { flush(); cur = b; }
|
|
181
|
+
buf += " " + t;
|
|
182
|
+
}
|
|
183
|
+
flush();
|
|
184
|
+
}
|
|
185
|
+
// What "scrolled" means here: the page itself, or, in an app whose content scrolls
|
|
186
|
+
// inside a panel (a chat, a feed), the biggest such panel.
|
|
187
|
+
let se = document.scrollingElement || document.documentElement;
|
|
188
|
+
if (se.scrollHeight - innerHeight < 2) {
|
|
189
|
+
let best = null, area = 0;
|
|
190
|
+
for (const el of document.querySelectorAll("body *")) {
|
|
191
|
+
if (el.scrollHeight - el.clientHeight < 2) continue;
|
|
192
|
+
const oy = (styleOf(el) || {}).overflowY;
|
|
193
|
+
if (oy !== "auto" && oy !== "scroll") continue;
|
|
194
|
+
const a = el.clientWidth * el.clientHeight;
|
|
195
|
+
if (a > area) { area = a; best = el; }
|
|
196
|
+
}
|
|
197
|
+
if (best) se = best;
|
|
198
|
+
}
|
|
199
|
+
const scrollMax = se === document.scrollingElement || se === document.documentElement ? se.scrollHeight - innerHeight : se.scrollHeight - se.clientHeight;
|
|
200
|
+
return {
|
|
201
|
+
controls, texts, more,
|
|
202
|
+
page: { url: location.href, title: document.title, scrollY: Math.round(se.scrollTop), scrollMax: Math.max(0, Math.round(scrollMax)), width: innerWidth, height: innerHeight },
|
|
203
|
+
};
|
|
204
204
|
})()`;
|
|
205
205
|
/** Find a tagged control again, or report it gone. Shared by every action below. */
|
|
206
|
-
const RESOLVE = String.raw `const S = window.__mwui; const ref = S && S.els.get(ID); const el = ref && ref.deref();
|
|
207
|
-
if (!el || !el.isConnected) return { error: "GONE" };
|
|
208
|
-
const describe = (e) => { if (!e || !e.tagName) return "something else"; let d = "<" + e.tagName.toLowerCase();
|
|
209
|
-
if (e.id) d += ' id="' + e.id + '"'; const c = typeof e.className === "string" ? e.className.trim().split(/\s+/).slice(0, 2).join(" ") : "";
|
|
206
|
+
const RESOLVE = String.raw `const S = window.__mwui; const ref = S && S.els.get(ID); const el = ref && ref.deref();
|
|
207
|
+
if (!el || !el.isConnected) return { error: "GONE" };
|
|
208
|
+
const describe = (e) => { if (!e || !e.tagName) return "something else"; let d = "<" + e.tagName.toLowerCase();
|
|
209
|
+
if (e.id) d += ' id="' + e.id + '"'; const c = typeof e.className === "string" ? e.className.trim().split(/\s+/).slice(0, 2).join(" ") : "";
|
|
210
210
|
if (c) d += ' class="' + c + '"'; d += ">"; const t = (e.innerText || "").replace(/\s+/g, " ").trim().slice(0, 40); return t ? d + ' "' + t + '"' : d; };`;
|
|
211
211
|
/** Where to click: the control's centre, after bringing it into view, if a click there
|
|
212
212
|
* would really reach it. */
|
|
213
|
-
const CLICK_POINT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
214
|
-
if (el.disabled || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled" };
|
|
215
|
-
if (el.tagName === "SELECT") return { error: "it is a dropdown: use type with the option to pick" };
|
|
216
|
-
let t = el;
|
|
217
|
-
const box = (e) => { const r = e.getBoundingClientRect(); return r.width >= 1 && r.height >= 1 ? r : null; };
|
|
218
|
-
if (!box(t) && el.labels && el.labels[0] && box(el.labels[0])) t = el.labels[0];
|
|
219
|
-
t.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
220
|
-
const r = box(t);
|
|
221
|
-
if (!r) return { error: "it has no size on the page" };
|
|
222
|
-
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
223
|
-
const root = t.getRootNode();
|
|
224
|
-
const hit = root.elementFromPoint ? root.elementFromPoint(x, y) : document.elementFromPoint(x, y);
|
|
225
|
-
if (hit && hit !== t && !t.contains(hit) && !(t === el.labels?.[0] && hit === el)) {
|
|
226
|
-
return { error: "it is covered by " + describe(hit) + " at that spot, so a click there would land on that instead" };
|
|
227
|
-
}
|
|
228
|
-
return { x, y };
|
|
213
|
+
const CLICK_POINT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
214
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled" };
|
|
215
|
+
if (el.tagName === "SELECT") return { error: "it is a dropdown: use type with the option to pick" };
|
|
216
|
+
let t = el;
|
|
217
|
+
const box = (e) => { const r = e.getBoundingClientRect(); return r.width >= 1 && r.height >= 1 ? r : null; };
|
|
218
|
+
if (!box(t) && el.labels && el.labels[0] && box(el.labels[0])) t = el.labels[0];
|
|
219
|
+
t.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
220
|
+
const r = box(t);
|
|
221
|
+
if (!r) return { error: "it has no size on the page" };
|
|
222
|
+
const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
223
|
+
const root = t.getRootNode();
|
|
224
|
+
const hit = root.elementFromPoint ? root.elementFromPoint(x, y) : document.elementFromPoint(x, y);
|
|
225
|
+
if (hit && hit !== t && !t.contains(hit) && !(t === el.labels?.[0] && hit === el)) {
|
|
226
|
+
return { error: "it is covered by " + describe(hit) + " at that spot, so a click there would land on that instead" };
|
|
227
|
+
}
|
|
228
|
+
return { x, y };
|
|
229
229
|
})()`;
|
|
230
230
|
/** Focus a text box and select what is in it, so typing replaces it. A dropdown picks
|
|
231
231
|
* the option instead. */
|
|
232
|
-
const TYPE_PREP = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
|
|
233
|
-
if (el.disabled || el.readOnly || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled or read-only" };
|
|
234
|
-
if (el.tagName === "INPUT" && el.type === "password") return { error: "PASSWORD" };
|
|
235
|
-
if (el.tagName === "SELECT") {
|
|
236
|
-
const want = TEXT.trim().toLowerCase();
|
|
237
|
-
const opts = [...el.options];
|
|
238
|
-
const opt = opts.find((o) => o.text.trim().toLowerCase() === want || o.value.toLowerCase() === want)
|
|
239
|
-
|| opts.find((o) => o.text.trim().toLowerCase().includes(want));
|
|
240
|
-
if (!opt) return { error: "it has no option " + JSON.stringify(TEXT) + ". Its options: " + opts.slice(0, 20).map((o) => JSON.stringify(o.text.trim())).join(", ") };
|
|
241
|
-
el.value = opt.value;
|
|
242
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
243
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
244
|
-
return { done: "picked " + JSON.stringify(opt.text.trim()) };
|
|
245
|
-
}
|
|
246
|
-
if (el.type === "checkbox" || el.type === "radio") return { error: "it is a " + el.type + ": use click" };
|
|
247
|
-
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
248
|
-
el.focus();
|
|
249
|
-
if (typeof el.select === "function" && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.select();
|
|
250
|
-
else if (el.isContentEditable) { const range = document.createRange(); range.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(range); }
|
|
251
|
-
if (document.activeElement !== el && !el.contains(document.activeElement)) return { error: "it would not take focus" };
|
|
252
|
-
return { focused: true };
|
|
232
|
+
const TYPE_PREP = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
|
|
233
|
+
if (el.disabled || el.readOnly || el.getAttribute("aria-disabled") === "true") return { error: "it is disabled or read-only" };
|
|
234
|
+
if (el.tagName === "INPUT" && el.type === "password") return { error: "PASSWORD" };
|
|
235
|
+
if (el.tagName === "SELECT") {
|
|
236
|
+
const want = TEXT.trim().toLowerCase();
|
|
237
|
+
const opts = [...el.options];
|
|
238
|
+
const opt = opts.find((o) => o.text.trim().toLowerCase() === want || o.value.toLowerCase() === want)
|
|
239
|
+
|| opts.find((o) => o.text.trim().toLowerCase().includes(want));
|
|
240
|
+
if (!opt) return { error: "it has no option " + JSON.stringify(TEXT) + ". Its options: " + opts.slice(0, 20).map((o) => JSON.stringify(o.text.trim())).join(", ") };
|
|
241
|
+
el.value = opt.value;
|
|
242
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
243
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
244
|
+
return { done: "picked " + JSON.stringify(opt.text.trim()) };
|
|
245
|
+
}
|
|
246
|
+
if (el.type === "checkbox" || el.type === "radio") return { error: "it is a " + el.type + ": use click" };
|
|
247
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
248
|
+
el.focus();
|
|
249
|
+
if (typeof el.select === "function" && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.select();
|
|
250
|
+
else if (el.isContentEditable) { const range = document.createRange(); range.selectNodeContents(el); const s = getSelection(); s.removeAllRanges(); s.addRange(range); }
|
|
251
|
+
if (document.activeElement !== el && !el.contains(document.activeElement)) return { error: "it would not take focus" };
|
|
252
|
+
return { focused: true };
|
|
253
253
|
})()`;
|
|
254
254
|
/** After typing: did the text arrive? Some inputs (date pickers, custom widgets) ignore
|
|
255
255
|
* typed text, and those get the value set directly with the events a person's typing
|
|
256
256
|
* would have fired. */
|
|
257
|
-
const TYPE_CHECK = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
|
|
258
|
-
const now = el.isContentEditable ? el.innerText : el.value;
|
|
259
|
-
if (now === TEXT || !(el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return { value: now };
|
|
260
|
-
const proto = el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
261
|
-
Object.getOwnPropertyDescriptor(proto, "value").set.call(el, TEXT);
|
|
262
|
-
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
263
|
-
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
264
|
-
return { value: el.value, set: true };
|
|
257
|
+
const TYPE_CHECK = (id, text) => String.raw `(() => { const ID = ${id}; const TEXT = ${JSON.stringify(text)}; ${RESOLVE}
|
|
258
|
+
const now = el.isContentEditable ? el.innerText : el.value;
|
|
259
|
+
if (now === TEXT || !(el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return { value: now };
|
|
260
|
+
const proto = el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
261
|
+
Object.getOwnPropertyDescriptor(proto, "value").set.call(el, TEXT);
|
|
262
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
263
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
264
|
+
return { value: el.value, set: true };
|
|
265
265
|
})()`;
|
|
266
266
|
/** The centre of a control (brought into view), or of the viewport when none is named. */
|
|
267
|
-
const POINT = (id) => String.raw `(() => {
|
|
268
|
-
${id === null ? "return { x: innerWidth / 2, y: innerHeight / 2 };" : `const ID = ${id}; ${RESOLVE}
|
|
269
|
-
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
270
|
-
const r = el.getBoundingClientRect();
|
|
271
|
-
if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
|
|
272
|
-
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };`}
|
|
267
|
+
const POINT = (id) => String.raw `(() => {
|
|
268
|
+
${id === null ? "return { x: innerWidth / 2, y: innerHeight / 2 };" : `const ID = ${id}; ${RESOLVE}
|
|
269
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
270
|
+
const r = el.getBoundingClientRect();
|
|
271
|
+
if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
|
|
272
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };`}
|
|
273
273
|
})()`;
|
|
274
274
|
/** A control's box on the page (document coordinates, padded a little so its edge shows),
|
|
275
275
|
* after bringing it into view. */
|
|
276
|
-
const BOX = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
277
|
-
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
278
|
-
const r = el.getBoundingClientRect();
|
|
279
|
-
if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
|
|
280
|
-
const pad = 8, x = Math.max(0, r.left + scrollX - pad), y = Math.max(0, r.top + scrollY - pad);
|
|
281
|
-
return { x, y, w: Math.min(r.width + 2 * pad, 4000), h: Math.min(r.height + 2 * pad, 4000) };
|
|
276
|
+
const BOX = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
277
|
+
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
278
|
+
const r = el.getBoundingClientRect();
|
|
279
|
+
if (r.width < 1 || r.height < 1) return { error: "it has no size on the page" };
|
|
280
|
+
const pad = 8, x = Math.max(0, r.left + scrollX - pad), y = Math.max(0, r.top + scrollY - pad);
|
|
281
|
+
return { x, y, w: Math.min(r.width + 2 * pad, 4000), h: Math.min(r.height + 2 * pad, 4000) };
|
|
282
282
|
})()`;
|
|
283
283
|
/** The facts a person judges a control by, read off the page: size, colours, type, and the
|
|
284
284
|
* things that are usually the bug (too small, cut off, hard to read). */
|
|
285
|
-
const INSPECT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
286
|
-
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
287
|
-
const r = el.getBoundingClientRect(); const st = getComputedStyle(el);
|
|
288
|
-
const rgb = (c) => { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return null; const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; };
|
|
289
|
-
const hex = (c) => { const v = rgb(c); return v ? "#" + [v.r, v.g, v.b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("") + (v.a < 1 ? " @" + Math.round(v.a * 100) + "%" : "") : c; };
|
|
290
|
-
const lum = (v) => { const f = (n) => { n /= 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
|
|
291
|
-
let bg = null;
|
|
292
|
-
for (let e = el; e && !bg; e = e.parentElement) { const v = rgb(getComputedStyle(e).backgroundColor); if (v && v.a > 0.95) bg = v; }
|
|
293
|
-
if (!bg) bg = { r: 255, g: 255, b: 255, a: 1 };
|
|
294
|
-
const fg = rgb(st.color);
|
|
295
|
-
let contrast = null;
|
|
296
|
-
if (fg) { const a = lum(fg), b = lum(bg); contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }
|
|
297
|
-
const lines = [];
|
|
298
|
-
lines.push("box: " + Math.round(r.width) + "x" + Math.round(r.height) + " at " + Math.round(r.left) + "," + Math.round(r.top) + " (viewport " + innerWidth + "x" + innerHeight + ")");
|
|
299
|
-
lines.push("text: " + st.fontSize + " " + st.fontFamily.split(",")[0].replace(/["']/g, "") + " weight " + st.fontWeight + ", colour " + hex(st.color) + " on " + hex("rgb(" + bg.r + "," + bg.g + "," + bg.b + ")") + (contrast ? " (contrast " + contrast.toFixed(1) + ":1)" : ""));
|
|
300
|
-
lines.push("surface: background " + hex(st.backgroundColor) + ", border " + st.borderTopWidth + " " + st.borderTopStyle + " " + hex(st.borderTopColor) + ", radius " + st.borderTopLeftRadius + ", padding " + st.padding + ", margin " + st.margin);
|
|
301
|
-
lines.push("behaviour: display " + st.display + ", opacity " + st.opacity + ", cursor " + st.cursor + (el.disabled ? ", disabled" : "") + (document.activeElement === el ? ", focused" : ""));
|
|
302
|
-
const issues = [];
|
|
303
|
-
const clickable = el.tagName === "BUTTON" || el.tagName === "A" || el.tagName === "INPUT" || el.getAttribute("role") === "button" || st.cursor === "pointer";
|
|
304
|
-
if (clickable && (r.width < 24 || r.height < 24)) issues.push("small press target (" + Math.round(r.width) + "x" + Math.round(r.height) + "; 24x24 is the least that is comfortable)");
|
|
305
|
-
if ((st.overflow !== "visible" || st.textOverflow === "ellipsis") && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1)) issues.push("its content is cut off (" + el.scrollWidth + "x" + el.scrollHeight + " of content in " + el.clientWidth + "x" + el.clientHeight + ")");
|
|
306
|
-
if (contrast && contrast < 4.5 && (el.innerText || "").trim() && parseFloat(st.fontSize) < 24) issues.push("low contrast for text this size (" + contrast.toFixed(1) + ":1; 4.5:1 is the usual bar)");
|
|
307
|
-
if (r.right < 0 || r.bottom < 0 || r.left > innerWidth || r.top > innerHeight) issues.push("it is outside the visible area");
|
|
308
|
-
if (r.right > innerWidth + 1) issues.push("it runs past the right edge of the viewport by " + Math.round(r.right - innerWidth) + "px");
|
|
309
|
-
if (st.visibility === "hidden" || Number(st.opacity) < 0.05) issues.push("it is invisible (visibility/opacity)");
|
|
310
|
-
lines.push(issues.length ? "worth a look: " + issues.join("; ") : "nothing obviously wrong with it");
|
|
311
|
-
return { lines };
|
|
285
|
+
const INSPECT = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE}
|
|
286
|
+
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
287
|
+
const r = el.getBoundingClientRect(); const st = getComputedStyle(el);
|
|
288
|
+
const rgb = (c) => { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return null; const p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; };
|
|
289
|
+
const hex = (c) => { const v = rgb(c); return v ? "#" + [v.r, v.g, v.b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("") + (v.a < 1 ? " @" + Math.round(v.a * 100) + "%" : "") : c; };
|
|
290
|
+
const lum = (v) => { const f = (n) => { n /= 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; return 0.2126 * f(v.r) + 0.7152 * f(v.g) + 0.0722 * f(v.b); };
|
|
291
|
+
let bg = null;
|
|
292
|
+
for (let e = el; e && !bg; e = e.parentElement) { const v = rgb(getComputedStyle(e).backgroundColor); if (v && v.a > 0.95) bg = v; }
|
|
293
|
+
if (!bg) bg = { r: 255, g: 255, b: 255, a: 1 };
|
|
294
|
+
const fg = rgb(st.color);
|
|
295
|
+
let contrast = null;
|
|
296
|
+
if (fg) { const a = lum(fg), b = lum(bg); contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }
|
|
297
|
+
const lines = [];
|
|
298
|
+
lines.push("box: " + Math.round(r.width) + "x" + Math.round(r.height) + " at " + Math.round(r.left) + "," + Math.round(r.top) + " (viewport " + innerWidth + "x" + innerHeight + ")");
|
|
299
|
+
lines.push("text: " + st.fontSize + " " + st.fontFamily.split(",")[0].replace(/["']/g, "") + " weight " + st.fontWeight + ", colour " + hex(st.color) + " on " + hex("rgb(" + bg.r + "," + bg.g + "," + bg.b + ")") + (contrast ? " (contrast " + contrast.toFixed(1) + ":1)" : ""));
|
|
300
|
+
lines.push("surface: background " + hex(st.backgroundColor) + ", border " + st.borderTopWidth + " " + st.borderTopStyle + " " + hex(st.borderTopColor) + ", radius " + st.borderTopLeftRadius + ", padding " + st.padding + ", margin " + st.margin);
|
|
301
|
+
lines.push("behaviour: display " + st.display + ", opacity " + st.opacity + ", cursor " + st.cursor + (el.disabled ? ", disabled" : "") + (document.activeElement === el ? ", focused" : ""));
|
|
302
|
+
const issues = [];
|
|
303
|
+
const clickable = el.tagName === "BUTTON" || el.tagName === "A" || el.tagName === "INPUT" || el.getAttribute("role") === "button" || st.cursor === "pointer";
|
|
304
|
+
if (clickable && (r.width < 24 || r.height < 24)) issues.push("small press target (" + Math.round(r.width) + "x" + Math.round(r.height) + "; 24x24 is the least that is comfortable)");
|
|
305
|
+
if ((st.overflow !== "visible" || st.textOverflow === "ellipsis") && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1)) issues.push("its content is cut off (" + el.scrollWidth + "x" + el.scrollHeight + " of content in " + el.clientWidth + "x" + el.clientHeight + ")");
|
|
306
|
+
if (contrast && contrast < 4.5 && (el.innerText || "").trim() && parseFloat(st.fontSize) < 24) issues.push("low contrast for text this size (" + contrast.toFixed(1) + ":1; 4.5:1 is the usual bar)");
|
|
307
|
+
if (r.right < 0 || r.bottom < 0 || r.left > innerWidth || r.top > innerHeight) issues.push("it is outside the visible area");
|
|
308
|
+
if (r.right > innerWidth + 1) issues.push("it runs past the right edge of the viewport by " + Math.round(r.right - innerWidth) + "px");
|
|
309
|
+
if (st.visibility === "hidden" || Number(st.opacity) < 0.05) issues.push("it is invisible (visibility/opacity)");
|
|
310
|
+
lines.push(issues.length ? "worth a look: " + issues.join("; ") : "nothing obviously wrong with it");
|
|
311
|
+
return { lines };
|
|
312
312
|
})()`;
|
|
313
313
|
/** Draw each listed control's number over it, in a layer that takes no clicks, so the picture
|
|
314
314
|
* and the list speak the same numbers. Removed again right after the picture is taken. */
|
|
315
|
-
const ANNOTATE = (pairs) => String.raw `(() => {
|
|
316
|
-
const S = window.__mwui; const old = document.getElementById("__mwui_overlay"); if (old) old.remove();
|
|
317
|
-
const layer = document.createElement("div"); layer.id = "__mwui_overlay";
|
|
318
|
-
layer.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none";
|
|
319
|
-
let drawn = 0;
|
|
320
|
-
for (const p of ${JSON.stringify(pairs)}) {
|
|
321
|
-
const ref = S && S.els.get(Number(p.id)); const el = ref && ref.deref(); if (!el || !el.isConnected) continue;
|
|
322
|
-
const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue;
|
|
323
|
-
const box = document.createElement("div");
|
|
324
|
-
box.style.cssText = "position:absolute;box-sizing:border-box;border:1.5px solid #ff2d95;left:" + (r.left + scrollX) + "px;top:" + (r.top + scrollY) + "px;width:" + r.width + "px;height:" + r.height + "px";
|
|
325
|
-
const tag = document.createElement("div");
|
|
326
|
-
tag.textContent = String(p.n);
|
|
327
|
-
tag.style.cssText = "position:absolute;left:-1.5px;top:-14px;min-width:14px;padding:0 3px;height:14px;line-height:14px;font:700 10px/14px monospace;color:#fff;background:#ff2d95;text-align:center;border-radius:2px";
|
|
328
|
-
box.appendChild(tag); layer.appendChild(box); drawn++;
|
|
329
|
-
}
|
|
330
|
-
document.documentElement.appendChild(layer);
|
|
331
|
-
return drawn;
|
|
315
|
+
const ANNOTATE = (pairs) => String.raw `(() => {
|
|
316
|
+
const S = window.__mwui; const old = document.getElementById("__mwui_overlay"); if (old) old.remove();
|
|
317
|
+
const layer = document.createElement("div"); layer.id = "__mwui_overlay";
|
|
318
|
+
layer.style.cssText = "position:absolute;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none";
|
|
319
|
+
let drawn = 0;
|
|
320
|
+
for (const p of ${JSON.stringify(pairs)}) {
|
|
321
|
+
const ref = S && S.els.get(Number(p.id)); const el = ref && ref.deref(); if (!el || !el.isConnected) continue;
|
|
322
|
+
const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) continue;
|
|
323
|
+
const box = document.createElement("div");
|
|
324
|
+
box.style.cssText = "position:absolute;box-sizing:border-box;border:1.5px solid #ff2d95;left:" + (r.left + scrollX) + "px;top:" + (r.top + scrollY) + "px;width:" + r.width + "px;height:" + r.height + "px";
|
|
325
|
+
const tag = document.createElement("div");
|
|
326
|
+
tag.textContent = String(p.n);
|
|
327
|
+
tag.style.cssText = "position:absolute;left:-1.5px;top:-14px;min-width:14px;padding:0 3px;height:14px;line-height:14px;font:700 10px/14px monospace;color:#fff;background:#ff2d95;text-align:center;border-radius:2px";
|
|
328
|
+
box.appendChild(tag); layer.appendChild(box); drawn++;
|
|
329
|
+
}
|
|
330
|
+
document.documentElement.appendChild(layer);
|
|
331
|
+
return drawn;
|
|
332
332
|
})()`;
|
|
333
333
|
const FOCUS = (id) => String.raw `(() => { const ID = ${id}; ${RESOLVE} el.focus(); return { ok: true }; })()`;
|
|
334
334
|
const NAMED_KEYS = {
|
|
@@ -401,15 +401,15 @@ const QUIET_MS = 150;
|
|
|
401
401
|
* no animation or transition that has an end is still running (a spinner that never ends
|
|
402
402
|
* does not count, or nothing would ever settle). Gives up at `max`, so a page that keeps
|
|
403
403
|
* moving is still looked at. Runs inside the page. */
|
|
404
|
-
const QUIET_SCRIPT = (quiet, max) => String.raw `new Promise((resolve) => {
|
|
405
|
-
const start = Date.now(); let last = start;
|
|
406
|
-
const mo = new MutationObserver(() => { last = Date.now(); });
|
|
407
|
-
mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
408
|
-
const moving = () => { try { return document.getAnimations().some((a) => { if (a.playState !== "running") return false;
|
|
409
|
-
const t = a.effect && a.effect.getComputedTiming && a.effect.getComputedTiming(); return !t || t.endTime !== Infinity; }); } catch { return false; } };
|
|
410
|
-
const tick = () => { const now = Date.now();
|
|
411
|
-
if (now - start >= ${max} || (now - last >= ${quiet} && !moving())) { mo.disconnect(); resolve(true); } else setTimeout(tick, 25); };
|
|
412
|
-
setTimeout(tick, 25);
|
|
404
|
+
const QUIET_SCRIPT = (quiet, max) => String.raw `new Promise((resolve) => {
|
|
405
|
+
const start = Date.now(); let last = start;
|
|
406
|
+
const mo = new MutationObserver(() => { last = Date.now(); });
|
|
407
|
+
mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
408
|
+
const moving = () => { try { return document.getAnimations().some((a) => { if (a.playState !== "running") return false;
|
|
409
|
+
const t = a.effect && a.effect.getComputedTiming && a.effect.getComputedTiming(); return !t || t.endTime !== Infinity; }); } catch { return false; } };
|
|
410
|
+
const tick = () => { const now = Date.now();
|
|
411
|
+
if (now - start >= ${max} || (now - last >= ${quiet} && !moving())) { mo.disconnect(); resolve(true); } else setTimeout(tick, 25); };
|
|
412
|
+
setTimeout(tick, 25);
|
|
413
413
|
})`;
|
|
414
414
|
/** One page being driven: its connection, and what it has reported since the last step. */
|
|
415
415
|
export class PageSession {
|
|
@@ -489,7 +489,8 @@ export class PageSession {
|
|
|
489
489
|
}
|
|
490
490
|
/** Attach to a page behind a local debugging port. `pick` narrows by title or address. */
|
|
491
491
|
static async attach(port, pick, owned) {
|
|
492
|
-
|
|
492
|
+
// Patient: a browser just started may not answer for a moment (see listTargetsPatiently).
|
|
493
|
+
const pages = pageTargets(await listTargetsPatiently(port));
|
|
493
494
|
if (pages.length === 0)
|
|
494
495
|
throw new Error(`port ${port} has no page to drive`);
|
|
495
496
|
const needle = pick?.toLowerCase();
|