@fasl-work/caos-app-shell 0.6.13 → 0.7.1
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 +71 -32
- package/bin/caos-shell-gate.mjs +67 -0
- package/chart.css +19 -0
- package/dist/chart.d.ts +58 -0
- package/dist/chart.js +153 -0
- package/dist/chunk-LOSSTLPP.js +174 -0
- package/dist/chunk-X6Y5CDUD.js +16 -0
- package/dist/index.d.ts +398 -119
- package/dist/index.js +708 -184
- package/dist/keys-CnvqkckF.d.ts +135 -0
- package/dist/keys.d.ts +4 -0
- package/dist/keys.js +1 -0
- package/dist/text-BlY47nIt.d.ts +21 -0
- package/dist/tokens-CSV0MOgR.d.ts +41 -0
- package/gate/inpage.mjs +494 -0
- package/gate/png.mjs +149 -0
- package/gate/run.mjs +595 -0
- package/gate/serve.mjs +97 -0
- package/package.json +40 -7
- package/reserved-classes.json +224 -0
- package/styles.css +225 -9
package/gate/inpage.mjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// The gate's in-page library. `installLib` is serialised by Playwright into an init script, so it must be
|
|
2
|
+
// self-contained: everything it uses is defined inside it. It installs `window.__caosGate` before the product's own
|
|
3
|
+
// scripts run, wraps requestAnimationFrame to count frames during an idle window (G8), and exposes the measurements
|
|
4
|
+
// the runner decides on. Each measurement answers one question about what a reader would see, on the element a
|
|
5
|
+
// reader would see it on (never `#root`, never a host box standing in for a drawing).
|
|
6
|
+
|
|
7
|
+
export function installLib() {
|
|
8
|
+
const G = (window.__caosGate = window.__caosGate || {});
|
|
9
|
+
G.raf = 0;
|
|
10
|
+
G.counting = false;
|
|
11
|
+
const nativeRaf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
|
|
12
|
+
if (nativeRaf) {
|
|
13
|
+
window.requestAnimationFrame = (cb) =>
|
|
14
|
+
nativeRaf((t) => {
|
|
15
|
+
if (G.counting) G.raf += 1;
|
|
16
|
+
cb(t);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const visible = (el) => {
|
|
21
|
+
if (!el || el.nodeType !== 1) return false;
|
|
22
|
+
const r = el.getBoundingClientRect();
|
|
23
|
+
if (r.width <= 0 || r.height <= 0) return false;
|
|
24
|
+
if (el.checkVisibility) return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
|
|
25
|
+
const s = getComputedStyle(el);
|
|
26
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
|
|
27
|
+
};
|
|
28
|
+
const shown = (el) => el.getClientRects().length > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : true);
|
|
29
|
+
const path = (el) => {
|
|
30
|
+
const parts = [];
|
|
31
|
+
let e = el;
|
|
32
|
+
while (e && e.nodeType === 1 && e !== document.body && e !== document.documentElement) {
|
|
33
|
+
const p = e.parentElement;
|
|
34
|
+
if (!p) break;
|
|
35
|
+
parts.unshift(`${e.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(p.children, e) + 1})`);
|
|
36
|
+
e = p;
|
|
37
|
+
}
|
|
38
|
+
return parts.length ? `body > ${parts.join(' > ')}` : 'body';
|
|
39
|
+
};
|
|
40
|
+
const describe = (el) => {
|
|
41
|
+
if (!el || el.nodeType !== 1) return String(el);
|
|
42
|
+
const t = el.tagName.toLowerCase();
|
|
43
|
+
const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` : '';
|
|
44
|
+
const attrs = ['data-control', 'data-tab', 'data-panel', 'data-plot', 'data-stage', 'data-case', 'data-readout', 'aria-label']
|
|
45
|
+
.filter((a) => el.hasAttribute(a))
|
|
46
|
+
.map((a) => `[${a}="${String(el.getAttribute(a)).slice(0, 30)}"]`)
|
|
47
|
+
.join('');
|
|
48
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 32);
|
|
49
|
+
return `${t}${el.id ? `#${el.id}` : ''}${cls}${attrs}${text ? ` "${text}"` : ''}`;
|
|
50
|
+
};
|
|
51
|
+
G.describe = describe;
|
|
52
|
+
|
|
53
|
+
/** '' when the page has settled on its declared state; otherwise why not (G7: never networkidle, never a sleep). */
|
|
54
|
+
G.ready = () => {
|
|
55
|
+
if (!document.querySelector('[data-brand]')) return 'no [data-brand]: the shell has not rendered';
|
|
56
|
+
const loading = [...document.querySelectorAll('[data-state="loading"]')].filter(shown);
|
|
57
|
+
if (loading.length) return `${loading.length} element(s) still declare data-state="loading": ${loading.slice(0, 3).map(describe).join('; ')}`;
|
|
58
|
+
const stale = [...document.querySelectorAll('[data-stale="1"]')].filter(shown);
|
|
59
|
+
if (stale.length) return `${stale.length} view(s) still show an earlier selection (data-stale="1"): ${stale.slice(0, 3).map(describe).join('; ')}`;
|
|
60
|
+
const undrawn = [...document.querySelectorAll('[data-drawn="0"]')].filter(shown);
|
|
61
|
+
if (undrawn.length) return `${undrawn.length} stage(s) never got a size (data-drawn="0"): ${undrawn.slice(0, 3).map(describe).join('; ')}`;
|
|
62
|
+
return '';
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** What the page says it is and which mode it is in (G1, G2). */
|
|
66
|
+
G.identity = () => {
|
|
67
|
+
const b = document.querySelector('[data-brand]');
|
|
68
|
+
const active = document.querySelector('header nav a.active, header nav a[aria-current="page"]');
|
|
69
|
+
return {
|
|
70
|
+
brand: b ? b.getAttribute('data-brand') : null,
|
|
71
|
+
title: document.title,
|
|
72
|
+
theme: document.documentElement.dataset.theme ?? null,
|
|
73
|
+
lang: document.documentElement.lang || null,
|
|
74
|
+
activeHref: active ? active.href : null,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The static layout of the current view (G5, G6 inputs, ADR-0071 and ADR-0017 measures). */
|
|
79
|
+
G.facts = () => {
|
|
80
|
+
const vw = window.innerWidth;
|
|
81
|
+
const vh = window.innerHeight;
|
|
82
|
+
const de = document.documentElement;
|
|
83
|
+
const body = document.body;
|
|
84
|
+
const docH = Math.max(de.scrollHeight, body.scrollHeight);
|
|
85
|
+
const docW = Math.max(de.scrollWidth, body.scrollWidth);
|
|
86
|
+
|
|
87
|
+
// Element boxes beyond the viewport, not clipped by an ancestor (scrollWidth alone misses clipped documents).
|
|
88
|
+
const beyond = [];
|
|
89
|
+
const walk = (el, clipped) => {
|
|
90
|
+
for (const c of el.children) {
|
|
91
|
+
const s = getComputedStyle(c);
|
|
92
|
+
if (s.display === 'none') continue;
|
|
93
|
+
const r = c.getBoundingClientRect();
|
|
94
|
+
if (!clipped && r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && (r.right > vw + 1 || r.left < -1)) {
|
|
95
|
+
beyond.push(`${describe(c)} spans ${Math.round(r.left)}..${Math.round(r.right)} of a ${vw}px viewport`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c.tagName.toLowerCase() === 'svg') continue;
|
|
99
|
+
walk(c, clipped || s.overflowX !== 'visible');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(body, false);
|
|
103
|
+
|
|
104
|
+
// Tab rows: one row each (ADR-0071 rule 4); tops clustered so a 1px active-tab offset is not a second row. A row
|
|
105
|
+
// also shows its tabs whole: a row that shrank under a tall panel cut its tabs' height (known shell defect 16).
|
|
106
|
+
const rows = [...document.querySelectorAll('[role="tablist"]')]
|
|
107
|
+
.filter((tl) => visible(tl) && tl.getAttribute('aria-orientation') !== 'vertical')
|
|
108
|
+
.map((tl) => {
|
|
109
|
+
const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
|
|
110
|
+
const tops = tabs.map((t) => t.getBoundingClientRect().top).sort((a, b) => a - b);
|
|
111
|
+
let n = tops.length ? 1 : 0;
|
|
112
|
+
for (let i = 1; i < tops.length; i += 1) if (tops[i] - tops[i - 1] > 8) n += 1;
|
|
113
|
+
// a shrunk row either lets its tabs overflow it or stretches them shorter than their own content
|
|
114
|
+
const lr = tl.getBoundingClientRect();
|
|
115
|
+
let cut = Math.max(0, tl.scrollHeight - tl.clientHeight);
|
|
116
|
+
for (const t of tabs) {
|
|
117
|
+
const r = t.getBoundingClientRect();
|
|
118
|
+
cut = Math.max(cut, t.scrollHeight - t.clientHeight, Math.round(Math.max(0, lr.top - r.top) + Math.max(0, r.bottom - lr.bottom)));
|
|
119
|
+
}
|
|
120
|
+
return { name: tl.getAttribute('aria-label') || describe(tl), rows: n, cut };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The rail: no scroll (rule 6) and every descendant inside its box (G5).
|
|
124
|
+
const railEl = document.querySelector('[data-rail]');
|
|
125
|
+
let rail = null;
|
|
126
|
+
if (railEl && visible(railEl)) {
|
|
127
|
+
const rr = railEl.getBoundingClientRect();
|
|
128
|
+
const outside = [];
|
|
129
|
+
for (const d of railEl.querySelectorAll('*')) {
|
|
130
|
+
if (d.closest('svg') && d.tagName.toLowerCase() !== 'svg') continue;
|
|
131
|
+
if (!visible(d)) continue;
|
|
132
|
+
const r = d.getBoundingClientRect();
|
|
133
|
+
if (r.width <= 1 || r.height <= 1) continue;
|
|
134
|
+
let p = d.parentElement;
|
|
135
|
+
let scroller = false;
|
|
136
|
+
while (p && p !== railEl) {
|
|
137
|
+
if (getComputedStyle(p).overflowX !== 'visible') {
|
|
138
|
+
scroller = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
p = p.parentElement;
|
|
142
|
+
}
|
|
143
|
+
if (scroller) continue;
|
|
144
|
+
if (r.right > rr.right + 1 || r.left < rr.left - 1) outside.push(`${describe(d)} spans ${Math.round(r.left)}..${Math.round(r.right)}, rail ${Math.round(rr.left)}..${Math.round(rr.right)}`);
|
|
145
|
+
}
|
|
146
|
+
rail = { scroll: railEl.scrollHeight, client: railEl.clientHeight, outside: outside.slice(0, 5), outsideCount: outside.length };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Truncated text must carry its full text (G5).
|
|
150
|
+
const truncated = [];
|
|
151
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
152
|
+
if (el.namespaceURI === 'http://www.w3.org/2000/svg') continue;
|
|
153
|
+
const s = getComputedStyle(el);
|
|
154
|
+
if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
|
|
155
|
+
// The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
|
|
156
|
+
// ancestors that CONTAINS it. Any label nearby is not enough: the rail's own aria-label would excuse everything.
|
|
157
|
+
const full = (el.textContent || '').trim().replace(/s+/g, ' ');
|
|
158
|
+
let named = false;
|
|
159
|
+
for (let i = 0, t = el; i < 3 && t; i += 1, t = t.parentElement) {
|
|
160
|
+
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/s+/g, ' ');
|
|
161
|
+
if (full && label.includes(full)) {
|
|
162
|
+
named = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!named) truncated.push(describe(el));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Containers that cut their content: overflow hidden or clip with more content than box (S1, G5).
|
|
170
|
+
const clipped = [];
|
|
171
|
+
const containers = new Set([de, body, document.getElementById('root'), ...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .caos-cw, [data-plot]')]);
|
|
172
|
+
for (const el of containers) {
|
|
173
|
+
if (!el || !visible(el)) continue;
|
|
174
|
+
const s = getComputedStyle(el);
|
|
175
|
+
if ((s.overflowY === 'hidden' || s.overflowY === 'clip') && el.scrollHeight > el.clientHeight + 4) {
|
|
176
|
+
clipped.push(`${describe(el)} cuts ${el.scrollHeight - el.clientHeight}px of content (overflow-y ${s.overflowY})`);
|
|
177
|
+
}
|
|
178
|
+
if ((s.overflowX === 'hidden' || s.overflowX === 'clip') && el.scrollWidth > el.clientWidth + 4) {
|
|
179
|
+
clipped.push(`${describe(el)} cuts ${el.scrollWidth - el.clientWidth}px of content horizontally (overflow-x ${s.overflowX})`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Layout containers that scroll sideways: a wide child pushed the page, the instrument or a panel past its width.
|
|
184
|
+
// Rows built to scroll (the nav, tab rows, a plot body holding a wide table) are not layout containers.
|
|
185
|
+
for (const el of new Set([...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .subtabpanel, .caos-cw')])) {
|
|
186
|
+
if (!visible(el)) continue;
|
|
187
|
+
const s = getComputedStyle(el);
|
|
188
|
+
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) {
|
|
189
|
+
clipped.push(`${describe(el)} scrolls sideways by ${el.scrollWidth - el.clientWidth}px (a child is wider than the layout)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The instrument and what is drawn in it (G6): canvases, svg drawings, images, tables; icons excluded.
|
|
194
|
+
const inst = document.querySelector('[data-instrument]');
|
|
195
|
+
let instrument = null;
|
|
196
|
+
const surfaces = [];
|
|
197
|
+
let prose = false;
|
|
198
|
+
if (inst && visible(inst)) {
|
|
199
|
+
const ir = inst.getBoundingClientRect();
|
|
200
|
+
const cx0 = Math.max(0, ir.left);
|
|
201
|
+
const cy0 = Math.max(0, ir.top);
|
|
202
|
+
const cx1 = Math.min(vw, ir.right);
|
|
203
|
+
const cy1 = Math.min(vh, ir.bottom);
|
|
204
|
+
instrument = { x: cx0, y: cy0, w: Math.max(0, cx1 - cx0), h: Math.max(0, cy1 - cy0) };
|
|
205
|
+
const ctx = inst.querySelector('.caos-cw-context');
|
|
206
|
+
prose = Boolean(ctx && visible(ctx));
|
|
207
|
+
for (const el of inst.querySelectorAll('canvas, svg, img, table, video')) {
|
|
208
|
+
const tag = el.tagName.toLowerCase();
|
|
209
|
+
if (tag === 'svg' && el.parentElement && el.parentElement.closest('svg')) continue;
|
|
210
|
+
if (el.parentElement && el.parentElement.closest('table') && tag === 'table') continue;
|
|
211
|
+
if (!visible(el)) continue;
|
|
212
|
+
const r = el.getBoundingClientRect();
|
|
213
|
+
const min = tag === 'canvas' || tag === 'table' ? 16 : 48;
|
|
214
|
+
if (r.width < min || r.height < min) continue;
|
|
215
|
+
const x = Math.max(cx0, r.left);
|
|
216
|
+
const y = Math.max(cy0, r.top);
|
|
217
|
+
const x2 = Math.min(cx1, r.right);
|
|
218
|
+
const y2 = Math.min(cy1, r.bottom);
|
|
219
|
+
if (x2 - x < 2 || y2 - y < 2) continue;
|
|
220
|
+
// The stage a drawing is judged against: the view's body, else the sized stage, else the surface itself; the
|
|
221
|
+
// view is the card that holds it. A drawing that fills a small corner of a large view underfills its stage.
|
|
222
|
+
const stageEl = el.closest('.caos-plot-body') || el.closest('[data-drawn]') || el;
|
|
223
|
+
const viewEl = el.closest('[data-plot]') || stageEl;
|
|
224
|
+
const clipRect = (b) => {
|
|
225
|
+
const sx = Math.max(cx0, b.left);
|
|
226
|
+
const sy = Math.max(cy0, b.top);
|
|
227
|
+
return { x: sx, y: sy, w: Math.max(0, Math.min(cx1, b.right) - sx), h: Math.max(0, Math.min(cy1, b.bottom) - sy) };
|
|
228
|
+
};
|
|
229
|
+
surfaces.push({
|
|
230
|
+
kind: tag,
|
|
231
|
+
label: describe(viewEl),
|
|
232
|
+
x,
|
|
233
|
+
y,
|
|
234
|
+
w: x2 - x,
|
|
235
|
+
h: y2 - y,
|
|
236
|
+
right: r.right,
|
|
237
|
+
stageKey: path(stageEl),
|
|
238
|
+
stage: clipRect(stageEl.getBoundingClientRect()),
|
|
239
|
+
viewKey: path(viewEl),
|
|
240
|
+
view: clipRect(viewEl.getBoundingClientRect()),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const wb = document.querySelector('[data-case-workbench]');
|
|
246
|
+
const workbench = wb
|
|
247
|
+
? { case: wb.getAttribute('data-case'), key: wb.getAttribute('data-state-key'), replayOnly: wb.getAttribute('data-replay-only') === '1' }
|
|
248
|
+
: null;
|
|
249
|
+
|
|
250
|
+
let offCentre = null;
|
|
251
|
+
const proseBody = document.querySelector('.page-body:not(.wide)');
|
|
252
|
+
if (proseBody && visible(proseBody)) {
|
|
253
|
+
const parent = proseBody.parentElement;
|
|
254
|
+
const pb = proseBody.getBoundingClientRect();
|
|
255
|
+
const cb = parent.getBoundingClientRect();
|
|
256
|
+
offCentre = Math.abs(pb.left - cb.left - (parent.clientWidth - pb.width) / 2);
|
|
257
|
+
}
|
|
258
|
+
const pageBody = document.querySelector('.page-body');
|
|
259
|
+
return {
|
|
260
|
+
vw,
|
|
261
|
+
vh,
|
|
262
|
+
docH,
|
|
263
|
+
docW,
|
|
264
|
+
beyond: beyond.slice(0, 5),
|
|
265
|
+
beyondCount: beyond.length,
|
|
266
|
+
rows,
|
|
267
|
+
rail,
|
|
268
|
+
truncated: truncated.slice(0, 5),
|
|
269
|
+
truncatedCount: truncated.length,
|
|
270
|
+
clipped,
|
|
271
|
+
instrument,
|
|
272
|
+
surfaces,
|
|
273
|
+
prose,
|
|
274
|
+
workbench,
|
|
275
|
+
offCentre,
|
|
276
|
+
pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
|
|
277
|
+
referenceLists: [...document.querySelectorAll('.reference-list')].filter(visible).length,
|
|
278
|
+
uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
|
|
279
|
+
sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
|
|
284
|
+
G.scrolls = () => {
|
|
285
|
+
const out = [];
|
|
286
|
+
const de = document.documentElement;
|
|
287
|
+
const docH = Math.max(de.scrollHeight, document.body.scrollHeight);
|
|
288
|
+
if (docH > window.innerHeight + 1) {
|
|
289
|
+
const y = window.scrollY;
|
|
290
|
+
winScroll(0, 0);
|
|
291
|
+
winScroll(0, docH);
|
|
292
|
+
const moved = window.scrollY > 0;
|
|
293
|
+
winScroll(0, y);
|
|
294
|
+
if (!moved) out.push(`the document is ${docH}px tall in a ${window.innerHeight}px viewport and does not scroll`);
|
|
295
|
+
}
|
|
296
|
+
for (const el of document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel')) {
|
|
297
|
+
if (!visible(el)) continue;
|
|
298
|
+
const s = getComputedStyle(el);
|
|
299
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 4) {
|
|
300
|
+
const t = el.scrollTop;
|
|
301
|
+
setScroll(el, el.scrollLeft, 0);
|
|
302
|
+
setScroll(el, el.scrollLeft, el.scrollHeight);
|
|
303
|
+
const moved = el.scrollTop > 0;
|
|
304
|
+
setScroll(el, el.scrollLeft, t);
|
|
305
|
+
if (!moved) out.push(`${describe(el)} holds ${el.scrollHeight - el.clientHeight}px more content and does not scroll`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const setScroll = (el, left, top) => el.scrollTo({ left, top, behavior: 'instant' });
|
|
312
|
+
const winScroll = (x, y) => window.scrollTo({ left: x, top: y, behavior: 'instant' });
|
|
313
|
+
|
|
314
|
+
/** Brings an element into view the way a reader can: by scrolling the containers a reader can scroll (overflow auto
|
|
315
|
+
* or scroll) and the document, never an overflow-hidden ancestor (scrollIntoView would, and hide a real cut). */
|
|
316
|
+
const bring = (el) => {
|
|
317
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
318
|
+
const s = getComputedStyle(a);
|
|
319
|
+
const ar = a.getBoundingClientRect();
|
|
320
|
+
let r = el.getBoundingClientRect();
|
|
321
|
+
// An element larger than the container is aligned by its start: aligning its end pushed its start out of
|
|
322
|
+
// view, and the probe then landed on whatever covers the container (known shell defect 18).
|
|
323
|
+
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && a.scrollWidth > a.clientWidth) {
|
|
324
|
+
if (r.left < ar.left || r.width > ar.width) setScroll(a, a.scrollLeft - (ar.left - r.left), a.scrollTop);
|
|
325
|
+
else if (r.right > ar.right) setScroll(a, a.scrollLeft + (r.right - ar.right + 8), a.scrollTop);
|
|
326
|
+
}
|
|
327
|
+
r = el.getBoundingClientRect();
|
|
328
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight) {
|
|
329
|
+
if (r.top < ar.top || r.height > ar.height) setScroll(a, a.scrollLeft, a.scrollTop - (ar.top - r.top));
|
|
330
|
+
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + (r.bottom - ar.bottom + 8));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const r = el.getBoundingClientRect();
|
|
334
|
+
if (r.top < 0 || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
|
|
335
|
+
const r2 = el.getBoundingClientRect();
|
|
336
|
+
if (r2.left < 0 || r2.right > window.innerWidth) winScroll(window.scrollX + r2.left + r2.width / 2 - window.innerWidth / 2, window.scrollY);
|
|
337
|
+
};
|
|
338
|
+
G.bringEl = (el) => bring(el);
|
|
339
|
+
G.bring = (selector) => {
|
|
340
|
+
const el = document.querySelector(selector);
|
|
341
|
+
if (el) bring(el);
|
|
342
|
+
return Boolean(el);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** The viewport cut by every ancestor that clips its content (overflow other than visible). */
|
|
346
|
+
const visibleArea = (el) => {
|
|
347
|
+
const area = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
348
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
349
|
+
const s = getComputedStyle(a);
|
|
350
|
+
if (s.overflowX === 'visible' && s.overflowY === 'visible') continue;
|
|
351
|
+
const ar = a.getBoundingClientRect();
|
|
352
|
+
if (s.overflowX !== 'visible') {
|
|
353
|
+
area.left = Math.max(area.left, ar.left);
|
|
354
|
+
area.right = Math.min(area.right, ar.right);
|
|
355
|
+
}
|
|
356
|
+
if (s.overflowY !== 'visible') {
|
|
357
|
+
area.top = Math.max(area.top, ar.top);
|
|
358
|
+
area.bottom = Math.min(area.bottom, ar.bottom);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return area;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** Every visible control can be brought into the viewport and receives the pointer at the centre of its visible
|
|
365
|
+
* part (G5). */
|
|
366
|
+
G.reach = () => {
|
|
367
|
+
const sel = 'a[href], button, select, input:not([type="hidden"]), textarea, [role="tab"], [role="button"], [tabindex]:not([tabindex="-1"])';
|
|
368
|
+
const saved = new Map();
|
|
369
|
+
for (const e of document.querySelectorAll('*')) if (e.scrollLeft || e.scrollTop) saved.set(e, [e.scrollLeft, e.scrollTop]);
|
|
370
|
+
const sx = window.scrollX;
|
|
371
|
+
const sy = window.scrollY;
|
|
372
|
+
const out = [];
|
|
373
|
+
let checked = 0;
|
|
374
|
+
const els = [...document.querySelectorAll(sel)].filter((el) => !el.disabled && !el.closest('[aria-hidden="true"]') && visible(el));
|
|
375
|
+
for (const el of els) {
|
|
376
|
+
bring(el);
|
|
377
|
+
// A wrapped inline element (a citation link over two lines) has its box centre between its line fragments; the
|
|
378
|
+
// pointer lands on a fragment, so the largest one is tested.
|
|
379
|
+
const frags = [...el.getClientRects()].filter((q) => q.width >= 1 && q.height >= 1);
|
|
380
|
+
const r = frags.length ? frags.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
|
|
381
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
382
|
+
checked += 1;
|
|
383
|
+
// The area the element can show in: the viewport cut by every ancestor that clips (known shell defect 18; an
|
|
384
|
+
// element that fits the window but not its scroll container was reported as never brought into view).
|
|
385
|
+
const area = visibleArea(el);
|
|
386
|
+
const fits = r.width <= area.right - area.left + 1 && r.height <= area.bottom - area.top + 1;
|
|
387
|
+
if (fits && (r.left < area.left - 1 || r.right > area.right + 1 || r.top < area.top - 1 || r.bottom > area.bottom + 1)) {
|
|
388
|
+
out.push(`${describe(el)} cannot be brought into the viewport (at ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// the pointer goes to the centre of the part that shows
|
|
392
|
+
const vx0 = Math.max(r.left, area.left);
|
|
393
|
+
const vx1 = Math.min(r.right, area.right);
|
|
394
|
+
const vy0 = Math.max(r.top, area.top);
|
|
395
|
+
const vy1 = Math.min(r.bottom, area.bottom);
|
|
396
|
+
if (vx1 - vx0 < 1 || vy1 - vy0 < 1) {
|
|
397
|
+
out.push(`${describe(el)} cannot be brought into the viewport (at ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const px = Math.min(Math.max((vx0 + vx1) / 2, 0), window.innerWidth - 1);
|
|
401
|
+
const py = Math.min(Math.max((vy0 + vy1) / 2, 0), window.innerHeight - 1);
|
|
402
|
+
const hit = document.elementFromPoint(px, py);
|
|
403
|
+
const ok =
|
|
404
|
+
hit && (hit === el || el.contains(hit) || (el.labels && [...el.labels].some((l) => l.contains(hit))) || (hit.tagName === 'LABEL' && hit.control === el));
|
|
405
|
+
if (!ok) out.push(`${describe(el)} is covered by ${hit ? describe(hit) : 'nothing (outside the page)'}`);
|
|
406
|
+
}
|
|
407
|
+
for (const e of document.querySelectorAll('*')) {
|
|
408
|
+
const s = saved.get(e);
|
|
409
|
+
if (s) setScroll(e, s[0], s[1]);
|
|
410
|
+
else if (e.scrollLeft || e.scrollTop) setScroll(e, 0, 0);
|
|
411
|
+
}
|
|
412
|
+
winScroll(sx, sy);
|
|
413
|
+
return { checked, unreachable: out.slice(0, 8), unreachableCount: out.length };
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** Animation frames and DOM mutations over a window at rest (G8). */
|
|
417
|
+
G.idle = (ms) =>
|
|
418
|
+
new Promise((done) => {
|
|
419
|
+
let mutations = 0;
|
|
420
|
+
const sample = [];
|
|
421
|
+
const mo = new MutationObserver((recs) => {
|
|
422
|
+
mutations += recs.length;
|
|
423
|
+
for (const r of recs) {
|
|
424
|
+
if (sample.length >= 3) break;
|
|
425
|
+
const t = r.target.nodeType === 1 ? r.target : r.target.parentElement;
|
|
426
|
+
sample.push(`${r.type}${r.attributeName ? ` ${r.attributeName}` : ''} on ${describe(t)}`);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
430
|
+
G.raf = 0;
|
|
431
|
+
G.counting = true;
|
|
432
|
+
setTimeout(() => {
|
|
433
|
+
G.counting = false;
|
|
434
|
+
mo.disconnect();
|
|
435
|
+
done({ raf: G.raf, mutations, sample, ms });
|
|
436
|
+
}, ms);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/** The visible tab lists outside the header, footer and dialogs, each with the list whose panel holds it (G7). */
|
|
440
|
+
G.tablists = () =>
|
|
441
|
+
[...document.querySelectorAll('[role="tablist"]')]
|
|
442
|
+
.filter((tl) => visible(tl) && !tl.closest('header, footer, [role="dialog"]'))
|
|
443
|
+
.map((tl) => {
|
|
444
|
+
const panel = tl.parentElement ? tl.parentElement.closest('[role="tabpanel"]') : null;
|
|
445
|
+
let owner = null;
|
|
446
|
+
if (panel) {
|
|
447
|
+
const id = panel.getAttribute('aria-labelledby');
|
|
448
|
+
const tab = id ? document.getElementById(id) : null;
|
|
449
|
+
const otl = tab ? tab.closest('[role="tablist"]') : null;
|
|
450
|
+
owner = otl ? path(otl) : 'unknown';
|
|
451
|
+
}
|
|
452
|
+
const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
|
|
453
|
+
return {
|
|
454
|
+
key: path(tl),
|
|
455
|
+
owner,
|
|
456
|
+
name: tl.getAttribute('aria-label') || '',
|
|
457
|
+
source: Boolean(tl.closest('.cs-source-tabs') || tl.classList.contains('cs-source-tabs')),
|
|
458
|
+
tabs: tabs.map((t) => (t.getAttribute('data-tab') || (t.textContent || '').trim()).slice(0, 40)),
|
|
459
|
+
active: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/** The cases the case control offers right now (G9). */
|
|
464
|
+
G.cases = () => {
|
|
465
|
+
const ctl = [...document.querySelectorAll('[data-control="case"]')].filter(visible);
|
|
466
|
+
const ids = [];
|
|
467
|
+
for (const c of ctl) {
|
|
468
|
+
if (c.tagName === 'SELECT') {
|
|
469
|
+
for (const o of c.options) if (!o.disabled && o.value) ids.push(o.value);
|
|
470
|
+
} else for (const b of c.querySelectorAll('[data-case]')) if (!b.disabled) ids.push(b.getAttribute('data-case'));
|
|
471
|
+
}
|
|
472
|
+
return { kind: ctl.length === 0 ? 'none' : ctl[0].tagName === 'SELECT' ? 'select' : 'chips', ids: [...new Set(ids)] };
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** The registered controls of the instrument, except the case control (G9). */
|
|
476
|
+
G.controls = () =>
|
|
477
|
+
[...document.querySelectorAll('[data-control]')]
|
|
478
|
+
.filter((c) => visible(c) && c.getAttribute('data-control') !== 'case')
|
|
479
|
+
.map((c) => {
|
|
480
|
+
let kind = 'unknown';
|
|
481
|
+
if (c.tagName === 'SELECT' || c.querySelector('select')) kind = 'select';
|
|
482
|
+
else if ((c.tagName === 'INPUT' && c.type === 'range') || c.querySelector('input[type="range"]')) kind = 'range';
|
|
483
|
+
else if (c.querySelector('input[type="checkbox"], input[type="radio"]') || (c.tagName === 'INPUT' && (c.type === 'checkbox' || c.type === 'radio'))) kind = 'check';
|
|
484
|
+
else if (c.querySelector('input[type="number"]') || (c.tagName === 'INPUT' && c.type === 'number')) kind = 'number';
|
|
485
|
+
else if (c.querySelector('button') || c.tagName === 'BUTTON') kind = 'buttons';
|
|
486
|
+
return { id: c.getAttribute('data-control'), path: path(c), kind };
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
/** The visible keyed views: every plot and readout with the selection key it shows (G9). */
|
|
490
|
+
G.views = () =>
|
|
491
|
+
[...document.querySelectorAll('[data-plot], [data-readout]')]
|
|
492
|
+
.filter(visible)
|
|
493
|
+
.map((v) => ({ label: describe(v), key: v.getAttribute('data-state-key') || '', stale: v.getAttribute('data-stale') === '1' }));
|
|
494
|
+
}
|
package/gate/png.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A minimal PNG decoder for the gate's own screenshots (8-bit RGB or RGBA, not interlaced: what Chromium writes).
|
|
2
|
+
// The gate decides on painted pixels (G6), never on the host element's box, so it reads the pixels itself instead of
|
|
3
|
+
// trusting what a renderer says it drew. No dependency: zlib is in Node.
|
|
4
|
+
|
|
5
|
+
import { inflateSync } from 'node:zlib';
|
|
6
|
+
|
|
7
|
+
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
8
|
+
|
|
9
|
+
/** @returns {{ width: number, height: number, data: Buffer }} RGBA, 4 bytes per pixel, row-major. */
|
|
10
|
+
export function decodePng(buf) {
|
|
11
|
+
if (!buf.subarray(0, 8).equals(SIGNATURE)) throw new Error('decodePng: not a PNG');
|
|
12
|
+
let pos = 8;
|
|
13
|
+
let width = 0;
|
|
14
|
+
let height = 0;
|
|
15
|
+
let bitDepth = 0;
|
|
16
|
+
let colorType = 0;
|
|
17
|
+
let interlace = 0;
|
|
18
|
+
const idat = [];
|
|
19
|
+
while (pos < buf.length) {
|
|
20
|
+
const len = buf.readUInt32BE(pos);
|
|
21
|
+
const type = buf.toString('ascii', pos + 4, pos + 8);
|
|
22
|
+
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
23
|
+
pos += 12 + len;
|
|
24
|
+
if (type === 'IHDR') {
|
|
25
|
+
width = data.readUInt32BE(0);
|
|
26
|
+
height = data.readUInt32BE(4);
|
|
27
|
+
bitDepth = data[8];
|
|
28
|
+
colorType = data[9];
|
|
29
|
+
interlace = data[12];
|
|
30
|
+
} else if (type === 'IDAT') idat.push(data);
|
|
31
|
+
else if (type === 'IEND') break;
|
|
32
|
+
}
|
|
33
|
+
if (bitDepth !== 8 || interlace !== 0 || (colorType !== 2 && colorType !== 6)) {
|
|
34
|
+
throw new Error(`decodePng: unsupported PNG (bit depth ${bitDepth}, colour type ${colorType}, interlace ${interlace})`);
|
|
35
|
+
}
|
|
36
|
+
const bpp = colorType === 6 ? 4 : 3;
|
|
37
|
+
const stride = width * bpp;
|
|
38
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
39
|
+
const out = Buffer.alloc(width * height * 4);
|
|
40
|
+
let prev = Buffer.alloc(stride);
|
|
41
|
+
for (let y = 0; y < height; y += 1) {
|
|
42
|
+
const filter = raw[y * (stride + 1)];
|
|
43
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
44
|
+
const cur = Buffer.alloc(stride);
|
|
45
|
+
for (let x = 0; x < stride; x += 1) {
|
|
46
|
+
const a = x >= bpp ? cur[x - bpp] : 0;
|
|
47
|
+
const b = prev[x];
|
|
48
|
+
const c = x >= bpp ? prev[x - bpp] : 0;
|
|
49
|
+
let v = line[x];
|
|
50
|
+
if (filter === 1) v += a;
|
|
51
|
+
else if (filter === 2) v += b;
|
|
52
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
53
|
+
else if (filter === 4) {
|
|
54
|
+
const p = a + b - c;
|
|
55
|
+
const pa = Math.abs(p - a);
|
|
56
|
+
const pb = Math.abs(p - b);
|
|
57
|
+
const pc = Math.abs(p - c);
|
|
58
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
59
|
+
}
|
|
60
|
+
cur[x] = v & 255;
|
|
61
|
+
}
|
|
62
|
+
for (let x = 0; x < width; x += 1) {
|
|
63
|
+
const o = (y * width + x) * 4;
|
|
64
|
+
out[o] = cur[x * bpp];
|
|
65
|
+
out[o + 1] = cur[x * bpp + 1];
|
|
66
|
+
out[o + 2] = cur[x * bpp + 2];
|
|
67
|
+
out[o + 3] = bpp === 4 ? cur[x * bpp + 3] : 255;
|
|
68
|
+
}
|
|
69
|
+
prev = cur;
|
|
70
|
+
}
|
|
71
|
+
return { width, height, data: out };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The painted extent of a region of an image: the bounding box of the pixels that differ from the region's
|
|
76
|
+
* background, taken as the most frequent colour along the region's border ring. A uniform region (nothing drawn)
|
|
77
|
+
* returns null.
|
|
78
|
+
* @param {{ width: number, height: number, data: Buffer }} img
|
|
79
|
+
* @param {{ x: number, y: number, w: number, h: number }} r region in image pixels
|
|
80
|
+
*/
|
|
81
|
+
export function paintedBox(img, r, threshold = 24) {
|
|
82
|
+
const x0 = Math.max(0, Math.floor(r.x));
|
|
83
|
+
const y0 = Math.max(0, Math.floor(r.y));
|
|
84
|
+
const x1 = Math.min(img.width, Math.ceil(r.x + r.w));
|
|
85
|
+
const y1 = Math.min(img.height, Math.ceil(r.y + r.h));
|
|
86
|
+
if (x1 - x0 < 2 || y1 - y0 < 2) return null;
|
|
87
|
+
const d = img.data;
|
|
88
|
+
const counts = new Map();
|
|
89
|
+
const vote = (x, y) => {
|
|
90
|
+
const o = (y * img.width + x) * 4;
|
|
91
|
+
const key = ((d[o] >> 3) << 10) | ((d[o + 1] >> 3) << 5) | (d[o + 2] >> 3);
|
|
92
|
+
const e = counts.get(key);
|
|
93
|
+
if (e) {
|
|
94
|
+
e.n += 1;
|
|
95
|
+
e.r += d[o];
|
|
96
|
+
e.g += d[o + 1];
|
|
97
|
+
e.b += d[o + 2];
|
|
98
|
+
} else counts.set(key, { n: 1, r: d[o], g: d[o + 1], b: d[o + 2] });
|
|
99
|
+
};
|
|
100
|
+
for (let x = x0; x < x1; x += 1) {
|
|
101
|
+
vote(x, y0);
|
|
102
|
+
vote(x, y1 - 1);
|
|
103
|
+
}
|
|
104
|
+
for (let y = y0; y < y1; y += 1) {
|
|
105
|
+
vote(x0, y);
|
|
106
|
+
vote(x1 - 1, y);
|
|
107
|
+
}
|
|
108
|
+
let best = null;
|
|
109
|
+
for (const e of counts.values()) if (!best || e.n > best.n) best = e;
|
|
110
|
+
const br = best.r / best.n;
|
|
111
|
+
const bg = best.g / best.n;
|
|
112
|
+
const bb = best.b / best.n;
|
|
113
|
+
let minX = Infinity;
|
|
114
|
+
let minY = Infinity;
|
|
115
|
+
let maxX = -1;
|
|
116
|
+
let maxY = -1;
|
|
117
|
+
let painted = 0;
|
|
118
|
+
for (let y = y0; y < y1; y += 1) {
|
|
119
|
+
let o = (y * img.width + x0) * 4;
|
|
120
|
+
for (let x = x0; x < x1; x += 1, o += 4) {
|
|
121
|
+
if (Math.abs(d[o] - br) > threshold || Math.abs(d[o + 1] - bg) > threshold || Math.abs(d[o + 2] - bb) > threshold) {
|
|
122
|
+
painted += 1;
|
|
123
|
+
if (x < minX) minX = x;
|
|
124
|
+
if (x > maxX) maxX = x;
|
|
125
|
+
if (y < minY) minY = y;
|
|
126
|
+
if (y > maxY) maxY = y;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (maxX < 0) return null;
|
|
131
|
+
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1, painted };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The area of a union of rectangles, rasterised on a grid of `cell` pixels. */
|
|
135
|
+
export function unionArea(rects, width, height, cell = 4) {
|
|
136
|
+
const cols = Math.ceil(width / cell);
|
|
137
|
+
const rows = Math.ceil(height / cell);
|
|
138
|
+
const grid = new Uint8Array(cols * rows);
|
|
139
|
+
for (const r of rects) {
|
|
140
|
+
const c0 = Math.max(0, Math.floor(r.x / cell));
|
|
141
|
+
const c1 = Math.min(cols, Math.ceil((r.x + r.w) / cell));
|
|
142
|
+
const r0 = Math.max(0, Math.floor(r.y / cell));
|
|
143
|
+
const r1 = Math.min(rows, Math.ceil((r.y + r.h) / cell));
|
|
144
|
+
for (let y = r0; y < r1; y += 1) grid.fill(1, y * cols + c0, y * cols + c1);
|
|
145
|
+
}
|
|
146
|
+
let n = 0;
|
|
147
|
+
for (let i = 0; i < grid.length; i += 1) n += grid[i];
|
|
148
|
+
return n * cell * cell;
|
|
149
|
+
}
|