@fasl-work/caos-app-shell 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/bin/caos-shell-gate.mjs +74 -72
- package/dist/chart.js +2 -2
- package/dist/{chunk-3QYQCWSZ.js → chunk-YECZXGVG.js} +1 -1
- package/dist/index.d.ts +184 -7
- package/dist/index.js +535 -116
- package/dist/{keys-CTQZafOf.d.ts → keys-DDQGijwQ.d.ts} +5 -3
- package/dist/keys.d.ts +1 -1
- package/dist/keys.js +1 -1
- package/gate/areas.mjs +698 -631
- package/gate/inpage.mjs +1416 -1270
- package/gate/run.mjs +1036 -780
- package/gate/serve.mjs +101 -101
- package/package.json +1 -1
- package/reserved-classes.json +42 -0
- package/src/content/executive.schema.json +659 -659
- package/styles.css +829 -769
- package/dist/{chunk-YVVDBSAC.js → chunk-33WTUQ7Q.js} +1 -1
package/gate/inpage.mjs
CHANGED
|
@@ -1,1270 +1,1416 @@
|
|
|
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
|
-
// The smallest rendered size of an illustration label (EXEC_LIMITS.minLabelPx of gate/areas.mjs; test/executive.test.ts keeps them equal).
|
|
11
|
-
const EXEC_MIN_LABEL_PX = 9;
|
|
12
|
-
G.counting = false;
|
|
13
|
-
const nativeRaf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
|
|
14
|
-
if (nativeRaf) {
|
|
15
|
-
window.requestAnimationFrame = (cb) =>
|
|
16
|
-
nativeRaf((t) => {
|
|
17
|
-
if (G.counting) G.raf += 1;
|
|
18
|
-
cb(t);
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The ancestors of an element up to (not including) `stop`. */
|
|
23
|
-
function* ancestorsUntil(el, stop) {
|
|
24
|
-
for (let a = el.parentElement; a && a !== stop && a !== document.body; a = a.parentElement) yield a;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const visible = (el) => {
|
|
28
|
-
if (!el || el.nodeType !== 1) return false;
|
|
29
|
-
const r = el.getBoundingClientRect();
|
|
30
|
-
if (r.width <= 0 || r.height <= 0) return false;
|
|
31
|
-
if (el.checkVisibility) return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
|
|
32
|
-
const s = getComputedStyle(el);
|
|
33
|
-
return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
|
|
34
|
-
};
|
|
35
|
-
const shown = (el) => el.getClientRects().length > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : true);
|
|
36
|
-
const FONT_FLOOR = 8; // px: a smaller font is not legible, whatever its line box
|
|
37
|
-
const fontPx = (el) => Number.parseFloat(getComputedStyle(el).fontSize);
|
|
38
|
-
/**
|
|
39
|
-
* The box a `clip-path` leaves of an element's own box `ar`, or null when the gate cannot read the shape: `inset()` is
|
|
40
|
-
* computed, any other shape (circle, polygon, path, url) is taken as cutting the element away.
|
|
41
|
-
*/
|
|
42
|
-
const clipPathBox = (cp, ar) => {
|
|
43
|
-
const m = /^inset\((.*)\)$/.exec(cp.trim());
|
|
44
|
-
if (!m) return null;
|
|
45
|
-
const v = m[1].split(/\bround\b/)[0].trim().split(/\s+/).filter(Boolean);
|
|
46
|
-
if (v.length < 1 || v.length > 4) return null;
|
|
47
|
-
const [top, right, bottom, left] = [v[0], v[1] ?? v[0], v[2] ?? v[0], v[3] ?? v[1] ?? v[0]];
|
|
48
|
-
const px = (x, ref) => (x.endsWith('%') ? (Number.parseFloat(x) / 100) * ref : Number.parseFloat(x));
|
|
49
|
-
const o = { top: px(top, ar.height), right: px(right, ar.width), bottom: px(bottom, ar.height), left: px(left, ar.width) };
|
|
50
|
-
if (Object.values(o).some((n) => Number.isNaN(n))) return null;
|
|
51
|
-
return { left: ar.left + o.left, top: ar.top + o.top, right: ar.right - o.right, bottom: ar.bottom - o.bottom };
|
|
52
|
-
};
|
|
53
|
-
/**
|
|
54
|
-
* The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` up (below `within`) that
|
|
55
|
-
* hides its overflow (`el` itself only when `self`: its overflow cuts its own text, not its own box) and inside every
|
|
56
|
-
* `clip-path` of `el` and of those ancestors. Null when nothing is left.
|
|
57
|
-
*/
|
|
58
|
-
const clipRect = (rect, el, within, self) => {
|
|
59
|
-
const b = within.getBoundingClientRect();
|
|
60
|
-
let left = Math.max(rect.left, b.left);
|
|
61
|
-
let top = Math.max(rect.top, b.top);
|
|
62
|
-
let right = Math.min(rect.right, b.right);
|
|
63
|
-
let bottom = Math.min(rect.bottom, b.bottom);
|
|
64
|
-
for (let a = el; a && a !== within && a !== document.body; a = a.parentElement) {
|
|
65
|
-
const s = getComputedStyle(a);
|
|
66
|
-
const ar = a.getBoundingClientRect();
|
|
67
|
-
if (a !== el || self) {
|
|
68
|
-
if (/hidden|clip/.test(s.overflowX)) {
|
|
69
|
-
left = Math.max(left, ar.left);
|
|
70
|
-
right = Math.min(right, ar.right);
|
|
71
|
-
}
|
|
72
|
-
if (/hidden|clip/.test(s.overflowY)) {
|
|
73
|
-
top = Math.max(top, ar.top);
|
|
74
|
-
bottom = Math.min(bottom, ar.bottom);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (s.clipPath && s.clipPath !== 'none') {
|
|
78
|
-
const c = clipPathBox(s.clipPath, ar);
|
|
79
|
-
if (!c) return null;
|
|
80
|
-
left = Math.max(left, c.left);
|
|
81
|
-
top = Math.max(top, c.top);
|
|
82
|
-
right = Math.min(right, c.right);
|
|
83
|
-
bottom = Math.min(bottom, c.bottom);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
|
87
|
-
};
|
|
88
|
-
const textNodesOf = (el) => {
|
|
89
|
-
const out = [];
|
|
90
|
-
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
91
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
92
|
-
if (!/\S/.test(n.nodeValue || '') || !n.parentElement || n.parentElement.closest('script, style, .katex-mathml')) continue;
|
|
93
|
-
out.push(n);
|
|
94
|
-
}
|
|
95
|
-
return out;
|
|
96
|
-
};
|
|
97
|
-
const probe = document.createRange();
|
|
98
|
-
/** Whether a reader sees the glyphs of a text node: its element is rendered, its font is legible, and a line of it lies inside what is not clipped. */
|
|
99
|
-
const glyphsSeen = (node, within, minW, minH) => {
|
|
100
|
-
const p = node.parentElement;
|
|
101
|
-
if (!p || (p.checkVisibility && !p.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) || fontPx(p) < FONT_FLOOR) return false;
|
|
102
|
-
probe.selectNodeContents(node);
|
|
103
|
-
for (const rc of probe.getClientRects()) {
|
|
104
|
-
if (rc.width <= 0 || rc.height <= 0) continue;
|
|
105
|
-
const c = clipRect(rc, p, within, true);
|
|
106
|
-
if (c && c.right - c.left >= minW && c.bottom - c.top >= minH) return true;
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
109
|
-
};
|
|
110
|
-
/**
|
|
111
|
-
* What a reader can read (ADR-0080 decision 3, ADR-0017 2.1): the element is rendered (a box, opacity above 0, CSS
|
|
112
|
-
* visibility), its font is not zero, the part of its box that lies inside `within` (and inside every ancestor that
|
|
113
|
-
* hides its overflow, and every clip-path) is at least minW by minH px, and the glyphs of its text are painted where
|
|
114
|
-
* that is so: a text node counts when its font is at least FONT_FLOOR px and a line of it is inside what is not clipped
|
|
115
|
-
* (text pushed out of its box by `text-indent`, cut by `clip-path` or set in a 1 px font is not read). An element with
|
|
116
|
-
* no text (a drawing) is judged by its box. `only` judges one text node of the element. A 1 by 1 px screen-reader-only
|
|
117
|
-
* note, a transparent one, one positioned off-screen or one cut off by a clipping box is not readable. `shown` stays
|
|
118
|
-
* for `data-state="loading"`, which must catch every pending element, visible or not.
|
|
119
|
-
*/
|
|
120
|
-
const readable = (el, within, minW = 40, minH = 12, only = null) => {
|
|
121
|
-
if (!visible(el) || !within) return false;
|
|
122
|
-
if (fontPx(el) < 1) return false;
|
|
123
|
-
const box = clipRect(el.getBoundingClientRect(), el, within, false);
|
|
124
|
-
if (!box || box.right - box.left < minW || box.bottom - box.top < minH) return false;
|
|
125
|
-
if (!only && el.matches('svg, canvas, img, video')) return true; // a drawing: the box decides, its labels are not the text of a reader
|
|
126
|
-
const nodes = only ? [only] : textNodesOf(el);
|
|
127
|
-
if (nodes.length === 0) return true;
|
|
128
|
-
return nodes.some((n) => glyphsSeen(n, within, Math.min(minW, 2), Math.min(minH, 4)));
|
|
129
|
-
};
|
|
130
|
-
/** The words and the letters and digits of the text a reader sees in an element (rendered text: hidden descendants are not in it). */
|
|
131
|
-
const renderedText = (el) => {
|
|
132
|
-
const t = el.innerText || '';
|
|
133
|
-
return { chars: t.replace(/[^\p{L}\p{N}]+/gu, '').length, words: t.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length };
|
|
134
|
-
};
|
|
135
|
-
const path = (el) => {
|
|
136
|
-
const parts = [];
|
|
137
|
-
let e = el;
|
|
138
|
-
while (e && e.nodeType === 1 && e !== document.body && e !== document.documentElement) {
|
|
139
|
-
const p = e.parentElement;
|
|
140
|
-
if (!p) break;
|
|
141
|
-
parts.unshift(`${e.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(p.children, e) + 1})`);
|
|
142
|
-
e = p;
|
|
143
|
-
}
|
|
144
|
-
return parts.length ? `body > ${parts.join(' > ')}` : 'body';
|
|
145
|
-
};
|
|
146
|
-
const describe = (el) => {
|
|
147
|
-
if (!el || el.nodeType !== 1) return String(el);
|
|
148
|
-
const t = el.tagName.toLowerCase();
|
|
149
|
-
const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` : '';
|
|
150
|
-
const attrs = ['data-control', 'data-tab', 'data-panel', 'data-plot', 'data-stage', 'data-case', 'data-readout', 'aria-label']
|
|
151
|
-
.filter((a) => el.hasAttribute(a))
|
|
152
|
-
.map((a) => `[${a}="${String(el.getAttribute(a)).slice(0, 30)}"]`)
|
|
153
|
-
.join('');
|
|
154
|
-
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 32);
|
|
155
|
-
return `${t}${el.id ? `#${el.id}` : ''}${cls}${attrs}${text ? ` "${text}"` : ''}`;
|
|
156
|
-
};
|
|
157
|
-
G.describe = describe;
|
|
158
|
-
|
|
159
|
-
/** '' when the page has settled on its declared state; otherwise why not (G7: never networkidle, never a sleep). */
|
|
160
|
-
G.ready = () => {
|
|
161
|
-
if (!document.querySelector('[data-brand]')) return 'no [data-brand]: the shell has not rendered';
|
|
162
|
-
const loading = [...document.querySelectorAll('[data-state="loading"]')].filter(shown);
|
|
163
|
-
if (loading.length) return `${loading.length} element(s) still declare data-state="loading": ${loading.slice(0, 3).map(describe).join('; ')}`;
|
|
164
|
-
const stale = [...document.querySelectorAll('[data-stale="1"]')].filter(shown);
|
|
165
|
-
if (stale.length) return `${stale.length} view(s) still show an earlier selection (data-stale="1"): ${stale.slice(0, 3).map(describe).join('; ')}`;
|
|
166
|
-
const undrawn = [...document.querySelectorAll('[data-drawn="0"]')].filter(shown);
|
|
167
|
-
if (undrawn.length) return `${undrawn.length} stage(s) never got a size (data-drawn="0"): ${undrawn.slice(0, 3).map(describe).join('; ')}`;
|
|
168
|
-
return '';
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/** What the page says it is and which mode it is in (G1, G2). */
|
|
172
|
-
G.identity = () => {
|
|
173
|
-
const b = document.querySelector('[data-brand]');
|
|
174
|
-
const active = document.querySelector('header nav a.active, header nav a[aria-current="page"]');
|
|
175
|
-
return {
|
|
176
|
-
brand: b ? b.getAttribute('data-brand') : null,
|
|
177
|
-
title: document.title,
|
|
178
|
-
theme: document.documentElement.dataset.theme ?? null,
|
|
179
|
-
lang: document.documentElement.lang || null,
|
|
180
|
-
activeHref: active ? active.href : null,
|
|
181
|
-
};
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
/** The static layout of the current view (G5, G6 inputs, ADR-0071 and ADR-0017 measures). */
|
|
185
|
-
G.facts = () => {
|
|
186
|
-
const vw = window.innerWidth;
|
|
187
|
-
const vh = window.innerHeight;
|
|
188
|
-
const de = document.documentElement;
|
|
189
|
-
const body = document.body;
|
|
190
|
-
const docH = Math.max(de.scrollHeight, body.scrollHeight);
|
|
191
|
-
const docW = Math.max(de.scrollWidth, body.scrollWidth);
|
|
192
|
-
|
|
193
|
-
// Element boxes beyond the viewport, not clipped by an ancestor (scrollWidth alone misses clipped documents).
|
|
194
|
-
const beyond = [];
|
|
195
|
-
const walk = (el, clipped) => {
|
|
196
|
-
for (const c of el.children) {
|
|
197
|
-
const s = getComputedStyle(c);
|
|
198
|
-
if (s.display === 'none') continue;
|
|
199
|
-
const r = c.getBoundingClientRect();
|
|
200
|
-
if (!clipped && r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && (r.right > vw + 1 || r.left < -1)) {
|
|
201
|
-
beyond.push(`${describe(c)} spans ${Math.round(r.left)}..${Math.round(r.right)} of a ${vw}px viewport`);
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (c.tagName.toLowerCase() === 'svg') continue;
|
|
205
|
-
walk(c, clipped || s.overflowX !== 'visible');
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
walk(body, false);
|
|
209
|
-
|
|
210
|
-
// Tab rows: one row each (ADR-0071 rule 4); tops clustered so a 1px active-tab offset is not a second row. A row
|
|
211
|
-
// also shows its tabs whole: a row that shrank under a tall panel cut its tabs' height (known shell defect 16).
|
|
212
|
-
const rows = [...document.querySelectorAll('[role="tablist"]')]
|
|
213
|
-
.filter((tl) => visible(tl) && tl.getAttribute('aria-orientation') !== 'vertical')
|
|
214
|
-
.map((tl) => {
|
|
215
|
-
const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
|
|
216
|
-
const tops = tabs.map((t) => t.getBoundingClientRect().top).sort((a, b) => a - b);
|
|
217
|
-
let n = tops.length ? 1 : 0;
|
|
218
|
-
for (let i = 1; i < tops.length; i += 1) if (tops[i] - tops[i - 1] > 8) n += 1;
|
|
219
|
-
// a shrunk row either lets its tabs overflow it or stretches them shorter than their own content
|
|
220
|
-
const lr = tl.getBoundingClientRect();
|
|
221
|
-
let cut = Math.max(0, tl.scrollHeight - tl.clientHeight);
|
|
222
|
-
for (const t of tabs) {
|
|
223
|
-
const r = t.getBoundingClientRect();
|
|
224
|
-
cut = Math.max(cut, t.scrollHeight - t.clientHeight, Math.round(Math.max(0, lr.top - r.top) + Math.max(0, r.bottom - lr.bottom)));
|
|
225
|
-
}
|
|
226
|
-
return { name: tl.getAttribute('aria-label') || describe(tl), rows: n, cut };
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// The rail: no scroll (rule 6) and every descendant inside its box (G5).
|
|
230
|
-
const railEl = document.querySelector('[data-rail]');
|
|
231
|
-
let rail = null;
|
|
232
|
-
if (railEl && visible(railEl)) {
|
|
233
|
-
const rr = railEl.getBoundingClientRect();
|
|
234
|
-
const outside = [];
|
|
235
|
-
for (const d of railEl.querySelectorAll('*')) {
|
|
236
|
-
if (d.closest('svg') && d.tagName.toLowerCase() !== 'svg') continue;
|
|
237
|
-
if (!visible(d)) continue;
|
|
238
|
-
const r = d.getBoundingClientRect();
|
|
239
|
-
if (r.width <= 1 || r.height <= 1) continue;
|
|
240
|
-
let p = d.parentElement;
|
|
241
|
-
let scroller = false;
|
|
242
|
-
while (p && p !== railEl) {
|
|
243
|
-
if (getComputedStyle(p).overflowX !== 'visible') {
|
|
244
|
-
scroller = true;
|
|
245
|
-
break;
|
|
246
|
-
}
|
|
247
|
-
p = p.parentElement;
|
|
248
|
-
}
|
|
249
|
-
if (scroller) continue;
|
|
250
|
-
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)}`);
|
|
251
|
-
}
|
|
252
|
-
rail = { scroll: railEl.scrollHeight, client: railEl.clientHeight, outside: outside.slice(0, 5), outsideCount: outside.length };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Truncated text must carry its full text (G5).
|
|
256
|
-
const truncated = [];
|
|
257
|
-
for (const el of document.querySelectorAll('body *')) {
|
|
258
|
-
if (el.namespaceURI === 'http://www.w3.org/2000/svg') continue;
|
|
259
|
-
const s = getComputedStyle(el);
|
|
260
|
-
if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
|
|
261
|
-
// The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
|
|
262
|
-
// ancestors that CONTAINS it. Any label nearby is not enough: the rail's own aria-label would excuse everything.
|
|
263
|
-
const full = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
264
|
-
let named = false;
|
|
265
|
-
for (let i = 0, t = el; i < 3 && t; i += 1, t = t.parentElement) {
|
|
266
|
-
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/\s+/g, ' ');
|
|
267
|
-
if (full && label.includes(full)) {
|
|
268
|
-
named = true;
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
if (!named) truncated.push(describe(el));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Containers that cut their content: overflow hidden or clip with more content than box (S1, G5).
|
|
276
|
-
const clipped = [];
|
|
277
|
-
const containers = new Set([de, body, document.getElementById('root'), ...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .caos-cw, [data-plot]')]);
|
|
278
|
-
for (const el of containers) {
|
|
279
|
-
if (!el || !visible(el)) continue;
|
|
280
|
-
const s = getComputedStyle(el);
|
|
281
|
-
if ((s.overflowY === 'hidden' || s.overflowY === 'clip') && el.scrollHeight > el.clientHeight + 4) {
|
|
282
|
-
clipped.push(`${describe(el)} cuts ${el.scrollHeight - el.clientHeight}px of content (overflow-y ${s.overflowY})`);
|
|
283
|
-
}
|
|
284
|
-
if ((s.overflowX === 'hidden' || s.overflowX === 'clip') && el.scrollWidth > el.clientWidth + 4) {
|
|
285
|
-
clipped.push(`${describe(el)} cuts ${el.scrollWidth - el.clientWidth}px of content horizontally (overflow-x ${s.overflowX})`);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Layout containers that scroll sideways: a wide child pushed the page, the instrument or a panel past its width.
|
|
290
|
-
// Rows built to scroll (the nav, tab rows, a plot body holding a wide table) are not layout containers.
|
|
291
|
-
for (const el of new Set([...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .subtabpanel, .caos-cw')])) {
|
|
292
|
-
if (!visible(el)) continue;
|
|
293
|
-
const s = getComputedStyle(el);
|
|
294
|
-
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) {
|
|
295
|
-
clipped.push(`${describe(el)} scrolls sideways by ${el.scrollWidth - el.clientWidth}px (a child is wider than the layout)`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// The instrument and what is drawn in it (G6): canvases, svg drawings, images, tables; icons excluded.
|
|
300
|
-
const inst = document.querySelector('[data-instrument]');
|
|
301
|
-
let instrument = null;
|
|
302
|
-
const surfaces = [];
|
|
303
|
-
let prose = false;
|
|
304
|
-
let unavailable = null; // data-state="unavailable" declared in the instrument: 'explained' (a reason a reader can read), 'unexplained' (readable, no reason) or 'unseen' (no reader can see it)
|
|
305
|
-
let drawn = 0; // drawing elements rendered in the instrument, whatever their size or visibility
|
|
306
|
-
if (inst && visible(inst)) {
|
|
307
|
-
const ir = inst.getBoundingClientRect();
|
|
308
|
-
const cx0 = Math.max(0, ir.left);
|
|
309
|
-
const cy0 = Math.max(0, ir.top);
|
|
310
|
-
const cx1 = Math.min(vw, ir.right);
|
|
311
|
-
const cy1 = Math.min(vh, ir.bottom);
|
|
312
|
-
instrument = { x: cx0, y: cy0, w: Math.max(0, cx1 - cx0), h: Math.max(0, cy1 - cy0) };
|
|
313
|
-
const ctx = inst.querySelector('.caos-cw-context');
|
|
314
|
-
prose = Boolean(ctx && visible(ctx));
|
|
315
|
-
// a declaration counts when a reader can see the element (a box of 40 by 12 px at least, inside the instrument,
|
|
316
|
-
// opaque); its reason is the text rendered, 12 letters or digits in two words at least
|
|
317
|
-
const declared = [...inst.querySelectorAll('[data-state="unavailable"]')].filter(shown);
|
|
318
|
-
const seen = declared.filter((el) => readable(el, inst));
|
|
319
|
-
if (seen.length) {
|
|
320
|
-
unavailable = seen.some((el) => {
|
|
321
|
-
const t = renderedText(el);
|
|
322
|
-
return t.chars >= 12 && t.words >= 2;
|
|
323
|
-
})
|
|
324
|
-
? 'explained'
|
|
325
|
-
: 'unexplained';
|
|
326
|
-
} else if (declared.length) unavailable = 'unseen';
|
|
327
|
-
// a drawing element rendered at all (a collapsed or invisible chart too) cancels the exemption; a card that holds the note is not one
|
|
328
|
-
drawn = [...inst.querySelectorAll('canvas, img, video, table, svg, [data-plot], [data-drawn]')].filter((el) => {
|
|
329
|
-
if (el.getClientRects().length === 0) return false;
|
|
330
|
-
const tag = el.tagName.toLowerCase();
|
|
331
|
-
if (tag === 'svg') {
|
|
332
|
-
if (el.parentElement && el.parentElement.closest('svg')) return false;
|
|
333
|
-
if (el.classList.contains('lucide') || el.closest('button, a, summary, [role="tab"], [role="button"]')) return false;
|
|
334
|
-
const r = el.getBoundingClientRect();
|
|
335
|
-
if (el.getAttribute('aria-hidden') === 'true' && r.width > 0 && r.height > 0 && r.width <= 32 && r.height <= 32) return false;
|
|
336
|
-
}
|
|
337
|
-
if (tag === 'table' && el.parentElement && el.parentElement.closest('table')) return false;
|
|
338
|
-
if (el.matches('[data-plot], [data-drawn]') && declared.some((d) => el.contains(d))) return false;
|
|
339
|
-
return true;
|
|
340
|
-
}).length;
|
|
341
|
-
for (const el of inst.querySelectorAll('canvas, svg, img, table, video')) {
|
|
342
|
-
const tag = el.tagName.toLowerCase();
|
|
343
|
-
if (tag === 'svg' && el.parentElement && el.parentElement.closest('svg')) continue;
|
|
344
|
-
if (el.parentElement && el.parentElement.closest('table') && tag === 'table') continue;
|
|
345
|
-
if (!visible(el)) continue;
|
|
346
|
-
const r = el.getBoundingClientRect();
|
|
347
|
-
const min = tag === 'canvas' || tag === 'table' ? 16 : 48;
|
|
348
|
-
if (r.width < min || r.height < min) continue;
|
|
349
|
-
const x = Math.max(cx0, r.left);
|
|
350
|
-
const y = Math.max(cy0, r.top);
|
|
351
|
-
const x2 = Math.min(cx1, r.right);
|
|
352
|
-
const y2 = Math.min(cy1, r.bottom);
|
|
353
|
-
if (x2 - x < 2 || y2 - y < 2) continue;
|
|
354
|
-
// The stage a drawing is judged against: the view's body, else the sized stage, else the surface itself; the
|
|
355
|
-
// view is the card that holds it. A drawing that fills a small corner of a large view underfills its stage.
|
|
356
|
-
const stageEl = el.closest('.caos-plot-body') || el.closest('[data-drawn]') || el;
|
|
357
|
-
const viewEl = el.closest('[data-plot]') || stageEl;
|
|
358
|
-
const clipRect = (b) => {
|
|
359
|
-
const sx = Math.max(cx0, b.left);
|
|
360
|
-
const sy = Math.max(cy0, b.top);
|
|
361
|
-
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) };
|
|
362
|
-
};
|
|
363
|
-
surfaces.push({
|
|
364
|
-
kind: tag,
|
|
365
|
-
label: describe(viewEl),
|
|
366
|
-
x,
|
|
367
|
-
y,
|
|
368
|
-
w: x2 - x,
|
|
369
|
-
h: y2 - y,
|
|
370
|
-
// a table or drawing inside a box that SCROLLS sideways (a wide table on a phone, ADR-0071 rule 3) ends where
|
|
371
|
-
// its box ends: a reader scrolls it; one cut by a box that hides its overflow still extends past the page
|
|
372
|
-
right: Math.min(r.right, ...[...ancestorsUntil(el, inst)].filter((a) => ['auto', 'scroll'].includes(getComputedStyle(a).overflowX)).map((a) => a.getBoundingClientRect().right)),
|
|
373
|
-
stageKey: path(stageEl),
|
|
374
|
-
stage: clipRect(stageEl.getBoundingClientRect()),
|
|
375
|
-
viewKey: path(viewEl),
|
|
376
|
-
view: clipRect(viewEl.getBoundingClientRect()),
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const wb = document.querySelector('[data-case-workbench]');
|
|
382
|
-
const workbench = wb
|
|
383
|
-
? { case: wb.getAttribute('data-case'), key: wb.getAttribute('data-state-key'), replayOnly: wb.getAttribute('data-replay-only') === '1' }
|
|
384
|
-
: null;
|
|
385
|
-
|
|
386
|
-
// The references of a page or open tab (ADR-0017 s4): one block, the last thing in its scope (the document page
|
|
387
|
-
// or the tab panel that holds it).
|
|
388
|
-
const refScope = (el) => el.closest('[role="tabpanel"], [data-doc-page]');
|
|
389
|
-
const refBlocks = [...document.querySelectorAll('[data-doc-references]')].filter(visible);
|
|
390
|
-
const isLastInScope = (block) => {
|
|
391
|
-
const scope = refScope(block);
|
|
392
|
-
for (let el = block; el && el !== scope; el = el.parentElement) {
|
|
393
|
-
for (let n = el.nextElementSibling; n; n = n.nextElementSibling) if (visible(n)) return false;
|
|
394
|
-
}
|
|
395
|
-
return true;
|
|
396
|
-
};
|
|
397
|
-
|
|
398
|
-
let offCentre = null;
|
|
399
|
-
const proseBody = document.querySelector('.page-body:not(.wide)');
|
|
400
|
-
if (proseBody && visible(proseBody)) {
|
|
401
|
-
const parent = proseBody.parentElement;
|
|
402
|
-
const pb = proseBody.getBoundingClientRect();
|
|
403
|
-
const cb = parent.getBoundingClientRect();
|
|
404
|
-
offCentre = Math.abs(pb.left - cb.left - (parent.clientWidth - pb.width) / 2);
|
|
405
|
-
}
|
|
406
|
-
const pageBody = document.querySelector('.page-body');
|
|
407
|
-
return {
|
|
408
|
-
vw,
|
|
409
|
-
vh,
|
|
410
|
-
docH,
|
|
411
|
-
docW,
|
|
412
|
-
beyond: beyond.slice(0, 5),
|
|
413
|
-
beyondCount: beyond.length,
|
|
414
|
-
rows,
|
|
415
|
-
rail,
|
|
416
|
-
truncated: truncated.slice(0, 5),
|
|
417
|
-
truncatedCount: truncated.length,
|
|
418
|
-
clipped,
|
|
419
|
-
instrument,
|
|
420
|
-
surfaces,
|
|
421
|
-
prose,
|
|
422
|
-
unavailable,
|
|
423
|
-
drawn,
|
|
424
|
-
workbench,
|
|
425
|
-
offCentre,
|
|
426
|
-
pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
|
|
427
|
-
referenceLists: [...document.querySelectorAll('.reference-list')].filter(visible).length,
|
|
428
|
-
// shell 0.11.0: the references of a page or open tab are one block at its end (ADR-0017 s4)
|
|
429
|
-
referenceBlocks: refBlocks.length,
|
|
430
|
-
referencesNotLast: refBlocks.filter((b) => !isLastInScope(b)).length,
|
|
431
|
-
refsDeclaredNoBlock: [...document.querySelectorAll('[data-refs]')].filter((sec) => visible(sec) && !refBlocks.some((b) => refScope(b) === refScope(sec))).length,
|
|
432
|
-
uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
|
|
433
|
-
// charts whose axis gives a tick the label of the tick before it (UPlotChart declares data-ticks-repeat)
|
|
434
|
-
repeatedTicks: [...document.querySelectorAll('[data-ticks-repeat]')]
|
|
435
|
-
.filter((el) => visible(el) && Number(el.getAttribute('data-ticks-repeat')) > 0)
|
|
436
|
-
.map((el) => ({ label: describe(el.closest('[data-plot]') || el), n: Number(el.getAttribute('data-ticks-repeat')) })),
|
|
437
|
-
// the drawing a filling card keeps under its note (G6 stage floor): the body of every visible `PlotCard fill`
|
|
438
|
-
fillStages: [...document.querySelectorAll('.caos-plot.fill')]
|
|
439
|
-
.filter(visible)
|
|
440
|
-
.map((card) => ({ label: describe(card), h: (card.querySelector(':scope > .caos-plot-body') || card).getBoundingClientRect().height })),
|
|
441
|
-
sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
|
|
442
|
-
};
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
// ---- 0.11.0: the required areas of the documentation pages (ADR-0017 section 2.1) ------------------------------
|
|
446
|
-
|
|
447
|
-
let docBound = null; // the page being measured: what lies outside it, or is clipped out of it, is not read
|
|
448
|
-
const wordsOf = (el) => {
|
|
449
|
-
// the words a reader reads: visible text outside equations; `linkWords` of them inside links
|
|
450
|
-
let words = 0;
|
|
451
|
-
let linkWords = 0;
|
|
452
|
-
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
453
|
-
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
454
|
-
const parent = n.parentElement;
|
|
455
|
-
if (!parent || parent.closest('.katex, script, style') || !readable(parent, docBound, 4, 4, n)) continue;
|
|
456
|
-
const k = (n.nodeValue || '').split(/\s+/).filter(Boolean).length;
|
|
457
|
-
words += k;
|
|
458
|
-
if (parent.closest('a')) linkWords += k;
|
|
459
|
-
}
|
|
460
|
-
return { words, linkWords };
|
|
461
|
-
};
|
|
462
|
-
const tabLabel = (t) => (t.textContent || '').trim().replace(/\s+/g, ' ');
|
|
463
|
-
/** The tabs from the outermost one to the one that opens `panel`: their labels (for messages) and their ids (for rules). */
|
|
464
|
-
const chainOf = (panel) => {
|
|
465
|
-
const labels = [];
|
|
466
|
-
const ids = [];
|
|
467
|
-
let p = panel;
|
|
468
|
-
while (p) {
|
|
469
|
-
const id = p.getAttribute('aria-labelledby');
|
|
470
|
-
const tab = id ? document.getElementById(id) : null;
|
|
471
|
-
if (!tab) break;
|
|
472
|
-
labels.unshift(tabLabel(tab));
|
|
473
|
-
ids.unshift(tab.getAttribute('data-tab') || tabLabel(tab));
|
|
474
|
-
const tl = tab.closest('[role="tablist"]');
|
|
475
|
-
p = tl && tl.parentElement ? tl.parentElement.closest('[role="tabpanel"]') : null;
|
|
476
|
-
}
|
|
477
|
-
return { label: labels.join(' > '), key: ids.join('/'), head: ids[0] ?? null };
|
|
478
|
-
};
|
|
479
|
-
const textOf = (el) => (el.innerText || '').trim().replace(/\s+/g, ' '); // rendered text: a hidden descendant is not in it
|
|
480
|
-
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
481
|
-
/** A part of a statement a reader sees: the declared text is in the rendered text, in the readable part (`sel`) when the element has one. */
|
|
482
|
-
const statPart = (el, want, sel) => {
|
|
483
|
-
if (!want) return false;
|
|
484
|
-
const kids = [...el.querySelectorAll(sel)];
|
|
485
|
-
return kids.length ? kids.some((k) => readable(k, docBound, 4, 4) && norm(k.innerText).includes(want)) : norm(el.innerText).includes(want);
|
|
486
|
-
};
|
|
487
|
-
/** A quantified statement: visible, a digit in its value, and the unit and the source it declares readable beside it (the `Stat` component). */
|
|
488
|
-
const validStat = (el) => {
|
|
489
|
-
if (!readable(el, docBound, 4, 4)) return false;
|
|
490
|
-
const value = el.querySelector('.caos-stat-value');
|
|
491
|
-
return /\d/.test((value ? value.innerText : el.innerText) || '') && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* What a documentation page shows in this state: its lede, its waivers, the leaf topics open, the views (the outermost
|
|
496
|
-
* tabs), every visible section with what it holds, and the figures and captioned equations of the open view. The rules
|
|
497
|
-
* (gate/areas.mjs) are applied by the runner; this only measures. A hidden element is never counted, a paragraph is
|
|
498
|
-
* counted once per distinct text, and the words are those of the displayed language.
|
|
499
|
-
*/
|
|
500
|
-
G.docPage = () => {
|
|
501
|
-
const page = document.querySelector('[data-doc-page]');
|
|
502
|
-
if (!page || !visible(page)) return null;
|
|
503
|
-
docBound = page;
|
|
504
|
-
const seen = (el) => readable(el, page, 4, 4);
|
|
505
|
-
const lede = page.querySelector('.lede');
|
|
506
|
-
// the document column: the page body without its padding; the lede and the executive view take all of it (Felipe, 2026-10-13)
|
|
507
|
-
const pageStyle = getComputedStyle(page);
|
|
508
|
-
const columnWidth = Math.round(page.clientWidth - parseFloat(pageStyle.paddingLeft) - parseFloat(pageStyle.paddingRight));
|
|
509
|
-
const panels = [...page.querySelectorAll('[role="tabpanel"]')].filter(visible);
|
|
510
|
-
// a tab list inside a section belongs to that section: its panels are not topics, and it does not make its topic a branch
|
|
511
|
-
const inSection = (el) => Boolean(el.closest('[data-doc-section]'));
|
|
512
|
-
const leaves = panels.filter((p) => !inSection(p) && ![...p.querySelectorAll('[role="tablist"]')].some((tl) => visible(tl) && !inSection(tl)));
|
|
513
|
-
const topicPanel = (el) => {
|
|
514
|
-
let p = el.closest('[role="tabpanel"]');
|
|
515
|
-
while (p && inSection(p)) p = p.parentElement ? p.parentElement.closest('[role="tabpanel"]') : null;
|
|
516
|
-
return p;
|
|
517
|
-
};
|
|
518
|
-
const topics = leaves.length ? leaves.map((p) => chainOf(p)) : [{ label: '', key: '', head: null }];
|
|
519
|
-
// the outermost tab list: the views of the page (the Introduction: executive, then technical)
|
|
520
|
-
const outer = [...page.querySelectorAll('[role="tablist"]')].filter(visible).find((tl) => !tl.closest('[role="tabpanel"]') && !inSection(tl));
|
|
521
|
-
const outerTabs = outer ? [...outer.querySelectorAll('[role="tab"]')] : [];
|
|
522
|
-
const outerPanel = outer && outer.parentElement ? [...outer.parentElement.children].find((c) => c.getAttribute('role') === 'tabpanel' && visible(c)) : null;
|
|
523
|
-
const captioned = (fig) => {
|
|
524
|
-
const cap = fig.querySelector('figcaption');
|
|
525
|
-
if (!cap || !textOf(cap)) return null;
|
|
526
|
-
const svg = fig.querySelector('svg');
|
|
527
|
-
const img = [...fig.querySelectorAll('img')].find((i) => /\.svg(\?|#|$)/i.test(i.getAttribute('src') || ''));
|
|
528
|
-
if (!(svg && visible(svg)) && !(img && visible(img))) return null;
|
|
529
|
-
return textOf(cap);
|
|
530
|
-
};
|
|
531
|
-
const equationCaption = (eq) => {
|
|
532
|
-
const cap = eq.querySelector('.equation-caption');
|
|
533
|
-
return cap && textOf(cap) ? textOf(cap) : null;
|
|
534
|
-
};
|
|
535
|
-
const sections = [...page.querySelectorAll('[data-doc-section]')].filter(visible).map((sec) => {
|
|
536
|
-
const mine = (el) => el.closest('[data-doc-section]') === sec && seen(el);
|
|
537
|
-
const dense = new Set();
|
|
538
|
-
for (const p of sec.querySelectorAll('p')) {
|
|
539
|
-
if (!mine(p) || p.closest('.callout, figure, .equation, table, [data-glossary], [data-stat]')) continue;
|
|
540
|
-
const { words, linkWords } = wordsOf(p);
|
|
541
|
-
if (words >= 40 && linkWords * 2 < words) dense.add(textOf(p).toLowerCase());
|
|
542
|
-
}
|
|
543
|
-
const callouts = [...sec.querySelectorAll('.callout')].filter(mine);
|
|
544
|
-
const variant = (c) => c.getAttribute('data-callout') || (c.classList.contains('callout-honest') ? 'honest' : c.classList.contains('callout-strong') ? 'strong' : 'note');
|
|
545
|
-
// what is distinct in this state, so the same thing seen in another state (a sub-tab of the section) counts once
|
|
546
|
-
const keyed = (els, key) => {
|
|
547
|
-
const seenKey = new Map();
|
|
548
|
-
return els.map((e) => {
|
|
549
|
-
const k = key(e);
|
|
550
|
-
const n = seenKey.get(k) ?? 0;
|
|
551
|
-
seenKey.set(k, n + 1);
|
|
552
|
-
return `${k}#${n}`;
|
|
553
|
-
});
|
|
554
|
-
};
|
|
555
|
-
const hasText = (el) => /[\p{L}\p{N}]/u.test(textOf(el));
|
|
556
|
-
// glossary items a reader sees: a term and a definition, both visible and holding a letter or a digit
|
|
557
|
-
const glossary = [...sec.querySelectorAll('[data-glossary]')].filter(mine).reduce((a, g) => {
|
|
558
|
-
const dts = [...g.querySelectorAll('dt')];
|
|
559
|
-
if (!dts.length) return a + [...g.querySelectorAll(':scope > li')].filter((li) => seen(li) && hasText(li)).length;
|
|
560
|
-
return a + dts.filter((dt) => {
|
|
561
|
-
const dd = dt.nextElementSibling;
|
|
562
|
-
return seen(dt) && hasText(dt) && dd && dd.tagName === 'DD' && seen(dd) && hasText(dd);
|
|
563
|
-
}).length;
|
|
564
|
-
}, 0);
|
|
565
|
-
const plots = [...sec.querySelectorAll('[data-plot]')].filter(mine);
|
|
566
|
-
const bare = [...sec.querySelectorAll('canvas, svg[data-chart]')].filter((c) => mine(c) && !c.closest('[data-plot]'));
|
|
567
|
-
const panel = topicPanel(sec);
|
|
568
|
-
const topic = panel ? chainOf(panel) : { label: '', key: '', head: null };
|
|
569
|
-
// the first table of the section: its rows of three or more cells, each proved by a quantified statement
|
|
570
|
-
const table = [...sec.querySelectorAll('table')].find(mine);
|
|
571
|
-
const tableRows = table ? [...table.querySelectorAll('tbody tr')].filter((r) => seen(r) && r.querySelectorAll('td, th').length >= 3) : [];
|
|
572
|
-
const list = [...sec.querySelectorAll('ul, ol')].find((l) => mine(l) && !l.closest('[data-glossary]') && !l.parentElement.closest('ul, ol'));
|
|
573
|
-
const items = list ? [...list.querySelectorAll(':scope > li')].filter(seen) : [];
|
|
574
|
-
// the rows of the area: those of its first table, or the items of its first list when the list comes first
|
|
575
|
-
const rowEls = list && (!table || !(table.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING)) ? items : tableRows;
|
|
576
|
-
const nonEmpty = (q, sel) => {
|
|
577
|
-
const e = q.querySelector(sel);
|
|
578
|
-
return Boolean(e && seen(e) && /[\p{L}\p{N}]/u.test(textOf(e)));
|
|
579
|
-
};
|
|
580
|
-
const eqEls = [...sec.querySelectorAll('.equation')].filter((e) => mine(e) && equationCaption(e));
|
|
581
|
-
const figEls = [...sec.querySelectorAll('figure')].filter((f) => mine(f) && captioned(f));
|
|
582
|
-
const honestEls = callouts.filter((c) => variant(c) === 'honest');
|
|
583
|
-
const strongEls = callouts.filter((c) => variant(c) === 'strong');
|
|
584
|
-
return {
|
|
585
|
-
title: ((sec.querySelector('h2') || {}).textContent || '').trim().slice(0, 60),
|
|
586
|
-
area: sec.getAttribute('data-area'),
|
|
587
|
-
kind: sec.getAttribute('data-method-kind'),
|
|
588
|
-
topic: topic.label,
|
|
589
|
-
topicKey: topic.key,
|
|
590
|
-
view: topic.head,
|
|
591
|
-
dense: dense.size,
|
|
592
|
-
denseKeys: [...dense],
|
|
593
|
-
equations: eqEls.length,
|
|
594
|
-
equationKeys: keyed(eqEls, (e) => `${equationCaption(e)}|${(e.querySelector('annotation') || {}).textContent || ''}`),
|
|
595
|
-
figures: figEls.length,
|
|
596
|
-
figureKeys: keyed(figEls, (f) => captioned(f)),
|
|
597
|
-
callouts: callouts.length,
|
|
598
|
-
calloutKeys: keyed(callouts, (c) => `${variant(c)}|${textOf(c)}`),
|
|
599
|
-
honest: honestEls.length,
|
|
600
|
-
honestKeys: keyed(honestEls, (c) => `${variant(c)}|${textOf(c)}`),
|
|
601
|
-
strong: strongEls.length,
|
|
602
|
-
strongKeys: keyed(strongEls, (c) => `${variant(c)}|${textOf(c)}`),
|
|
603
|
-
refs: Number(sec.getAttribute('data-refs')) || 0,
|
|
604
|
-
glossary,
|
|
605
|
-
steps: Math.max(0, ...[...sec.querySelectorAll('ol')].filter(mine).map((o) => [...o.querySelectorAll(':scope > li')].filter((li) => seen(li) && hasText(li)).length)),
|
|
606
|
-
tables: [...sec.querySelectorAll('table')].filter((t) => mine(t) && !t.parentElement.closest('table')).length,
|
|
607
|
-
codes: [...sec.querySelectorAll('pre')].filter(mine).length,
|
|
608
|
-
charts: plots.length + bare.length,
|
|
609
|
-
// a control registered with the panel (data-control), not any button or link
|
|
610
|
-
controls: [...sec.querySelectorAll('[data-control]')].filter(mine).length,
|
|
611
|
-
stats: [...sec.querySelectorAll('[data-stat]')].filter((e) => mine(e) && validStat(e)).length,
|
|
612
|
-
rows: rowEls.length,
|
|
613
|
-
rowsNoStat: rowEls.filter((r) => ![...r.querySelectorAll('[data-stat]')].some(validStat)).length,
|
|
614
|
-
items: items.length,
|
|
615
|
-
thinItems: items.filter((li) => wordsOf(li).words < 6).length,
|
|
616
|
-
qa: [...sec.querySelectorAll('[data-qa]')].filter((q) => mine(q) && nonEmpty(q, '[data-question]') && nonEmpty(q, '[data-answer]')).length,
|
|
617
|
-
};
|
|
618
|
-
});
|
|
619
|
-
const selected = outerTabs.find((t) => t.getAttribute('aria-selected') === 'true');
|
|
620
|
-
// The executive view (shell 0.13.0): what the ExecutiveView shows in this state (one sub-tab = one section), for executiveFailures of gate/areas.mjs.
|
|
621
|
-
const execRoots = [...page.querySelectorAll('[data-executive-view]')].filter(visible);
|
|
622
|
-
const executive = (() => {
|
|
623
|
-
if (!execRoots.length) return { count: 0 };
|
|
624
|
-
const root = execRoots[0];
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
for (const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
// the
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
const
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
?
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
//
|
|
888
|
-
|
|
889
|
-
const
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
if (
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1022
|
-
return
|
|
1023
|
-
};
|
|
1024
|
-
|
|
1025
|
-
/** The
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
const
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
+
// The smallest rendered size of an illustration label (EXEC_LIMITS.minLabelPx of gate/areas.mjs; test/executive.test.ts keeps them equal).
|
|
11
|
+
const EXEC_MIN_LABEL_PX = 9;
|
|
12
|
+
G.counting = false;
|
|
13
|
+
const nativeRaf = window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : null;
|
|
14
|
+
if (nativeRaf) {
|
|
15
|
+
window.requestAnimationFrame = (cb) =>
|
|
16
|
+
nativeRaf((t) => {
|
|
17
|
+
if (G.counting) G.raf += 1;
|
|
18
|
+
cb(t);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The ancestors of an element up to (not including) `stop`. */
|
|
23
|
+
function* ancestorsUntil(el, stop) {
|
|
24
|
+
for (let a = el.parentElement; a && a !== stop && a !== document.body; a = a.parentElement) yield a;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const visible = (el) => {
|
|
28
|
+
if (!el || el.nodeType !== 1) return false;
|
|
29
|
+
const r = el.getBoundingClientRect();
|
|
30
|
+
if (r.width <= 0 || r.height <= 0) return false;
|
|
31
|
+
if (el.checkVisibility) return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
|
|
32
|
+
const s = getComputedStyle(el);
|
|
33
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
|
|
34
|
+
};
|
|
35
|
+
const shown = (el) => el.getClientRects().length > 0 && (el.checkVisibility ? el.checkVisibility({ checkVisibilityCSS: true }) : true);
|
|
36
|
+
const FONT_FLOOR = 8; // px: a smaller font is not legible, whatever its line box
|
|
37
|
+
const fontPx = (el) => Number.parseFloat(getComputedStyle(el).fontSize);
|
|
38
|
+
/**
|
|
39
|
+
* The box a `clip-path` leaves of an element's own box `ar`, or null when the gate cannot read the shape: `inset()` is
|
|
40
|
+
* computed, any other shape (circle, polygon, path, url) is taken as cutting the element away.
|
|
41
|
+
*/
|
|
42
|
+
const clipPathBox = (cp, ar) => {
|
|
43
|
+
const m = /^inset\((.*)\)$/.exec(cp.trim());
|
|
44
|
+
if (!m) return null;
|
|
45
|
+
const v = m[1].split(/\bround\b/)[0].trim().split(/\s+/).filter(Boolean);
|
|
46
|
+
if (v.length < 1 || v.length > 4) return null;
|
|
47
|
+
const [top, right, bottom, left] = [v[0], v[1] ?? v[0], v[2] ?? v[0], v[3] ?? v[1] ?? v[0]];
|
|
48
|
+
const px = (x, ref) => (x.endsWith('%') ? (Number.parseFloat(x) / 100) * ref : Number.parseFloat(x));
|
|
49
|
+
const o = { top: px(top, ar.height), right: px(right, ar.width), bottom: px(bottom, ar.height), left: px(left, ar.width) };
|
|
50
|
+
if (Object.values(o).some((n) => Number.isNaN(n))) return null;
|
|
51
|
+
return { left: ar.left + o.left, top: ar.top + o.top, right: ar.right - o.right, bottom: ar.bottom - o.bottom };
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` up (below `within`) that
|
|
55
|
+
* hides its overflow (`el` itself only when `self`: its overflow cuts its own text, not its own box) and inside every
|
|
56
|
+
* `clip-path` of `el` and of those ancestors. Null when nothing is left.
|
|
57
|
+
*/
|
|
58
|
+
const clipRect = (rect, el, within, self) => {
|
|
59
|
+
const b = within.getBoundingClientRect();
|
|
60
|
+
let left = Math.max(rect.left, b.left);
|
|
61
|
+
let top = Math.max(rect.top, b.top);
|
|
62
|
+
let right = Math.min(rect.right, b.right);
|
|
63
|
+
let bottom = Math.min(rect.bottom, b.bottom);
|
|
64
|
+
for (let a = el; a && a !== within && a !== document.body; a = a.parentElement) {
|
|
65
|
+
const s = getComputedStyle(a);
|
|
66
|
+
const ar = a.getBoundingClientRect();
|
|
67
|
+
if (a !== el || self) {
|
|
68
|
+
if (/hidden|clip/.test(s.overflowX)) {
|
|
69
|
+
left = Math.max(left, ar.left);
|
|
70
|
+
right = Math.min(right, ar.right);
|
|
71
|
+
}
|
|
72
|
+
if (/hidden|clip/.test(s.overflowY)) {
|
|
73
|
+
top = Math.max(top, ar.top);
|
|
74
|
+
bottom = Math.min(bottom, ar.bottom);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (s.clipPath && s.clipPath !== 'none') {
|
|
78
|
+
const c = clipPathBox(s.clipPath, ar);
|
|
79
|
+
if (!c) return null;
|
|
80
|
+
left = Math.max(left, c.left);
|
|
81
|
+
top = Math.max(top, c.top);
|
|
82
|
+
right = Math.min(right, c.right);
|
|
83
|
+
bottom = Math.min(bottom, c.bottom);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
|
87
|
+
};
|
|
88
|
+
const textNodesOf = (el) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
91
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
92
|
+
if (!/\S/.test(n.nodeValue || '') || !n.parentElement || n.parentElement.closest('script, style, .katex-mathml')) continue;
|
|
93
|
+
out.push(n);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const probe = document.createRange();
|
|
98
|
+
/** Whether a reader sees the glyphs of a text node: its element is rendered, its font is legible, and a line of it lies inside what is not clipped. */
|
|
99
|
+
const glyphsSeen = (node, within, minW, minH) => {
|
|
100
|
+
const p = node.parentElement;
|
|
101
|
+
if (!p || (p.checkVisibility && !p.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) || fontPx(p) < FONT_FLOOR) return false;
|
|
102
|
+
probe.selectNodeContents(node);
|
|
103
|
+
for (const rc of probe.getClientRects()) {
|
|
104
|
+
if (rc.width <= 0 || rc.height <= 0) continue;
|
|
105
|
+
const c = clipRect(rc, p, within, true);
|
|
106
|
+
if (c && c.right - c.left >= minW && c.bottom - c.top >= minH) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* What a reader can read (ADR-0080 decision 3, ADR-0017 2.1): the element is rendered (a box, opacity above 0, CSS
|
|
112
|
+
* visibility), its font is not zero, the part of its box that lies inside `within` (and inside every ancestor that
|
|
113
|
+
* hides its overflow, and every clip-path) is at least minW by minH px, and the glyphs of its text are painted where
|
|
114
|
+
* that is so: a text node counts when its font is at least FONT_FLOOR px and a line of it is inside what is not clipped
|
|
115
|
+
* (text pushed out of its box by `text-indent`, cut by `clip-path` or set in a 1 px font is not read). An element with
|
|
116
|
+
* no text (a drawing) is judged by its box. `only` judges one text node of the element. A 1 by 1 px screen-reader-only
|
|
117
|
+
* note, a transparent one, one positioned off-screen or one cut off by a clipping box is not readable. `shown` stays
|
|
118
|
+
* for `data-state="loading"`, which must catch every pending element, visible or not.
|
|
119
|
+
*/
|
|
120
|
+
const readable = (el, within, minW = 40, minH = 12, only = null) => {
|
|
121
|
+
if (!visible(el) || !within) return false;
|
|
122
|
+
if (fontPx(el) < 1) return false;
|
|
123
|
+
const box = clipRect(el.getBoundingClientRect(), el, within, false);
|
|
124
|
+
if (!box || box.right - box.left < minW || box.bottom - box.top < minH) return false;
|
|
125
|
+
if (!only && el.matches('svg, canvas, img, video')) return true; // a drawing: the box decides, its labels are not the text of a reader
|
|
126
|
+
const nodes = only ? [only] : textNodesOf(el);
|
|
127
|
+
if (nodes.length === 0) return true;
|
|
128
|
+
return nodes.some((n) => glyphsSeen(n, within, Math.min(minW, 2), Math.min(minH, 4)));
|
|
129
|
+
};
|
|
130
|
+
/** The words and the letters and digits of the text a reader sees in an element (rendered text: hidden descendants are not in it). */
|
|
131
|
+
const renderedText = (el) => {
|
|
132
|
+
const t = el.innerText || '';
|
|
133
|
+
return { chars: t.replace(/[^\p{L}\p{N}]+/gu, '').length, words: t.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length };
|
|
134
|
+
};
|
|
135
|
+
const path = (el) => {
|
|
136
|
+
const parts = [];
|
|
137
|
+
let e = el;
|
|
138
|
+
while (e && e.nodeType === 1 && e !== document.body && e !== document.documentElement) {
|
|
139
|
+
const p = e.parentElement;
|
|
140
|
+
if (!p) break;
|
|
141
|
+
parts.unshift(`${e.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(p.children, e) + 1})`);
|
|
142
|
+
e = p;
|
|
143
|
+
}
|
|
144
|
+
return parts.length ? `body > ${parts.join(' > ')}` : 'body';
|
|
145
|
+
};
|
|
146
|
+
const describe = (el) => {
|
|
147
|
+
if (!el || el.nodeType !== 1) return String(el);
|
|
148
|
+
const t = el.tagName.toLowerCase();
|
|
149
|
+
const cls = typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 3).join('.')}` : '';
|
|
150
|
+
const attrs = ['data-control', 'data-tab', 'data-panel', 'data-plot', 'data-stage', 'data-case', 'data-readout', 'aria-label']
|
|
151
|
+
.filter((a) => el.hasAttribute(a))
|
|
152
|
+
.map((a) => `[${a}="${String(el.getAttribute(a)).slice(0, 30)}"]`)
|
|
153
|
+
.join('');
|
|
154
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 32);
|
|
155
|
+
return `${t}${el.id ? `#${el.id}` : ''}${cls}${attrs}${text ? ` "${text}"` : ''}`;
|
|
156
|
+
};
|
|
157
|
+
G.describe = describe;
|
|
158
|
+
|
|
159
|
+
/** '' when the page has settled on its declared state; otherwise why not (G7: never networkidle, never a sleep). */
|
|
160
|
+
G.ready = () => {
|
|
161
|
+
if (!document.querySelector('[data-brand]')) return 'no [data-brand]: the shell has not rendered';
|
|
162
|
+
const loading = [...document.querySelectorAll('[data-state="loading"]')].filter(shown);
|
|
163
|
+
if (loading.length) return `${loading.length} element(s) still declare data-state="loading": ${loading.slice(0, 3).map(describe).join('; ')}`;
|
|
164
|
+
const stale = [...document.querySelectorAll('[data-stale="1"]')].filter(shown);
|
|
165
|
+
if (stale.length) return `${stale.length} view(s) still show an earlier selection (data-stale="1"): ${stale.slice(0, 3).map(describe).join('; ')}`;
|
|
166
|
+
const undrawn = [...document.querySelectorAll('[data-drawn="0"]')].filter(shown);
|
|
167
|
+
if (undrawn.length) return `${undrawn.length} stage(s) never got a size (data-drawn="0"): ${undrawn.slice(0, 3).map(describe).join('; ')}`;
|
|
168
|
+
return '';
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** What the page says it is and which mode it is in (G1, G2). */
|
|
172
|
+
G.identity = () => {
|
|
173
|
+
const b = document.querySelector('[data-brand]');
|
|
174
|
+
const active = document.querySelector('header nav a.active, header nav a[aria-current="page"]');
|
|
175
|
+
return {
|
|
176
|
+
brand: b ? b.getAttribute('data-brand') : null,
|
|
177
|
+
title: document.title,
|
|
178
|
+
theme: document.documentElement.dataset.theme ?? null,
|
|
179
|
+
lang: document.documentElement.lang || null,
|
|
180
|
+
activeHref: active ? active.href : null,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The static layout of the current view (G5, G6 inputs, ADR-0071 and ADR-0017 measures). */
|
|
185
|
+
G.facts = () => {
|
|
186
|
+
const vw = window.innerWidth;
|
|
187
|
+
const vh = window.innerHeight;
|
|
188
|
+
const de = document.documentElement;
|
|
189
|
+
const body = document.body;
|
|
190
|
+
const docH = Math.max(de.scrollHeight, body.scrollHeight);
|
|
191
|
+
const docW = Math.max(de.scrollWidth, body.scrollWidth);
|
|
192
|
+
|
|
193
|
+
// Element boxes beyond the viewport, not clipped by an ancestor (scrollWidth alone misses clipped documents).
|
|
194
|
+
const beyond = [];
|
|
195
|
+
const walk = (el, clipped) => {
|
|
196
|
+
for (const c of el.children) {
|
|
197
|
+
const s = getComputedStyle(c);
|
|
198
|
+
if (s.display === 'none') continue;
|
|
199
|
+
const r = c.getBoundingClientRect();
|
|
200
|
+
if (!clipped && r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && (r.right > vw + 1 || r.left < -1)) {
|
|
201
|
+
beyond.push(`${describe(c)} spans ${Math.round(r.left)}..${Math.round(r.right)} of a ${vw}px viewport`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (c.tagName.toLowerCase() === 'svg') continue;
|
|
205
|
+
walk(c, clipped || s.overflowX !== 'visible');
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(body, false);
|
|
209
|
+
|
|
210
|
+
// Tab rows: one row each (ADR-0071 rule 4); tops clustered so a 1px active-tab offset is not a second row. A row
|
|
211
|
+
// also shows its tabs whole: a row that shrank under a tall panel cut its tabs' height (known shell defect 16).
|
|
212
|
+
const rows = [...document.querySelectorAll('[role="tablist"]')]
|
|
213
|
+
.filter((tl) => visible(tl) && tl.getAttribute('aria-orientation') !== 'vertical')
|
|
214
|
+
.map((tl) => {
|
|
215
|
+
const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
|
|
216
|
+
const tops = tabs.map((t) => t.getBoundingClientRect().top).sort((a, b) => a - b);
|
|
217
|
+
let n = tops.length ? 1 : 0;
|
|
218
|
+
for (let i = 1; i < tops.length; i += 1) if (tops[i] - tops[i - 1] > 8) n += 1;
|
|
219
|
+
// a shrunk row either lets its tabs overflow it or stretches them shorter than their own content
|
|
220
|
+
const lr = tl.getBoundingClientRect();
|
|
221
|
+
let cut = Math.max(0, tl.scrollHeight - tl.clientHeight);
|
|
222
|
+
for (const t of tabs) {
|
|
223
|
+
const r = t.getBoundingClientRect();
|
|
224
|
+
cut = Math.max(cut, t.scrollHeight - t.clientHeight, Math.round(Math.max(0, lr.top - r.top) + Math.max(0, r.bottom - lr.bottom)));
|
|
225
|
+
}
|
|
226
|
+
return { name: tl.getAttribute('aria-label') || describe(tl), rows: n, cut };
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The rail: no scroll (rule 6) and every descendant inside its box (G5).
|
|
230
|
+
const railEl = document.querySelector('[data-rail]');
|
|
231
|
+
let rail = null;
|
|
232
|
+
if (railEl && visible(railEl)) {
|
|
233
|
+
const rr = railEl.getBoundingClientRect();
|
|
234
|
+
const outside = [];
|
|
235
|
+
for (const d of railEl.querySelectorAll('*')) {
|
|
236
|
+
if (d.closest('svg') && d.tagName.toLowerCase() !== 'svg') continue;
|
|
237
|
+
if (!visible(d)) continue;
|
|
238
|
+
const r = d.getBoundingClientRect();
|
|
239
|
+
if (r.width <= 1 || r.height <= 1) continue;
|
|
240
|
+
let p = d.parentElement;
|
|
241
|
+
let scroller = false;
|
|
242
|
+
while (p && p !== railEl) {
|
|
243
|
+
if (getComputedStyle(p).overflowX !== 'visible') {
|
|
244
|
+
scroller = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
p = p.parentElement;
|
|
248
|
+
}
|
|
249
|
+
if (scroller) continue;
|
|
250
|
+
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)}`);
|
|
251
|
+
}
|
|
252
|
+
rail = { scroll: railEl.scrollHeight, client: railEl.clientHeight, outside: outside.slice(0, 5), outsideCount: outside.length };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Truncated text must carry its full text (G5).
|
|
256
|
+
const truncated = [];
|
|
257
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
258
|
+
if (el.namespaceURI === 'http://www.w3.org/2000/svg') continue;
|
|
259
|
+
const s = getComputedStyle(el);
|
|
260
|
+
if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
|
|
261
|
+
// The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
|
|
262
|
+
// ancestors that CONTAINS it. Any label nearby is not enough: the rail's own aria-label would excuse everything.
|
|
263
|
+
const full = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
264
|
+
let named = false;
|
|
265
|
+
for (let i = 0, t = el; i < 3 && t; i += 1, t = t.parentElement) {
|
|
266
|
+
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/\s+/g, ' ');
|
|
267
|
+
if (full && label.includes(full)) {
|
|
268
|
+
named = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (!named) truncated.push(describe(el));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Containers that cut their content: overflow hidden or clip with more content than box (S1, G5).
|
|
276
|
+
const clipped = [];
|
|
277
|
+
const containers = new Set([de, body, document.getElementById('root'), ...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .caos-cw, [data-plot]')]);
|
|
278
|
+
for (const el of containers) {
|
|
279
|
+
if (!el || !visible(el)) continue;
|
|
280
|
+
const s = getComputedStyle(el);
|
|
281
|
+
if ((s.overflowY === 'hidden' || s.overflowY === 'clip') && el.scrollHeight > el.clientHeight + 4) {
|
|
282
|
+
clipped.push(`${describe(el)} cuts ${el.scrollHeight - el.clientHeight}px of content (overflow-y ${s.overflowY})`);
|
|
283
|
+
}
|
|
284
|
+
if ((s.overflowX === 'hidden' || s.overflowX === 'clip') && el.scrollWidth > el.clientWidth + 4) {
|
|
285
|
+
clipped.push(`${describe(el)} cuts ${el.scrollWidth - el.clientWidth}px of content horizontally (overflow-x ${s.overflowX})`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Layout containers that scroll sideways: a wide child pushed the page, the instrument or a panel past its width.
|
|
290
|
+
// Rows built to scroll (the nav, tab rows, a plot body holding a wide table) are not layout containers.
|
|
291
|
+
for (const el of new Set([...document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel, .subtabpanel, .caos-cw')])) {
|
|
292
|
+
if (!visible(el)) continue;
|
|
293
|
+
const s = getComputedStyle(el);
|
|
294
|
+
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) {
|
|
295
|
+
clipped.push(`${describe(el)} scrolls sideways by ${el.scrollWidth - el.clientWidth}px (a child is wider than the layout)`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The instrument and what is drawn in it (G6): canvases, svg drawings, images, tables; icons excluded.
|
|
300
|
+
const inst = document.querySelector('[data-instrument]');
|
|
301
|
+
let instrument = null;
|
|
302
|
+
const surfaces = [];
|
|
303
|
+
let prose = false;
|
|
304
|
+
let unavailable = null; // data-state="unavailable" declared in the instrument: 'explained' (a reason a reader can read), 'unexplained' (readable, no reason) or 'unseen' (no reader can see it)
|
|
305
|
+
let drawn = 0; // drawing elements rendered in the instrument, whatever their size or visibility
|
|
306
|
+
if (inst && visible(inst)) {
|
|
307
|
+
const ir = inst.getBoundingClientRect();
|
|
308
|
+
const cx0 = Math.max(0, ir.left);
|
|
309
|
+
const cy0 = Math.max(0, ir.top);
|
|
310
|
+
const cx1 = Math.min(vw, ir.right);
|
|
311
|
+
const cy1 = Math.min(vh, ir.bottom);
|
|
312
|
+
instrument = { x: cx0, y: cy0, w: Math.max(0, cx1 - cx0), h: Math.max(0, cy1 - cy0) };
|
|
313
|
+
const ctx = inst.querySelector('.caos-cw-context');
|
|
314
|
+
prose = Boolean(ctx && visible(ctx));
|
|
315
|
+
// a declaration counts when a reader can see the element (a box of 40 by 12 px at least, inside the instrument,
|
|
316
|
+
// opaque); its reason is the text rendered, 12 letters or digits in two words at least
|
|
317
|
+
const declared = [...inst.querySelectorAll('[data-state="unavailable"]')].filter(shown);
|
|
318
|
+
const seen = declared.filter((el) => readable(el, inst));
|
|
319
|
+
if (seen.length) {
|
|
320
|
+
unavailable = seen.some((el) => {
|
|
321
|
+
const t = renderedText(el);
|
|
322
|
+
return t.chars >= 12 && t.words >= 2;
|
|
323
|
+
})
|
|
324
|
+
? 'explained'
|
|
325
|
+
: 'unexplained';
|
|
326
|
+
} else if (declared.length) unavailable = 'unseen';
|
|
327
|
+
// a drawing element rendered at all (a collapsed or invisible chart too) cancels the exemption; a card that holds the note is not one
|
|
328
|
+
drawn = [...inst.querySelectorAll('canvas, img, video, table, svg, [data-plot], [data-drawn]')].filter((el) => {
|
|
329
|
+
if (el.getClientRects().length === 0) return false;
|
|
330
|
+
const tag = el.tagName.toLowerCase();
|
|
331
|
+
if (tag === 'svg') {
|
|
332
|
+
if (el.parentElement && el.parentElement.closest('svg')) return false;
|
|
333
|
+
if (el.classList.contains('lucide') || el.closest('button, a, summary, [role="tab"], [role="button"]')) return false;
|
|
334
|
+
const r = el.getBoundingClientRect();
|
|
335
|
+
if (el.getAttribute('aria-hidden') === 'true' && r.width > 0 && r.height > 0 && r.width <= 32 && r.height <= 32) return false;
|
|
336
|
+
}
|
|
337
|
+
if (tag === 'table' && el.parentElement && el.parentElement.closest('table')) return false;
|
|
338
|
+
if (el.matches('[data-plot], [data-drawn]') && declared.some((d) => el.contains(d))) return false;
|
|
339
|
+
return true;
|
|
340
|
+
}).length;
|
|
341
|
+
for (const el of inst.querySelectorAll('canvas, svg, img, table, video')) {
|
|
342
|
+
const tag = el.tagName.toLowerCase();
|
|
343
|
+
if (tag === 'svg' && el.parentElement && el.parentElement.closest('svg')) continue;
|
|
344
|
+
if (el.parentElement && el.parentElement.closest('table') && tag === 'table') continue;
|
|
345
|
+
if (!visible(el)) continue;
|
|
346
|
+
const r = el.getBoundingClientRect();
|
|
347
|
+
const min = tag === 'canvas' || tag === 'table' ? 16 : 48;
|
|
348
|
+
if (r.width < min || r.height < min) continue;
|
|
349
|
+
const x = Math.max(cx0, r.left);
|
|
350
|
+
const y = Math.max(cy0, r.top);
|
|
351
|
+
const x2 = Math.min(cx1, r.right);
|
|
352
|
+
const y2 = Math.min(cy1, r.bottom);
|
|
353
|
+
if (x2 - x < 2 || y2 - y < 2) continue;
|
|
354
|
+
// The stage a drawing is judged against: the view's body, else the sized stage, else the surface itself; the
|
|
355
|
+
// view is the card that holds it. A drawing that fills a small corner of a large view underfills its stage.
|
|
356
|
+
const stageEl = el.closest('.caos-plot-body') || el.closest('[data-drawn]') || el;
|
|
357
|
+
const viewEl = el.closest('[data-plot]') || stageEl;
|
|
358
|
+
const clipRect = (b) => {
|
|
359
|
+
const sx = Math.max(cx0, b.left);
|
|
360
|
+
const sy = Math.max(cy0, b.top);
|
|
361
|
+
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) };
|
|
362
|
+
};
|
|
363
|
+
surfaces.push({
|
|
364
|
+
kind: tag,
|
|
365
|
+
label: describe(viewEl),
|
|
366
|
+
x,
|
|
367
|
+
y,
|
|
368
|
+
w: x2 - x,
|
|
369
|
+
h: y2 - y,
|
|
370
|
+
// a table or drawing inside a box that SCROLLS sideways (a wide table on a phone, ADR-0071 rule 3) ends where
|
|
371
|
+
// its box ends: a reader scrolls it; one cut by a box that hides its overflow still extends past the page
|
|
372
|
+
right: Math.min(r.right, ...[...ancestorsUntil(el, inst)].filter((a) => ['auto', 'scroll'].includes(getComputedStyle(a).overflowX)).map((a) => a.getBoundingClientRect().right)),
|
|
373
|
+
stageKey: path(stageEl),
|
|
374
|
+
stage: clipRect(stageEl.getBoundingClientRect()),
|
|
375
|
+
viewKey: path(viewEl),
|
|
376
|
+
view: clipRect(viewEl.getBoundingClientRect()),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const wb = document.querySelector('[data-case-workbench]');
|
|
382
|
+
const workbench = wb
|
|
383
|
+
? { case: wb.getAttribute('data-case'), key: wb.getAttribute('data-state-key'), replayOnly: wb.getAttribute('data-replay-only') === '1' }
|
|
384
|
+
: null;
|
|
385
|
+
|
|
386
|
+
// The references of a page or open tab (ADR-0017 s4): one block, the last thing in its scope (the document page
|
|
387
|
+
// or the tab panel that holds it).
|
|
388
|
+
const refScope = (el) => el.closest('[role="tabpanel"], [data-doc-page]');
|
|
389
|
+
const refBlocks = [...document.querySelectorAll('[data-doc-references]')].filter(visible);
|
|
390
|
+
const isLastInScope = (block) => {
|
|
391
|
+
const scope = refScope(block);
|
|
392
|
+
for (let el = block; el && el !== scope; el = el.parentElement) {
|
|
393
|
+
for (let n = el.nextElementSibling; n; n = n.nextElementSibling) if (visible(n)) return false;
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
let offCentre = null;
|
|
399
|
+
const proseBody = document.querySelector('.page-body:not(.wide)');
|
|
400
|
+
if (proseBody && visible(proseBody)) {
|
|
401
|
+
const parent = proseBody.parentElement;
|
|
402
|
+
const pb = proseBody.getBoundingClientRect();
|
|
403
|
+
const cb = parent.getBoundingClientRect();
|
|
404
|
+
offCentre = Math.abs(pb.left - cb.left - (parent.clientWidth - pb.width) / 2);
|
|
405
|
+
}
|
|
406
|
+
const pageBody = document.querySelector('.page-body');
|
|
407
|
+
return {
|
|
408
|
+
vw,
|
|
409
|
+
vh,
|
|
410
|
+
docH,
|
|
411
|
+
docW,
|
|
412
|
+
beyond: beyond.slice(0, 5),
|
|
413
|
+
beyondCount: beyond.length,
|
|
414
|
+
rows,
|
|
415
|
+
rail,
|
|
416
|
+
truncated: truncated.slice(0, 5),
|
|
417
|
+
truncatedCount: truncated.length,
|
|
418
|
+
clipped,
|
|
419
|
+
instrument,
|
|
420
|
+
surfaces,
|
|
421
|
+
prose,
|
|
422
|
+
unavailable,
|
|
423
|
+
drawn,
|
|
424
|
+
workbench,
|
|
425
|
+
offCentre,
|
|
426
|
+
pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
|
|
427
|
+
referenceLists: [...document.querySelectorAll('.reference-list')].filter(visible).length,
|
|
428
|
+
// shell 0.11.0: the references of a page or open tab are one block at its end (ADR-0017 s4)
|
|
429
|
+
referenceBlocks: refBlocks.length,
|
|
430
|
+
referencesNotLast: refBlocks.filter((b) => !isLastInScope(b)).length,
|
|
431
|
+
refsDeclaredNoBlock: [...document.querySelectorAll('[data-refs]')].filter((sec) => visible(sec) && !refBlocks.some((b) => refScope(b) === refScope(sec))).length,
|
|
432
|
+
uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
|
|
433
|
+
// charts whose axis gives a tick the label of the tick before it (UPlotChart declares data-ticks-repeat)
|
|
434
|
+
repeatedTicks: [...document.querySelectorAll('[data-ticks-repeat]')]
|
|
435
|
+
.filter((el) => visible(el) && Number(el.getAttribute('data-ticks-repeat')) > 0)
|
|
436
|
+
.map((el) => ({ label: describe(el.closest('[data-plot]') || el), n: Number(el.getAttribute('data-ticks-repeat')) })),
|
|
437
|
+
// the drawing a filling card keeps under its note (G6 stage floor): the body of every visible `PlotCard fill`
|
|
438
|
+
fillStages: [...document.querySelectorAll('.caos-plot.fill')]
|
|
439
|
+
.filter(visible)
|
|
440
|
+
.map((card) => ({ label: describe(card), h: (card.querySelector(':scope > .caos-plot-body') || card).getBoundingClientRect().height })),
|
|
441
|
+
sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
|
|
442
|
+
};
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// ---- 0.11.0: the required areas of the documentation pages (ADR-0017 section 2.1) ------------------------------
|
|
446
|
+
|
|
447
|
+
let docBound = null; // the page being measured: what lies outside it, or is clipped out of it, is not read
|
|
448
|
+
const wordsOf = (el) => {
|
|
449
|
+
// the words a reader reads: visible text outside equations; `linkWords` of them inside links
|
|
450
|
+
let words = 0;
|
|
451
|
+
let linkWords = 0;
|
|
452
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
453
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
454
|
+
const parent = n.parentElement;
|
|
455
|
+
if (!parent || parent.closest('.katex, script, style') || !readable(parent, docBound, 4, 4, n)) continue;
|
|
456
|
+
const k = (n.nodeValue || '').split(/\s+/).filter(Boolean).length;
|
|
457
|
+
words += k;
|
|
458
|
+
if (parent.closest('a')) linkWords += k;
|
|
459
|
+
}
|
|
460
|
+
return { words, linkWords };
|
|
461
|
+
};
|
|
462
|
+
const tabLabel = (t) => (t.textContent || '').trim().replace(/\s+/g, ' ');
|
|
463
|
+
/** The tabs from the outermost one to the one that opens `panel`: their labels (for messages) and their ids (for rules). */
|
|
464
|
+
const chainOf = (panel) => {
|
|
465
|
+
const labels = [];
|
|
466
|
+
const ids = [];
|
|
467
|
+
let p = panel;
|
|
468
|
+
while (p) {
|
|
469
|
+
const id = p.getAttribute('aria-labelledby');
|
|
470
|
+
const tab = id ? document.getElementById(id) : null;
|
|
471
|
+
if (!tab) break;
|
|
472
|
+
labels.unshift(tabLabel(tab));
|
|
473
|
+
ids.unshift(tab.getAttribute('data-tab') || tabLabel(tab));
|
|
474
|
+
const tl = tab.closest('[role="tablist"]');
|
|
475
|
+
p = tl && tl.parentElement ? tl.parentElement.closest('[role="tabpanel"]') : null;
|
|
476
|
+
}
|
|
477
|
+
return { label: labels.join(' > '), key: ids.join('/'), head: ids[0] ?? null };
|
|
478
|
+
};
|
|
479
|
+
const textOf = (el) => (el.innerText || '').trim().replace(/\s+/g, ' '); // rendered text: a hidden descendant is not in it
|
|
480
|
+
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
481
|
+
/** A part of a statement a reader sees: the declared text is in the rendered text, in the readable part (`sel`) when the element has one. */
|
|
482
|
+
const statPart = (el, want, sel) => {
|
|
483
|
+
if (!want) return false;
|
|
484
|
+
const kids = [...el.querySelectorAll(sel)];
|
|
485
|
+
return kids.length ? kids.some((k) => readable(k, docBound, 4, 4) && norm(k.innerText).includes(want)) : norm(el.innerText).includes(want);
|
|
486
|
+
};
|
|
487
|
+
/** A quantified statement: visible, a digit in its value, and the unit and the source it declares readable beside it (the `Stat` component). */
|
|
488
|
+
const validStat = (el) => {
|
|
489
|
+
if (!readable(el, docBound, 4, 4)) return false;
|
|
490
|
+
const value = el.querySelector('.caos-stat-value');
|
|
491
|
+
return /\d/.test((value ? value.innerText : el.innerText) || '') && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* What a documentation page shows in this state: its lede, its waivers, the leaf topics open, the views (the outermost
|
|
496
|
+
* tabs), every visible section with what it holds, and the figures and captioned equations of the open view. The rules
|
|
497
|
+
* (gate/areas.mjs) are applied by the runner; this only measures. A hidden element is never counted, a paragraph is
|
|
498
|
+
* counted once per distinct text, and the words are those of the displayed language.
|
|
499
|
+
*/
|
|
500
|
+
G.docPage = () => {
|
|
501
|
+
const page = document.querySelector('[data-doc-page]');
|
|
502
|
+
if (!page || !visible(page)) return null;
|
|
503
|
+
docBound = page;
|
|
504
|
+
const seen = (el) => readable(el, page, 4, 4);
|
|
505
|
+
const lede = page.querySelector('.lede');
|
|
506
|
+
// the document column: the page body without its padding; the lede and the executive view take all of it (Felipe, 2026-10-13)
|
|
507
|
+
const pageStyle = getComputedStyle(page);
|
|
508
|
+
const columnWidth = Math.round(page.clientWidth - parseFloat(pageStyle.paddingLeft) - parseFloat(pageStyle.paddingRight));
|
|
509
|
+
const panels = [...page.querySelectorAll('[role="tabpanel"]')].filter(visible);
|
|
510
|
+
// a tab list inside a section belongs to that section: its panels are not topics, and it does not make its topic a branch
|
|
511
|
+
const inSection = (el) => Boolean(el.closest('[data-doc-section]'));
|
|
512
|
+
const leaves = panels.filter((p) => !inSection(p) && ![...p.querySelectorAll('[role="tablist"]')].some((tl) => visible(tl) && !inSection(tl)));
|
|
513
|
+
const topicPanel = (el) => {
|
|
514
|
+
let p = el.closest('[role="tabpanel"]');
|
|
515
|
+
while (p && inSection(p)) p = p.parentElement ? p.parentElement.closest('[role="tabpanel"]') : null;
|
|
516
|
+
return p;
|
|
517
|
+
};
|
|
518
|
+
const topics = leaves.length ? leaves.map((p) => chainOf(p)) : [{ label: '', key: '', head: null }];
|
|
519
|
+
// the outermost tab list: the views of the page (the Introduction: executive, then technical)
|
|
520
|
+
const outer = [...page.querySelectorAll('[role="tablist"]')].filter(visible).find((tl) => !tl.closest('[role="tabpanel"]') && !inSection(tl));
|
|
521
|
+
const outerTabs = outer ? [...outer.querySelectorAll('[role="tab"]')] : [];
|
|
522
|
+
const outerPanel = outer && outer.parentElement ? [...outer.parentElement.children].find((c) => c.getAttribute('role') === 'tabpanel' && visible(c)) : null;
|
|
523
|
+
const captioned = (fig) => {
|
|
524
|
+
const cap = fig.querySelector('figcaption');
|
|
525
|
+
if (!cap || !textOf(cap)) return null;
|
|
526
|
+
const svg = fig.querySelector('svg');
|
|
527
|
+
const img = [...fig.querySelectorAll('img')].find((i) => /\.svg(\?|#|$)/i.test(i.getAttribute('src') || ''));
|
|
528
|
+
if (!(svg && visible(svg)) && !(img && visible(img))) return null;
|
|
529
|
+
return textOf(cap);
|
|
530
|
+
};
|
|
531
|
+
const equationCaption = (eq) => {
|
|
532
|
+
const cap = eq.querySelector('.equation-caption');
|
|
533
|
+
return cap && textOf(cap) ? textOf(cap) : null;
|
|
534
|
+
};
|
|
535
|
+
const sections = [...page.querySelectorAll('[data-doc-section]')].filter(visible).map((sec) => {
|
|
536
|
+
const mine = (el) => el.closest('[data-doc-section]') === sec && seen(el);
|
|
537
|
+
const dense = new Set();
|
|
538
|
+
for (const p of sec.querySelectorAll('p')) {
|
|
539
|
+
if (!mine(p) || p.closest('.callout, figure, .equation, table, [data-glossary], [data-stat]')) continue;
|
|
540
|
+
const { words, linkWords } = wordsOf(p);
|
|
541
|
+
if (words >= 40 && linkWords * 2 < words) dense.add(textOf(p).toLowerCase());
|
|
542
|
+
}
|
|
543
|
+
const callouts = [...sec.querySelectorAll('.callout')].filter(mine);
|
|
544
|
+
const variant = (c) => c.getAttribute('data-callout') || (c.classList.contains('callout-honest') ? 'honest' : c.classList.contains('callout-strong') ? 'strong' : 'note');
|
|
545
|
+
// what is distinct in this state, so the same thing seen in another state (a sub-tab of the section) counts once
|
|
546
|
+
const keyed = (els, key) => {
|
|
547
|
+
const seenKey = new Map();
|
|
548
|
+
return els.map((e) => {
|
|
549
|
+
const k = key(e);
|
|
550
|
+
const n = seenKey.get(k) ?? 0;
|
|
551
|
+
seenKey.set(k, n + 1);
|
|
552
|
+
return `${k}#${n}`;
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
const hasText = (el) => /[\p{L}\p{N}]/u.test(textOf(el));
|
|
556
|
+
// glossary items a reader sees: a term and a definition, both visible and holding a letter or a digit
|
|
557
|
+
const glossary = [...sec.querySelectorAll('[data-glossary]')].filter(mine).reduce((a, g) => {
|
|
558
|
+
const dts = [...g.querySelectorAll('dt')];
|
|
559
|
+
if (!dts.length) return a + [...g.querySelectorAll(':scope > li')].filter((li) => seen(li) && hasText(li)).length;
|
|
560
|
+
return a + dts.filter((dt) => {
|
|
561
|
+
const dd = dt.nextElementSibling;
|
|
562
|
+
return seen(dt) && hasText(dt) && dd && dd.tagName === 'DD' && seen(dd) && hasText(dd);
|
|
563
|
+
}).length;
|
|
564
|
+
}, 0);
|
|
565
|
+
const plots = [...sec.querySelectorAll('[data-plot]')].filter(mine);
|
|
566
|
+
const bare = [...sec.querySelectorAll('canvas, svg[data-chart]')].filter((c) => mine(c) && !c.closest('[data-plot]'));
|
|
567
|
+
const panel = topicPanel(sec);
|
|
568
|
+
const topic = panel ? chainOf(panel) : { label: '', key: '', head: null };
|
|
569
|
+
// the first table of the section: its rows of three or more cells, each proved by a quantified statement
|
|
570
|
+
const table = [...sec.querySelectorAll('table')].find(mine);
|
|
571
|
+
const tableRows = table ? [...table.querySelectorAll('tbody tr')].filter((r) => seen(r) && r.querySelectorAll('td, th').length >= 3) : [];
|
|
572
|
+
const list = [...sec.querySelectorAll('ul, ol')].find((l) => mine(l) && !l.closest('[data-glossary]') && !l.parentElement.closest('ul, ol'));
|
|
573
|
+
const items = list ? [...list.querySelectorAll(':scope > li')].filter(seen) : [];
|
|
574
|
+
// the rows of the area: those of its first table, or the items of its first list when the list comes first
|
|
575
|
+
const rowEls = list && (!table || !(table.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING)) ? items : tableRows;
|
|
576
|
+
const nonEmpty = (q, sel) => {
|
|
577
|
+
const e = q.querySelector(sel);
|
|
578
|
+
return Boolean(e && seen(e) && /[\p{L}\p{N}]/u.test(textOf(e)));
|
|
579
|
+
};
|
|
580
|
+
const eqEls = [...sec.querySelectorAll('.equation')].filter((e) => mine(e) && equationCaption(e));
|
|
581
|
+
const figEls = [...sec.querySelectorAll('figure')].filter((f) => mine(f) && captioned(f));
|
|
582
|
+
const honestEls = callouts.filter((c) => variant(c) === 'honest');
|
|
583
|
+
const strongEls = callouts.filter((c) => variant(c) === 'strong');
|
|
584
|
+
return {
|
|
585
|
+
title: ((sec.querySelector('h2') || {}).textContent || '').trim().slice(0, 60),
|
|
586
|
+
area: sec.getAttribute('data-area'),
|
|
587
|
+
kind: sec.getAttribute('data-method-kind'),
|
|
588
|
+
topic: topic.label,
|
|
589
|
+
topicKey: topic.key,
|
|
590
|
+
view: topic.head,
|
|
591
|
+
dense: dense.size,
|
|
592
|
+
denseKeys: [...dense],
|
|
593
|
+
equations: eqEls.length,
|
|
594
|
+
equationKeys: keyed(eqEls, (e) => `${equationCaption(e)}|${(e.querySelector('annotation') || {}).textContent || ''}`),
|
|
595
|
+
figures: figEls.length,
|
|
596
|
+
figureKeys: keyed(figEls, (f) => captioned(f)),
|
|
597
|
+
callouts: callouts.length,
|
|
598
|
+
calloutKeys: keyed(callouts, (c) => `${variant(c)}|${textOf(c)}`),
|
|
599
|
+
honest: honestEls.length,
|
|
600
|
+
honestKeys: keyed(honestEls, (c) => `${variant(c)}|${textOf(c)}`),
|
|
601
|
+
strong: strongEls.length,
|
|
602
|
+
strongKeys: keyed(strongEls, (c) => `${variant(c)}|${textOf(c)}`),
|
|
603
|
+
refs: Number(sec.getAttribute('data-refs')) || 0,
|
|
604
|
+
glossary,
|
|
605
|
+
steps: Math.max(0, ...[...sec.querySelectorAll('ol')].filter(mine).map((o) => [...o.querySelectorAll(':scope > li')].filter((li) => seen(li) && hasText(li)).length)),
|
|
606
|
+
tables: [...sec.querySelectorAll('table')].filter((t) => mine(t) && !t.parentElement.closest('table')).length,
|
|
607
|
+
codes: [...sec.querySelectorAll('pre')].filter(mine).length,
|
|
608
|
+
charts: plots.length + bare.length,
|
|
609
|
+
// a control registered with the panel (data-control), not any button or link
|
|
610
|
+
controls: [...sec.querySelectorAll('[data-control]')].filter(mine).length,
|
|
611
|
+
stats: [...sec.querySelectorAll('[data-stat]')].filter((e) => mine(e) && validStat(e)).length,
|
|
612
|
+
rows: rowEls.length,
|
|
613
|
+
rowsNoStat: rowEls.filter((r) => ![...r.querySelectorAll('[data-stat]')].some(validStat)).length,
|
|
614
|
+
items: items.length,
|
|
615
|
+
thinItems: items.filter((li) => wordsOf(li).words < 6).length,
|
|
616
|
+
qa: [...sec.querySelectorAll('[data-qa]')].filter((q) => mine(q) && nonEmpty(q, '[data-question]') && nonEmpty(q, '[data-answer]')).length,
|
|
617
|
+
};
|
|
618
|
+
});
|
|
619
|
+
const selected = outerTabs.find((t) => t.getAttribute('aria-selected') === 'true');
|
|
620
|
+
// The executive view (shell 0.13.0): what the ExecutiveView shows in this state (one sub-tab = one section), for executiveFailures of gate/areas.mjs.
|
|
621
|
+
const execRoots = [...page.querySelectorAll('[data-executive-view]')].filter(visible);
|
|
622
|
+
const executive = (() => {
|
|
623
|
+
if (!execRoots.length) return { count: 0 };
|
|
624
|
+
const root = execRoots[0];
|
|
625
|
+
// A card the carousel's window hides (`hidden` on its item) is out of sight by design: G16 walks the carousel and fails one it
|
|
626
|
+
// cannot reach. Any other card that is not seen is not shown, as before 0.14.0.
|
|
627
|
+
const hiddenByDeck = (el) => {
|
|
628
|
+
const li = el.closest('[data-deck-card]');
|
|
629
|
+
return Boolean(li) && li.hidden && Boolean(li.closest('[data-deck-mode="carousel"]'));
|
|
630
|
+
};
|
|
631
|
+
// The back of a flip card is content the reader gets by turning the card: read every field with the cards turned too (the
|
|
632
|
+
// same `seen` and `innerText` as for any text), and call a field readable when it is seen in either face.
|
|
633
|
+
const turned = new Map();
|
|
634
|
+
withFlipsTurned(() => {
|
|
635
|
+
for (const e of root.querySelectorAll('[data-exec-field], [data-exec-cdef]')) turned.set(e, { text: textOf(e), seen: seen(e) });
|
|
636
|
+
});
|
|
637
|
+
const fullText = (e) => textOf(e) || (turned.get(e) ? turned.get(e).text : '');
|
|
638
|
+
const isSeen = (e) => seen(e) || Boolean(turned.get(e) && turned.get(e).seen);
|
|
639
|
+
const isTok = (v) => {
|
|
640
|
+
const x = String(v || '').trim().toLowerCase();
|
|
641
|
+
return !x || ['none', 'currentcolor', 'transparent', 'inherit', 'initial', 'unset', 'context-fill', 'context-stroke'].includes(x) || x.startsWith('var(--') || x.startsWith('url(');
|
|
642
|
+
};
|
|
643
|
+
const COLOR_PROPS = ['fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color', 'color'];
|
|
644
|
+
const literalsOf = (svg) => {
|
|
645
|
+
const out = new Set();
|
|
646
|
+
const scan = (css) => {
|
|
647
|
+
for (const m of css.matchAll(/(?:^|[;{\s])(fill|stroke|stop-color|flood-color|lighting-color|color)\s*:\s*([^;}]+)/gi)) if (!isTok(m[2])) out.add(`${m[1].toLowerCase()}: ${m[2].trim()}`);
|
|
648
|
+
};
|
|
649
|
+
for (const el of [svg, ...svg.querySelectorAll('*')]) {
|
|
650
|
+
for (const p of COLOR_PROPS) if (el.hasAttribute(p) && !isTok(el.getAttribute(p))) out.add(`${p}: ${el.getAttribute(p).trim()}`);
|
|
651
|
+
if (el.hasAttribute('style')) scan(el.getAttribute('style'));
|
|
652
|
+
if (el.localName === 'style') scan(el.textContent || '');
|
|
653
|
+
}
|
|
654
|
+
return [...out];
|
|
655
|
+
};
|
|
656
|
+
const illustrations = [...root.querySelectorAll('[data-exec-illustration]')].map((fig) => {
|
|
657
|
+
const svg = fig.querySelector('svg');
|
|
658
|
+
const sec = fig.closest('[data-exec-section]');
|
|
659
|
+
const cap = fig.querySelector('figcaption');
|
|
660
|
+
const info = { name: fig.getAttribute('data-exec-illustration'), section: sec ? Number(sec.getAttribute('data-exec-section')) : 0, file: fig.getAttribute('data-exec-file'), state: fig.getAttribute('data-state'), why: textOf(fig.querySelector('.caos-exec-fig-error') || fig).slice(0, 80), svg: Boolean(svg), img: fig.querySelectorAll('img').length, chart: fig.querySelectorAll('[data-chart], [data-plot]').length, caption: Boolean(cap && textOf(cap)), literal: [], outside: [], small: [], labels: 0 };
|
|
661
|
+
if (!svg) return info;
|
|
662
|
+
info.literal = literalsOf(svg);
|
|
663
|
+
const vb = svg.viewBox && svg.viewBox.baseVal;
|
|
664
|
+
const w = svg.getBoundingClientRect().width;
|
|
665
|
+
const scale = vb && vb.width ? w / vb.width : 1;
|
|
666
|
+
for (const t of svg.querySelectorAll('text')) {
|
|
667
|
+
const label = (t.textContent || '').trim();
|
|
668
|
+
if (!label || !visible(t)) continue;
|
|
669
|
+
info.labels += 1;
|
|
670
|
+
if (vb && vb.width) {
|
|
671
|
+
const b = t.getBBox();
|
|
672
|
+
if (b.x < vb.x - 1 || b.y < vb.y - 1 || b.x + b.width > vb.x + vb.width + 1 || b.y + b.height > vb.y + vb.height + 1) info.outside.push(label.slice(0, 40));
|
|
673
|
+
}
|
|
674
|
+
const px = parseFloat(getComputedStyle(t).fontSize) * scale;
|
|
675
|
+
if (px < EXEC_MIN_LABEL_PX) info.small.push(`"${label.slice(0, 30)}" at ${px.toFixed(1)}px`);
|
|
676
|
+
}
|
|
677
|
+
return info;
|
|
678
|
+
});
|
|
679
|
+
const sections = [...root.querySelectorAll('[data-exec-section]')].map((sec) => {
|
|
680
|
+
const key = sec.querySelector('[data-exec-key-message]');
|
|
681
|
+
return {
|
|
682
|
+
n: Number(sec.getAttribute('data-exec-section')),
|
|
683
|
+
title: (sec.getAttribute('aria-label') || '').trim(),
|
|
684
|
+
// the key message line comes first in the section, and a reader can read it
|
|
685
|
+
keyFirst: Boolean(key) && sec.firstElementChild === key,
|
|
686
|
+
keyMessage: key && seen(key) ? textOf(key) : '',
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
// the rows each part of the view shows in this state (the floors), counted once per kind
|
|
690
|
+
const items = {};
|
|
691
|
+
// a card of a carousel counts although the window hides it (G16 proves every card can be reached); a flip card counts whole
|
|
692
|
+
for (const el of root.querySelectorAll('[data-exec-item]')) if (seen(el) || hiddenByDeck(el)) items[el.getAttribute('data-exec-item')] = (items[el.getAttribute('data-exec-item')] ?? 0) + 1;
|
|
693
|
+
const landingEl = root.querySelector('[data-exec-landing]');
|
|
694
|
+
const landing = {
|
|
695
|
+
present: Boolean(landingEl && seen(landingEl)),
|
|
696
|
+
title: landingEl ? textOf(landingEl.querySelector('[data-exec-field="title"]') || landingEl).slice(0, 80) : '',
|
|
697
|
+
statement: landingEl ? textOf(landingEl.querySelector('[data-exec-field="key_statement"]') || landingEl).slice(0, 80) : '',
|
|
698
|
+
};
|
|
699
|
+
// the 4 C's a reader sees in the proposal: the name, the definition and what each means for the proposal
|
|
700
|
+
const cs = [...root.querySelectorAll('[data-exec-c]')]
|
|
701
|
+
.filter((li) => seen(li) || hiddenByDeck(li))
|
|
702
|
+
.map((li) => ({ name: textOf(li.querySelector('[data-exec-cname]') || li), def: fullText(li.querySelector('[data-exec-cdef]') || li), applies: fullText(li.querySelector('[data-exec-field$=".applies"]') || li.querySelector('span') || li) }));
|
|
703
|
+
const links = [...root.querySelectorAll('[data-exec-links] a')].map((a) => ({ kind: a.getAttribute('data-exec-link'), text: textOf(a), href: a.getAttribute('href') }));
|
|
704
|
+
// the sub-tabs of the view: their labels (all of them, shown or not), and the one selected
|
|
705
|
+
const tabEls = [...root.querySelectorAll('[data-exec-tabs] [role="tab"]')];
|
|
706
|
+
const panelOpen = outerPanel ? outerPanel.contains(root) : true;
|
|
707
|
+
const execOpen = selected && (selected.getAttribute('data-tab') || tabLabel(selected)) === 'executive';
|
|
708
|
+
const area = execOpen && outerPanel ? outerPanel : null;
|
|
709
|
+
const docSections = area ? [...area.querySelectorAll('[data-doc-section]')].filter((e) => visible(e) && !root.contains(e)) : [];
|
|
710
|
+
// anything of the open executive view that is neither the ExecutiveView nor the references block of the panel
|
|
711
|
+
const strangers = area
|
|
712
|
+
? [...area.querySelectorAll('p, ul, ol, table, figure, h1, h2, h3, h4, pre, canvas, .callout, .equation, .caos-qa')]
|
|
713
|
+
.filter((e) => visible(e) && !root.contains(e) && !e.closest('[data-doc-references], [data-doc-section]'))
|
|
714
|
+
.map((e) => textOf(e).slice(0, 50) || e.tagName.toLowerCase())
|
|
715
|
+
: [];
|
|
716
|
+
return {
|
|
717
|
+
count: execRoots.length,
|
|
718
|
+
schema: root.getAttribute('data-executive-schema'),
|
|
719
|
+
valid: root.getAttribute('data-executive-valid'),
|
|
720
|
+
errors: root.getAttribute('data-executive-errors') || '',
|
|
721
|
+
inOpenView: panelOpen,
|
|
722
|
+
docSections: docSections.length,
|
|
723
|
+
docSectionTitles: docSections.map((e) => textOf(e.querySelector('h2') || e).slice(0, 40)),
|
|
724
|
+
strangers,
|
|
725
|
+
sections,
|
|
726
|
+
landing,
|
|
727
|
+
items,
|
|
728
|
+
cs,
|
|
729
|
+
links,
|
|
730
|
+
fields: [...root.querySelectorAll('[data-exec-field]')].map((e) => ({ path: e.getAttribute('data-exec-field'), text: fullText(e), readable: isSeen(e) || hiddenByDeck(e) })),
|
|
731
|
+
// the blocks of this state narrower than the document column (an illustration may stay centred inside its figure)
|
|
732
|
+
narrow: [...root.querySelectorAll('.subtabs, .caos-exec-notes-wrap, [data-exec-section], [data-exec-section] > *')]
|
|
733
|
+
.filter((e) => visible(e))
|
|
734
|
+
.map((e) => [`${e.tagName.toLowerCase()}${e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : ''}`, Math.round(e.getBoundingClientRect().width)])
|
|
735
|
+
.filter(([, w]) => w < columnWidth - 2)
|
|
736
|
+
.map(([name, w]) => `${name} ${w}px`),
|
|
737
|
+
column: columnWidth,
|
|
738
|
+
// everything the view prints in this state, for the check that no rubric label or question is on the page
|
|
739
|
+
text: `${textOf(root)} ${[...turned.entries()].filter(([e]) => e.closest('[data-flip-back]')).map(([, v]) => v.text).join(' ')}`,
|
|
740
|
+
tabs: tabEls.map(tabLabel),
|
|
741
|
+
activeTab: tabEls.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
|
|
742
|
+
// each source note: the link it holds (its target and its text) and the text beside the link
|
|
743
|
+
notes: [...root.querySelectorAll('[data-exec-notes] > li')].map((li) => {
|
|
744
|
+
const a = li.querySelector('a');
|
|
745
|
+
const link = a && seen(a) ? textOf(a) : '';
|
|
746
|
+
const note = li.querySelector('[data-exec-source-note]');
|
|
747
|
+
const rest = note ? textOf(note) : textOf(li).replace(link, '').trim();
|
|
748
|
+
return { href: a ? a.getAttribute('href') : null, title: link, note: rest };
|
|
749
|
+
}),
|
|
750
|
+
// the figures this state shows: the notes they point at (the same figure seen in two states counts once)
|
|
751
|
+
cites: [...root.querySelectorAll('.caos-exec-cite a')].map((a) => a.getAttribute('href')),
|
|
752
|
+
illustrations,
|
|
753
|
+
};
|
|
754
|
+
})();
|
|
755
|
+
const inView = (sel) => (outerPanel ? [...outerPanel.querySelectorAll(sel)] : [...page.querySelectorAll(sel)]).filter(seen);
|
|
756
|
+
return {
|
|
757
|
+
ledeWords: lede && visible(lede) ? wordsOf(lede).words : 0,
|
|
758
|
+
ledeWidth: lede && visible(lede) ? Math.round(lede.getBoundingClientRect().width) : 0,
|
|
759
|
+
columnWidth,
|
|
760
|
+
waiver: page.getAttribute('data-floor-waiver'),
|
|
761
|
+
topics,
|
|
762
|
+
views: outerTabs.map((t) => t.getAttribute('data-tab') || tabLabel(t)),
|
|
763
|
+
view: selected ? selected.getAttribute('data-tab') || tabLabel(selected) : null,
|
|
764
|
+
// the executive view holds none anywhere on the page, the part outside the two views included
|
|
765
|
+
viewEquations: [...page.querySelectorAll('.equation')].filter(seen).length,
|
|
766
|
+
viewGlossaries: [...page.querySelectorAll('[data-glossary]')].filter(seen).length,
|
|
767
|
+
sections,
|
|
768
|
+
executive,
|
|
769
|
+
figures: inView('figure').map(captioned).filter(Boolean),
|
|
770
|
+
equations: inView('.equation').map(equationCaption).filter(Boolean),
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
|
|
775
|
+
G.scrolls = () => {
|
|
776
|
+
const out = [];
|
|
777
|
+
const de = document.documentElement;
|
|
778
|
+
const docH = Math.max(de.scrollHeight, document.body.scrollHeight);
|
|
779
|
+
if (docH > window.innerHeight + 1) {
|
|
780
|
+
const y = window.scrollY;
|
|
781
|
+
winScroll(0, 0);
|
|
782
|
+
winScroll(0, docH);
|
|
783
|
+
const moved = window.scrollY > 0;
|
|
784
|
+
winScroll(0, y);
|
|
785
|
+
if (!moved) out.push(`the document is ${docH}px tall in a ${window.innerHeight}px viewport and does not scroll`);
|
|
786
|
+
}
|
|
787
|
+
for (const el of document.querySelectorAll('main, .page, .page-body, [data-instrument], [data-rail], .tabpanel')) {
|
|
788
|
+
if (!visible(el)) continue;
|
|
789
|
+
const s = getComputedStyle(el);
|
|
790
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 4) {
|
|
791
|
+
const t = el.scrollTop;
|
|
792
|
+
setScroll(el, el.scrollLeft, 0);
|
|
793
|
+
setScroll(el, el.scrollLeft, el.scrollHeight);
|
|
794
|
+
const moved = el.scrollTop > 0;
|
|
795
|
+
setScroll(el, el.scrollLeft, t);
|
|
796
|
+
if (!moved) out.push(`${describe(el)} holds ${el.scrollHeight - el.clientHeight}px more content and does not scroll`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const setScroll = (el, left, top) => el.scrollTo({ left, top, behavior: 'instant' });
|
|
803
|
+
const winScroll = (x, y) => window.scrollTo({ left: x, top: y, behavior: 'instant' });
|
|
804
|
+
|
|
805
|
+
/** Brings an element into view the way a reader can: by scrolling the containers a reader can scroll (overflow auto
|
|
806
|
+
* or scroll) and the document, never an overflow-hidden ancestor (scrollIntoView would, and hide a real cut). */
|
|
807
|
+
const bring = (el) => {
|
|
808
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
809
|
+
const s = getComputedStyle(a);
|
|
810
|
+
const ar = a.getBoundingClientRect();
|
|
811
|
+
let r = el.getBoundingClientRect();
|
|
812
|
+
// An element larger than the container is aligned by its start: aligning its end pushed its start out of
|
|
813
|
+
// view, and the probe then landed on whatever covers the container (known shell defect 18). An element that
|
|
814
|
+
// fits is scrolled until its end shows with a margin, never past its start: with less slack than the margin,
|
|
815
|
+
// the margin pushed its start out and the probe reported the element it had moved (known shell defect 25).
|
|
816
|
+
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && a.scrollWidth > a.clientWidth) {
|
|
817
|
+
if (r.left < ar.left || r.width > ar.width) setScroll(a, a.scrollLeft - (ar.left - r.left), a.scrollTop);
|
|
818
|
+
else if (r.right > ar.right) setScroll(a, a.scrollLeft + Math.min(r.right - ar.right + 8, r.left - ar.left), a.scrollTop);
|
|
819
|
+
}
|
|
820
|
+
r = el.getBoundingClientRect();
|
|
821
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight) {
|
|
822
|
+
if (r.top < ar.top || r.height > ar.height) setScroll(a, a.scrollLeft, a.scrollTop - (ar.top - r.top));
|
|
823
|
+
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + Math.min(r.bottom - ar.bottom + 8, r.top - ar.top));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
// the document is scrolled so the element shows below a header stuck at the top of the viewport (0.8.0: the
|
|
827
|
+
// sticky header sticks in a scrolling document since known shell defect 26 was fixed)
|
|
828
|
+
const cover = el.closest('header, [role="banner"]') ? 0 : stuckTop();
|
|
829
|
+
const r = el.getBoundingClientRect();
|
|
830
|
+
if (r.top < cover || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - (cover + (window.innerHeight - cover) / 2));
|
|
831
|
+
const r2 = el.getBoundingClientRect();
|
|
832
|
+
if (r2.left < 0 || r2.right > window.innerWidth) winScroll(window.scrollX + r2.left + r2.width / 2 - window.innerWidth / 2, window.scrollY);
|
|
833
|
+
};
|
|
834
|
+
G.bringEl = (el) => bring(el);
|
|
835
|
+
G.bring = (selector) => {
|
|
836
|
+
const el = document.querySelector(selector);
|
|
837
|
+
if (el) bring(el);
|
|
838
|
+
return Boolean(el);
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
/** The viewport cut by every ancestor that clips its content (overflow other than visible). */
|
|
842
|
+
/** The bottom of a page header stuck to the top of the viewport (sticky or fixed), or 0: what it covers is not in
|
|
843
|
+
* view for a reader. */
|
|
844
|
+
function stuckTop() {
|
|
845
|
+
let bottom = 0;
|
|
846
|
+
for (const h of document.querySelectorAll('header, [role="banner"]')) {
|
|
847
|
+
if (h.parentElement && h.parentElement.closest('header, [role="banner"], [role="dialog"]')) continue;
|
|
848
|
+
const s = getComputedStyle(h);
|
|
849
|
+
if (s.position !== 'sticky' && s.position !== 'fixed') continue;
|
|
850
|
+
const r = h.getBoundingClientRect();
|
|
851
|
+
if (r.top <= 1 && r.bottom > 0 && r.width >= window.innerWidth * 0.5) bottom = Math.max(bottom, r.bottom);
|
|
852
|
+
}
|
|
853
|
+
return bottom;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const visibleArea = (el) => {
|
|
857
|
+
const area = { left: 0, top: el.closest('header, [role="banner"]') ? 0 : stuckTop(), right: window.innerWidth, bottom: window.innerHeight };
|
|
858
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
859
|
+
const s = getComputedStyle(a);
|
|
860
|
+
if (s.overflowX === 'visible' && s.overflowY === 'visible') continue;
|
|
861
|
+
const ar = a.getBoundingClientRect();
|
|
862
|
+
if (s.overflowX !== 'visible') {
|
|
863
|
+
area.left = Math.max(area.left, ar.left);
|
|
864
|
+
area.right = Math.min(area.right, ar.right);
|
|
865
|
+
}
|
|
866
|
+
if (s.overflowY !== 'visible') {
|
|
867
|
+
area.top = Math.max(area.top, ar.top);
|
|
868
|
+
area.bottom = Math.min(area.bottom, ar.bottom);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return area;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
/** Every visible control can be brought into the viewport and receives the pointer at the centre of its visible
|
|
875
|
+
* part (G5). */
|
|
876
|
+
G.reach = () => {
|
|
877
|
+
const sel = 'a[href], button, select, input:not([type="hidden"]), textarea, [role="tab"], [role="button"], [tabindex]:not([tabindex="-1"])';
|
|
878
|
+
const saved = new Map();
|
|
879
|
+
for (const e of document.querySelectorAll('*')) if (e.scrollLeft || e.scrollTop) saved.set(e, [e.scrollLeft, e.scrollTop]);
|
|
880
|
+
const sx = window.scrollX;
|
|
881
|
+
const sy = window.scrollY;
|
|
882
|
+
const out = [];
|
|
883
|
+
let checked = 0;
|
|
884
|
+
const els = [...document.querySelectorAll(sel)].filter((el) => !el.disabled && !el.closest('[aria-hidden="true"]') && visible(el));
|
|
885
|
+
for (const el of els) {
|
|
886
|
+
bring(el);
|
|
887
|
+
// A wrapped inline element (a citation link over two lines) has its box centre between its line fragments; the
|
|
888
|
+
// pointer lands on a fragment, so the largest one is tested.
|
|
889
|
+
const frags = [...el.getClientRects()].filter((q) => q.width >= 1 && q.height >= 1);
|
|
890
|
+
const r = frags.length ? frags.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
|
|
891
|
+
// a 1x1 box is the visually hidden pattern (the skip link, screen-reader text): not a pointer target
|
|
892
|
+
if (r.width <= 1 || r.height <= 1) continue;
|
|
893
|
+
checked += 1;
|
|
894
|
+
// The area the element can show in: the viewport cut by every ancestor that clips (known shell defect 18; an
|
|
895
|
+
// element that fits the window but not its scroll container was reported as never brought into view).
|
|
896
|
+
const area = visibleArea(el);
|
|
897
|
+
const fits = r.width <= area.right - area.left + 1 && r.height <= area.bottom - area.top + 1;
|
|
898
|
+
if (fits && (r.left < area.left - 1 || r.right > area.right + 1 || r.top < area.top - 1 || r.bottom > area.bottom + 1)) {
|
|
899
|
+
out.push(`${describe(el)} cannot be brought into the viewport (at ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
// the pointer goes to the centre of the part that shows
|
|
903
|
+
const vx0 = Math.max(r.left, area.left);
|
|
904
|
+
const vx1 = Math.min(r.right, area.right);
|
|
905
|
+
const vy0 = Math.max(r.top, area.top);
|
|
906
|
+
const vy1 = Math.min(r.bottom, area.bottom);
|
|
907
|
+
if (vx1 - vx0 < 1 || vy1 - vy0 < 1) {
|
|
908
|
+
out.push(`${describe(el)} cannot be brought into the viewport (at ${Math.round(r.left)},${Math.round(r.top)})`);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const px = Math.min(Math.max((vx0 + vx1) / 2, 0), window.innerWidth - 1);
|
|
912
|
+
const py = Math.min(Math.max((vy0 + vy1) / 2, 0), window.innerHeight - 1);
|
|
913
|
+
const hit = document.elementFromPoint(px, py);
|
|
914
|
+
const ok =
|
|
915
|
+
hit && (hit === el || el.contains(hit) || (el.labels && [...el.labels].some((l) => l.contains(hit))) || (hit.tagName === 'LABEL' && hit.control === el));
|
|
916
|
+
if (!ok) out.push(`${describe(el)} is covered by ${hit ? describe(hit) : 'nothing (outside the page)'}`);
|
|
917
|
+
}
|
|
918
|
+
for (const e of document.querySelectorAll('*')) {
|
|
919
|
+
const s = saved.get(e);
|
|
920
|
+
if (s) setScroll(e, s[0], s[1]);
|
|
921
|
+
else if (e.scrollLeft || e.scrollTop) setScroll(e, 0, 0);
|
|
922
|
+
}
|
|
923
|
+
winScroll(sx, sy);
|
|
924
|
+
return { checked, unreachable: out.slice(0, 8), unreachableCount: out.length };
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Runs `fn` with every closed flip card turned (and no transition, so the faces are settled), then turns them back. The gate
|
|
929
|
+
* reads a back with the same measures it uses for any text; a back the card can never show stays unreadable.
|
|
930
|
+
*/
|
|
931
|
+
const withFlipsTurned = (fn) => {
|
|
932
|
+
const cards = [...document.querySelectorAll('[data-flip-card][data-flip-open="false"]')];
|
|
933
|
+
if (cards.length === 0) return fn();
|
|
934
|
+
const st = document.createElement('style');
|
|
935
|
+
st.textContent = '.caos-flip, .caos-flip * { transition: none !important; }';
|
|
936
|
+
document.head.append(st);
|
|
937
|
+
for (const c of cards) c.setAttribute('data-flip-open', 'true');
|
|
938
|
+
try {
|
|
939
|
+
return fn();
|
|
940
|
+
} finally {
|
|
941
|
+
for (const c of cards) c.setAttribute('data-flip-open', 'false');
|
|
942
|
+
st.remove();
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
// ---- G16: flip cards and card decks (shell 0.14.0)
|
|
946
|
+
const inTree = (el) => {
|
|
947
|
+
// reachable by assistive technology and by the keyboard: rendered, and no ancestor hides it or makes it inert
|
|
948
|
+
if (!el.checkVisibility({ checkVisibilityCSS: true })) return false;
|
|
949
|
+
for (let e = el; e; e = e.parentElement) if (e.getAttribute('aria-hidden') === 'true' || e.hasAttribute('inert') || e.hidden) return false;
|
|
950
|
+
return true;
|
|
951
|
+
};
|
|
952
|
+
/** A control a keyboard or a pointer can reach: rendered, no `inert` ancestor. `aria-hidden` does not take a control out of the tab order. */
|
|
953
|
+
const reachable = (el) => {
|
|
954
|
+
if (!el.checkVisibility({ checkVisibilityCSS: true })) return false;
|
|
955
|
+
for (let e = el; e; e = e.parentElement) if (e.hasAttribute('inert')) return false;
|
|
956
|
+
return true;
|
|
957
|
+
};
|
|
958
|
+
const countWords = (t) => String(t || '').split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
959
|
+
/** What the flip cards and the card decks of this state are: their words, their buttons, what the hidden face exposes, where a deck ends. */
|
|
960
|
+
G.cards = () => {
|
|
961
|
+
const squash = (e) => (e ? (e.textContent || '').replace(/\s+/g, ' ').trim() : '');
|
|
962
|
+
const vw = window.innerWidth;
|
|
963
|
+
const cards = [...document.querySelectorAll('[data-flip-card]')].map((card, index) => {
|
|
964
|
+
const open = card.getAttribute('data-flip-open') === 'true';
|
|
965
|
+
const front = card.querySelector('.caos-flip-front');
|
|
966
|
+
const back = card.querySelector('[data-flip-back]');
|
|
967
|
+
const hiddenFace = open ? front : back;
|
|
968
|
+
const toggles = [...card.querySelectorAll('[data-flip-toggle]')];
|
|
969
|
+
const owned = toggles.length > 0 && toggles.every((t) => { const id = t.getAttribute('aria-controls'); return id && document.getElementById(id) && t.hasAttribute('aria-expanded'); });
|
|
970
|
+
const exposed = hiddenFace ? hiddenFace.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])') : [];
|
|
971
|
+
return {
|
|
972
|
+
index,
|
|
973
|
+
label: squash(card.querySelector('[data-flip-title]')).slice(0, 40) || `card ${index + 1}`,
|
|
974
|
+
titleWords: countWords(squash(card.querySelector('[data-flip-title]'))),
|
|
975
|
+
backWords: countWords(squash(card.querySelector('[data-flip-content]'))),
|
|
976
|
+
open,
|
|
977
|
+
shown: inTree(card) || card.getClientRects().length > 0,
|
|
978
|
+
buttons: toggles.length > 0 && toggles.every((t) => t.tagName === 'BUTTON'),
|
|
979
|
+
wired: owned,
|
|
980
|
+
hiddenInTree: Boolean(hiddenFace) && inTree(hiddenFace),
|
|
981
|
+
hiddenFocusable: [...exposed].some(reachable),
|
|
982
|
+
contentShown: (() => { const c = card.querySelector('[data-flip-content]'); if (!c || !c.checkVisibility({ checkVisibilityCSS: true })) return false; const r = c.getBoundingClientRect(); return r.height > 0 && r.width > 0; })(),
|
|
983
|
+
deck: Array.from(document.querySelectorAll('[data-card-deck]')).findIndex((d) => d.contains(card)),
|
|
984
|
+
};
|
|
985
|
+
});
|
|
986
|
+
const decks = [...document.querySelectorAll('[data-card-deck]')].map((deck, index) => {
|
|
987
|
+
const track = deck.querySelector('[data-deck-track]');
|
|
988
|
+
const box = deck.getBoundingClientRect();
|
|
989
|
+
const lis = [...deck.querySelectorAll('[data-deck-card]')];
|
|
990
|
+
const shownCards = lis.filter((li) => li.checkVisibility({ checkVisibilityCSS: true }));
|
|
991
|
+
const outside = shownCards.filter((li) => { const r = li.getBoundingClientRect(); return r.right > box.right + 1 || r.left < box.left - 1; });
|
|
992
|
+
const next = deck.querySelector('[data-deck-next]');
|
|
993
|
+
const prev = deck.querySelector('[data-deck-prev]');
|
|
994
|
+
return {
|
|
995
|
+
index,
|
|
996
|
+
label: deck.getAttribute('aria-label') || (track && track.getAttribute('aria-label')) || `deck ${index + 1}`,
|
|
997
|
+
mode: deck.getAttribute('data-deck-mode'),
|
|
998
|
+
total: lis.length,
|
|
999
|
+
visible: lis.map((li, i) => (li.checkVisibility({ checkVisibilityCSS: true }) ? i : -1)).filter((i) => i >= 0),
|
|
1000
|
+
flip: deck.hasAttribute('data-deck-flip'),
|
|
1001
|
+
controls: Boolean(next && prev) && next.checkVisibility({ checkVisibilityCSS: true }) && prev.checkVisibility({ checkVisibilityCSS: true }),
|
|
1002
|
+
atEnd: next ? next.getAttribute('aria-disabled') === 'true' : true,
|
|
1003
|
+
atStart: prev ? prev.getAttribute('aria-disabled') === 'true' : true,
|
|
1004
|
+
sideways: Math.max(deck.scrollWidth - deck.clientWidth, track ? track.scrollWidth - track.clientWidth : 0) > 1 || box.right > vw + 1 || outside.length > 0 || (track ? track.getBoundingClientRect().right > box.right + 1 : false),
|
|
1005
|
+
outside: outside.length,
|
|
1006
|
+
docW: document.documentElement.scrollWidth,
|
|
1007
|
+
vw,
|
|
1008
|
+
};
|
|
1009
|
+
});
|
|
1010
|
+
return { lang: (document.documentElement.lang || 'en').slice(0, 2) === 'es' ? 'es' : 'en', cards, decks };
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
// ---- G17: animated illustrations (shell 0.14.0)
|
|
1014
|
+
/** The SVG drawings of the page (the executive illustrations and the figures): their markup, and what still moves in them. */
|
|
1015
|
+
G.animations = () => {
|
|
1016
|
+
const items = [...document.querySelectorAll('figure svg, [data-exec-illustration] svg')].filter((v, i, a) => a.indexOf(v) === i).map((svg, i) => {
|
|
1017
|
+
const fig = svg.closest('[data-exec-illustration]');
|
|
1018
|
+
const smil = svg.querySelector('animate, animateTransform, animateMotion, set');
|
|
1019
|
+
const css = document.getAnimations().filter((a) => a.effect && a.effect.target && svg.contains(a.effect.target) && a.playState === 'running').length;
|
|
1020
|
+
return { name: fig ? fig.getAttribute('data-exec-illustration') : `figure ${i + 1}`, svg: svg.outerHTML, runningCss: css, runningSmil: smil ? !svg.animationsPaused() : false, smil: Boolean(smil) };
|
|
1021
|
+
});
|
|
1022
|
+
return { items };
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
/** The documentation figures drawn by an `img` of an SVG file: their sources (the runner fetches and analyses them). */
|
|
1026
|
+
G.figureImages = () => [...document.querySelectorAll('figure img')].filter((i) => /\.svg(\?|#|$)/i.test(i.getAttribute('src') || '')).map((i, k) => ({ name: `figure ${k + 1}`, src: i.currentSrc || i.src }));
|
|
1027
|
+
|
|
1028
|
+
// ---- G18: Focus mode for the App (shell 0.14.0)
|
|
1029
|
+
/** The App or Focus mode as the reader meets it: which one, the case and the group, the URL, the page and the rail. */
|
|
1030
|
+
G.focusFacts = () => {
|
|
1031
|
+
const shell = document.querySelector('[data-focus-shell]');
|
|
1032
|
+
const stage = document.querySelector('[data-case-workbench]');
|
|
1033
|
+
const rail = document.querySelector('.caos-focus-rail');
|
|
1034
|
+
const lists = [...document.querySelectorAll('[data-focus-shell] [role="tablist"]')].filter(visible);
|
|
1035
|
+
const rows = lists.map((t) => new Set([...t.querySelectorAll('[role="tab"]')].map((b) => Math.round(b.getBoundingClientRect().top))).size);
|
|
1036
|
+
const panel = stage ? stage.querySelector('[role="tabpanel"]') : null;
|
|
1037
|
+
const st = document.querySelector('.caos-focus-stage');
|
|
1038
|
+
const box = st ? st.getBoundingClientRect() : null;
|
|
1039
|
+
return {
|
|
1040
|
+
focus: Boolean(shell),
|
|
1041
|
+
app: Boolean(document.querySelector('[data-workbench]')),
|
|
1042
|
+
caseId: stage ? stage.getAttribute('data-case') : null,
|
|
1043
|
+
group: shell ? (stage ? stage.getAttribute('data-focus-group') : null) : panel ? panel.getAttribute('data-panel') : null,
|
|
1044
|
+
search: location.search,
|
|
1045
|
+
subview: (() => { const t = document.querySelector('[data-case-workbench] .subtab.active'); return t ? t.getAttribute('data-tab') : null; })(),
|
|
1046
|
+
nestedMain: document.querySelectorAll('main').length > 1 || Boolean(document.querySelector('main main')),
|
|
1047
|
+
vw: window.innerWidth,
|
|
1048
|
+
vh: window.innerHeight,
|
|
1049
|
+
docW: document.documentElement.scrollWidth,
|
|
1050
|
+
docH: document.documentElement.scrollHeight,
|
|
1051
|
+
rail: rail ? { scroll: rail.scrollHeight, client: rail.clientHeight } : null,
|
|
1052
|
+
tabRows: rows,
|
|
1053
|
+
share: box ? (box.width * box.height) / (window.innerWidth * window.innerHeight) : 0,
|
|
1054
|
+
exitInside: (() => { const e = document.querySelector('[data-focus-exit]'); if (!e) return null; const r = e.getBoundingClientRect(); return r.width > 0 && r.right <= window.innerWidth + 1 && r.bottom <= window.innerHeight + 1; })(),
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
/** Animation frames and DOM mutations over a window at rest (G8). */
|
|
1059
|
+
G.idle = (ms) =>
|
|
1060
|
+
new Promise((done) => {
|
|
1061
|
+
let mutations = 0;
|
|
1062
|
+
const sample = [];
|
|
1063
|
+
const mo = new MutationObserver((recs) => {
|
|
1064
|
+
mutations += recs.length;
|
|
1065
|
+
for (const r of recs) {
|
|
1066
|
+
if (sample.length >= 3) break;
|
|
1067
|
+
const t = r.target.nodeType === 1 ? r.target : r.target.parentElement;
|
|
1068
|
+
sample.push(`${r.type}${r.attributeName ? ` ${r.attributeName}` : ''} on ${describe(t)}`);
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
1072
|
+
G.raf = 0;
|
|
1073
|
+
G.counting = true;
|
|
1074
|
+
setTimeout(() => {
|
|
1075
|
+
G.counting = false;
|
|
1076
|
+
mo.disconnect();
|
|
1077
|
+
done({ raf: G.raf, mutations, sample, ms });
|
|
1078
|
+
}, ms);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
/** The visible tab lists outside the header, footer and dialogs, each with the list whose panel holds it (G7). */
|
|
1082
|
+
G.tablists = () =>
|
|
1083
|
+
[...document.querySelectorAll('[role="tablist"]')]
|
|
1084
|
+
.filter((tl) => visible(tl) && !tl.closest('header, footer, [role="dialog"]'))
|
|
1085
|
+
.map((tl) => {
|
|
1086
|
+
const panel = tl.parentElement ? tl.parentElement.closest('[role="tabpanel"]') : null;
|
|
1087
|
+
let owner = null;
|
|
1088
|
+
if (panel) {
|
|
1089
|
+
const id = panel.getAttribute('aria-labelledby');
|
|
1090
|
+
const tab = id ? document.getElementById(id) : null;
|
|
1091
|
+
const otl = tab ? tab.closest('[role="tablist"]') : null;
|
|
1092
|
+
owner = otl ? path(otl) : 'unknown';
|
|
1093
|
+
}
|
|
1094
|
+
const tabs = [...tl.querySelectorAll('[role="tab"]')].filter((t) => t.closest('[role="tablist"]') === tl && visible(t));
|
|
1095
|
+
return {
|
|
1096
|
+
key: path(tl),
|
|
1097
|
+
owner,
|
|
1098
|
+
name: tl.getAttribute('aria-label') || '',
|
|
1099
|
+
source: Boolean(tl.closest('.cs-source-tabs') || tl.classList.contains('cs-source-tabs')),
|
|
1100
|
+
inSection: Boolean(tl.closest('[data-doc-section]')),
|
|
1101
|
+
tabs: tabs.map((t) => (t.getAttribute('data-tab') || (t.textContent || '').trim()).slice(0, 40)),
|
|
1102
|
+
active: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
|
|
1103
|
+
};
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
/** The cases the case control offers right now (G9). */
|
|
1107
|
+
G.cases = () => {
|
|
1108
|
+
const ctl = [...document.querySelectorAll('[data-control="case"]')].filter(visible);
|
|
1109
|
+
const ids = [];
|
|
1110
|
+
for (const c of ctl) {
|
|
1111
|
+
if (c.tagName === 'SELECT') {
|
|
1112
|
+
for (const o of c.options) if (!o.disabled && o.value) ids.push(o.value);
|
|
1113
|
+
} else for (const b of c.querySelectorAll('[data-case]')) if (!b.disabled) ids.push(b.getAttribute('data-case'));
|
|
1114
|
+
}
|
|
1115
|
+
return { kind: ctl.length === 0 ? 'none' : ctl[0].tagName === 'SELECT' ? 'select' : 'chips', ids: [...new Set(ids)] };
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
/** The registered controls of the instrument, except the case control (G9). */
|
|
1119
|
+
G.controls = () =>
|
|
1120
|
+
[...document.querySelectorAll('[data-control]')]
|
|
1121
|
+
.filter((c) => visible(c) && c.getAttribute('data-control') !== 'case')
|
|
1122
|
+
// a control that cannot act in this selection is not moved (known shell defect 20): every input in it disabled
|
|
1123
|
+
.filter((c) => {
|
|
1124
|
+
const inputs = c.matches('input, select, button, textarea') ? [c] : [...c.querySelectorAll('input, select, button, textarea')];
|
|
1125
|
+
return inputs.length === 0 || inputs.some((e) => !e.disabled);
|
|
1126
|
+
})
|
|
1127
|
+
.map((c) => {
|
|
1128
|
+
let kind = 'unknown';
|
|
1129
|
+
if (c.tagName === 'SELECT' || c.querySelector('select')) kind = 'select';
|
|
1130
|
+
else if ((c.tagName === 'INPUT' && c.type === 'range') || c.querySelector('input[type="range"]')) kind = 'range';
|
|
1131
|
+
else if (c.querySelector('input[type="checkbox"], input[type="radio"]') || (c.tagName === 'INPUT' && (c.type === 'checkbox' || c.type === 'radio'))) kind = 'check';
|
|
1132
|
+
else if (c.querySelector('input[type="number"]') || (c.tagName === 'INPUT' && c.type === 'number')) kind = 'number';
|
|
1133
|
+
else if (c.querySelector('button') || c.tagName === 'BUTTON') kind = 'buttons';
|
|
1134
|
+
return { id: c.getAttribute('data-control'), path: path(c), kind };
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
/** The visible keyed views: every plot and readout with the selection key it shows (G9). */
|
|
1138
|
+
G.views = () =>
|
|
1139
|
+
[...document.querySelectorAll('[data-plot], [data-readout]')]
|
|
1140
|
+
.filter(visible)
|
|
1141
|
+
.map((v) => ({ label: describe(v), key: v.getAttribute('data-state-key') || '', stale: v.getAttribute('data-stale') === '1' }));
|
|
1142
|
+
|
|
1143
|
+
// ---- 0.8.0 measures ----------------------------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
/** The part of the page the ancestors of an element let it show when they CUT their content (overflow hidden or
|
|
1146
|
+
* clip). A scroll container does not cut (a reader scrolls it), and neither does the viewport; and what lies beyond
|
|
1147
|
+
* the first scroll container on an axis is a matter of scroll position, so the walk stops there on that axis (0.9.1:
|
|
1148
|
+
* the contained `.app-shell`, viewport-high with its overflow hidden, read every documentation figure below the fold
|
|
1149
|
+
* as "outside the drawing"). */
|
|
1150
|
+
const cutArea = (el) => {
|
|
1151
|
+
const area = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
|
|
1152
|
+
let x = true;
|
|
1153
|
+
let y = true;
|
|
1154
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement && (x || y); a = a.parentElement) {
|
|
1155
|
+
const s = getComputedStyle(a);
|
|
1156
|
+
const ar = a.getBoundingClientRect();
|
|
1157
|
+
if (x && (s.overflowX === 'hidden' || s.overflowX === 'clip')) {
|
|
1158
|
+
area.left = Math.max(area.left, ar.left);
|
|
1159
|
+
area.right = Math.min(area.right, ar.right);
|
|
1160
|
+
}
|
|
1161
|
+
if (y && (s.overflowY === 'hidden' || s.overflowY === 'clip')) {
|
|
1162
|
+
area.top = Math.max(area.top, ar.top);
|
|
1163
|
+
area.bottom = Math.min(area.bottom, ar.bottom);
|
|
1164
|
+
}
|
|
1165
|
+
if (s.overflowX === 'auto' || s.overflowX === 'scroll') x = false;
|
|
1166
|
+
if (s.overflowY === 'auto' || s.overflowY === 'scroll') y = false;
|
|
1167
|
+
}
|
|
1168
|
+
return area;
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
/** The boxes a text element occupies on screen: one per line (tspan) when it has several. */
|
|
1172
|
+
const textBoxes = (t) => {
|
|
1173
|
+
const spans = [...t.querySelectorAll('tspan')].filter((s) => (s.textContent || '').trim());
|
|
1174
|
+
const parts = spans.length ? spans : [t];
|
|
1175
|
+
return parts
|
|
1176
|
+
.map((p) => p.getBoundingClientRect())
|
|
1177
|
+
.filter((r) => r.width > 0.5 && r.height > 0.5)
|
|
1178
|
+
.map((r) => ({ left: r.left, top: r.top, right: r.right, bottom: r.bottom }));
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* G10, text in drawings: in every drawing a reader looks at (a declared chart, any SVG in the instrument, any figure
|
|
1183
|
+
* in a document) each label lies inside the drawing and inside the part of it its containers show, and no two
|
|
1184
|
+
* labels overlap. Measured on the rendered glyph boxes, so a label sized for one font and drawn in a wider one fails
|
|
1185
|
+
* (the wide-font pass renders every page in a wider fallback font). A halo drawn as a second identical label at the
|
|
1186
|
+
* same place is not an overlap. A drawing that crosses labels on purpose declares `data-text-overlap="allowed"`.
|
|
1187
|
+
*/
|
|
1188
|
+
G.drawingText = () => {
|
|
1189
|
+
const out = [];
|
|
1190
|
+
const svgs = new Set([
|
|
1191
|
+
...document.querySelectorAll('svg[data-chart], [data-instrument] svg, .prose svg, figure svg'),
|
|
1192
|
+
]);
|
|
1193
|
+
for (const svg of svgs) {
|
|
1194
|
+
if (svg.parentElement && svg.parentElement.closest('svg')) continue;
|
|
1195
|
+
if (svg.classList.contains('lucide') || !visible(svg)) continue;
|
|
1196
|
+
const sr = svg.getBoundingClientRect();
|
|
1197
|
+
if (sr.width < 48 || sr.height < 24) continue;
|
|
1198
|
+
const area = cutArea(svg);
|
|
1199
|
+
const box = { left: Math.max(sr.left, area.left), top: Math.max(sr.top, area.top), right: Math.min(sr.right, area.right), bottom: Math.min(sr.bottom, area.bottom) };
|
|
1200
|
+
const name = describe(svg.closest('[data-plot]') || svg);
|
|
1201
|
+
const texts = [...svg.querySelectorAll('text')].filter((t) => (t.textContent || '').trim() && visible(t));
|
|
1202
|
+
const items = [];
|
|
1203
|
+
for (const t of texts) {
|
|
1204
|
+
for (const b of textBoxes(t)) {
|
|
1205
|
+
// a label outside the visible part of the drawing: cut, or drawn where no one sees it
|
|
1206
|
+
if (b.top >= box.bottom || b.bottom <= box.top) {
|
|
1207
|
+
out.push(`${name}: the label "${(t.textContent || '').trim().slice(0, 40)}" lies outside the drawing`);
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
const cutX = Math.max(0, box.left - b.left) + Math.max(0, b.right - box.right);
|
|
1211
|
+
const cutY = Math.max(0, box.top - b.top) + Math.max(0, b.bottom - box.bottom);
|
|
1212
|
+
if (cutX > 1.5 || cutY > 2.5) out.push(`${name}: the label "${(t.textContent || '').trim().slice(0, 40)}" is cut by ${Math.round(Math.max(cutX, cutY))}px at the drawing's edge`);
|
|
1213
|
+
items.push({ t, b, text: (t.textContent || '').trim() });
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (svg.closest('[data-text-overlap="allowed"]') || items.length > 600) continue;
|
|
1217
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
1218
|
+
for (let j = i + 1; j < items.length; j += 1) {
|
|
1219
|
+
const a = items[i];
|
|
1220
|
+
const c = items[j];
|
|
1221
|
+
if (a.t === c.t || a.t.contains(c.t) || c.t.contains(a.t)) continue;
|
|
1222
|
+
const w = Math.min(a.b.right, c.b.right) - Math.max(a.b.left, c.b.left);
|
|
1223
|
+
const h = Math.min(a.b.bottom, c.b.bottom) - Math.max(a.b.top, c.b.top);
|
|
1224
|
+
if (w <= 1.5 || h <= 2.5) continue;
|
|
1225
|
+
// a halo: the same label drawn twice at the same place
|
|
1226
|
+
if (a.text === c.text && Math.abs(a.b.left - c.b.left) < 1.5 && Math.abs(a.b.top - c.b.top) < 1.5) continue;
|
|
1227
|
+
out.push(`${name}: the labels "${a.text.slice(0, 30)}" and "${c.text.slice(0, 30)}" overlap by ${Math.round(w)}x${Math.round(h)}px`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return [...new Set(out)];
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* G11, numbers in Spanish: a page in Spanish writes decimals with a comma (conventions/languages.md). Reads every
|
|
1236
|
+
* visible text, SVG labels included, outside code, formulas, versions and anything marked translate="no"; a number
|
|
1237
|
+
* with a decimal point fails, a Spanish thousands group (27.345) passes. A canvas is not read.
|
|
1238
|
+
*/
|
|
1239
|
+
G.decimals = () => {
|
|
1240
|
+
const out = [];
|
|
1241
|
+
const skip = 'code, pre, kbd, samp, .katex, [data-version], [data-build], [translate="no"], script, style, [aria-hidden="true"]';
|
|
1242
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
1243
|
+
// an exponent belongs to the number: 2.7e-08 on a Spanish page writes its decimal with a point too
|
|
1244
|
+
const re = /(?<![\w.,/:@#])(\d+)\.(\d+)(?:[eE][-+]?\d+)?(?![\w/@]|\.\d)/g;
|
|
1245
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1246
|
+
const text = n.nodeValue || '';
|
|
1247
|
+
if (!/\d\.\d/.test(text)) continue;
|
|
1248
|
+
const el = n.parentElement;
|
|
1249
|
+
if (!el || el.closest(skip) || !shown(el)) continue;
|
|
1250
|
+
for (const m of text.matchAll(re)) {
|
|
1251
|
+
// a thousands group of the Spanish locale: 1 to 3 digits, not starting with 0, then groups of exactly 3
|
|
1252
|
+
const token = text.slice(m.index).match(/^\d+(?:\.\d+)+/)?.[0] ?? m[0];
|
|
1253
|
+
if (/^[1-9]\d{0,2}(\.\d{3})+$/.test(token)) continue;
|
|
1254
|
+
out.push(`"${text.trim().slice(0, 60)}" writes ${m[0]} with a decimal point (${describe(el)})`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return [...new Set(out)].slice(0, 12);
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
/** The scroll container of an element: the nearest ancestor that scrolls vertically, or the document. */
|
|
1261
|
+
const scroller = (el) => {
|
|
1262
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
1263
|
+
const s = getComputedStyle(a);
|
|
1264
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight + 1) return a;
|
|
1265
|
+
}
|
|
1266
|
+
return null;
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* G12, the vertical sub-tab list stays in view: on a vertical SubTabs whose panel is taller than its scroll box,
|
|
1271
|
+
* scroll to the end of the panel and require the active sub-tab to be visible (known shell defect 24). Restores the
|
|
1272
|
+
* position it changed.
|
|
1273
|
+
*/
|
|
1274
|
+
G.sticky = () => {
|
|
1275
|
+
const out = [];
|
|
1276
|
+
for (const st of document.querySelectorAll('.subtabs-vertical')) {
|
|
1277
|
+
if (!visible(st)) continue;
|
|
1278
|
+
const list = st.querySelector(':scope > .subtablist');
|
|
1279
|
+
const active = list && list.querySelector('.subtab.active');
|
|
1280
|
+
if (!list || !active || getComputedStyle(list).flexDirection !== 'column') continue;
|
|
1281
|
+
const sc = scroller(st);
|
|
1282
|
+
const viewH = sc ? sc.clientHeight : window.innerHeight;
|
|
1283
|
+
const rect = st.getBoundingClientRect();
|
|
1284
|
+
if (rect.height <= viewH + 4) continue;
|
|
1285
|
+
const before = sc ? sc.scrollTop : window.scrollY;
|
|
1286
|
+
// scroll so the end of the sub-tab block is at the bottom of the scroll box
|
|
1287
|
+
const delta = rect.bottom - (sc ? sc.getBoundingClientRect().bottom : window.innerHeight);
|
|
1288
|
+
if (sc) setScroll(sc, sc.scrollLeft, sc.scrollTop + delta);
|
|
1289
|
+
else winScroll(window.scrollX, window.scrollY + delta);
|
|
1290
|
+
const a = active.getBoundingClientRect();
|
|
1291
|
+
const area = visibleArea(active);
|
|
1292
|
+
const headerBottom = (() => {
|
|
1293
|
+
const h = document.querySelector('.site-header');
|
|
1294
|
+
if (!h || sc) return 0;
|
|
1295
|
+
const hs = getComputedStyle(h);
|
|
1296
|
+
return hs.position === 'sticky' || hs.position === 'fixed' ? h.getBoundingClientRect().bottom : 0;
|
|
1297
|
+
})();
|
|
1298
|
+
const top = Math.max(area.top, headerBottom);
|
|
1299
|
+
if (a.bottom <= top + 2 || a.top >= area.bottom - 2) out.push(`${describe(list)}: at the end of a ${Math.round(rect.height)}px section the active sub-tab "${(active.textContent || '').trim().slice(0, 30)}" is out of view (the list scrolled away)`);
|
|
1300
|
+
if (sc) setScroll(sc, sc.scrollLeft, before);
|
|
1301
|
+
else winScroll(window.scrollX, before);
|
|
1302
|
+
}
|
|
1303
|
+
return out;
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
const parseColor = (c) => {
|
|
1307
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || '');
|
|
1308
|
+
if (!m) return null;
|
|
1309
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
1310
|
+
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
|
|
1311
|
+
};
|
|
1312
|
+
const over = (top, bottom) => ({
|
|
1313
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
1314
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
1315
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
1316
|
+
a: 1,
|
|
1317
|
+
});
|
|
1318
|
+
const lum = (c) => {
|
|
1319
|
+
const ch = (v) => {
|
|
1320
|
+
const x = v / 255;
|
|
1321
|
+
return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
|
1322
|
+
};
|
|
1323
|
+
return 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b);
|
|
1324
|
+
};
|
|
1325
|
+
/** The colour behind an element: its ancestors' backgrounds composited, or null when an image or gradient is there. */
|
|
1326
|
+
const backdrop = (el) => {
|
|
1327
|
+
const layers = [];
|
|
1328
|
+
for (let a = el; a; a = a.parentElement) {
|
|
1329
|
+
const s = getComputedStyle(a);
|
|
1330
|
+
if (s.backgroundImage && s.backgroundImage !== 'none') return null;
|
|
1331
|
+
const c = parseColor(s.backgroundColor);
|
|
1332
|
+
if (c && c.a > 0) {
|
|
1333
|
+
layers.push(c);
|
|
1334
|
+
if (c.a >= 1) break;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
let base = { r: 255, g: 255, b: 255, a: 1 };
|
|
1338
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) base = over(layers[i], base);
|
|
1339
|
+
return base;
|
|
1340
|
+
};
|
|
1341
|
+
/** The colour an SVG text element is painted on: the last filled shape painted before it whose box holds its centre. */
|
|
1342
|
+
const svgBackdrop = (text, page) => {
|
|
1343
|
+
const svg = text.closest('svg');
|
|
1344
|
+
if (!svg) return page;
|
|
1345
|
+
const r = text.getBoundingClientRect();
|
|
1346
|
+
const cx = r.left + r.width / 2;
|
|
1347
|
+
const cy = r.top + r.height / 2;
|
|
1348
|
+
let found = null;
|
|
1349
|
+
for (const shape of svg.querySelectorAll('rect, circle, ellipse, polygon, path')) {
|
|
1350
|
+
if (!(shape.compareDocumentPosition(text) & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
|
|
1351
|
+
const ss = getComputedStyle(shape);
|
|
1352
|
+
if (ss.display === 'none' || ss.visibility === 'hidden') continue;
|
|
1353
|
+
const fill = parseColor(ss.fill);
|
|
1354
|
+
if (!fill || fill.a === 0) continue;
|
|
1355
|
+
const b = shape.getBoundingClientRect();
|
|
1356
|
+
if (cx < b.left || cx > b.right || cy < b.top || cy > b.bottom) continue;
|
|
1357
|
+
const a = fill.a * Number(ss.fillOpacity || 1) * opacityOf(shape);
|
|
1358
|
+
if (a <= 0.05) continue;
|
|
1359
|
+
found = { ...fill, a };
|
|
1360
|
+
}
|
|
1361
|
+
return found ? over(found, page) : page;
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const opacityOf = (el) => {
|
|
1365
|
+
let o = 1;
|
|
1366
|
+
for (let a = el; a; a = a.parentElement) o *= Number(getComputedStyle(a).opacity || 1);
|
|
1367
|
+
return o;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* G13, text contrast (WCAG 2.2 AA, 1.4.3): every visible text a reader can read, SVG labels included, against the
|
|
1372
|
+
* colour behind it: 4.5:1, or 3:1 for large text (24px, or 18.66px bold). Disabled controls are exempt, as WCAG
|
|
1373
|
+
* exempts inactive components; text over an image or a gradient is not judged.
|
|
1374
|
+
*/
|
|
1375
|
+
G.contrast = () => {
|
|
1376
|
+
const out = [];
|
|
1377
|
+
const seen = new Set();
|
|
1378
|
+
// a colour transition still running reads as a colour between two states (a chip just selected read 3.89:1 at
|
|
1379
|
+
// 87% of its way to white): finish every finite transition first; looping animations are left alone
|
|
1380
|
+
if (document.getAnimations) {
|
|
1381
|
+
for (const a of document.getAnimations()) {
|
|
1382
|
+
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : Infinity;
|
|
1383
|
+
if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition && Number.isFinite(Number(end))) a.finish();
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
1387
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1388
|
+
if (!(n.nodeValue || '').trim()) continue;
|
|
1389
|
+
const el = n.parentElement;
|
|
1390
|
+
if (!el || seen.has(el)) continue;
|
|
1391
|
+
seen.add(el);
|
|
1392
|
+
if (el.closest('script, style, [aria-hidden="true"], .sr-only, .skip-link, :disabled, [aria-disabled="true"], .katex-mathml') || !visible(el)) continue;
|
|
1393
|
+
const s = getComputedStyle(el);
|
|
1394
|
+
const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
1395
|
+
const fg = parseColor(isSvg ? s.fill : s.color);
|
|
1396
|
+
if (!fg) continue;
|
|
1397
|
+
const host = isSvg ? el.closest('svg')?.parentElement ?? el : el;
|
|
1398
|
+
const page = backdrop(host);
|
|
1399
|
+
if (!page) continue;
|
|
1400
|
+
// SVG text sits on what the drawing painted under it (a label on a bar): the last filled shape before it whose
|
|
1401
|
+
// box holds the text's centre, composited over the page (0.9.1: a marker in the page colour on a red bar read
|
|
1402
|
+
// 1.06:1 against the page)
|
|
1403
|
+
const bg = isSvg ? svgBackdrop(el, page) : page;
|
|
1404
|
+
const alpha = fg.a * opacityOf(el);
|
|
1405
|
+
const shownColor = over({ ...fg, a: alpha }, bg);
|
|
1406
|
+
const l1 = lum(shownColor);
|
|
1407
|
+
const l2 = lum(bg);
|
|
1408
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
1409
|
+
const px = parseFloat(s.fontSize) || 16;
|
|
1410
|
+
const bold = Number(s.fontWeight) >= 700;
|
|
1411
|
+
const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5;
|
|
1412
|
+
if (ratio + 1e-6 < need) out.push(`${describe(el)}: contrast ${ratio.toFixed(2)}:1, below ${need}:1 (${isSvg ? s.fill : s.color} on rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)}))`);
|
|
1413
|
+
}
|
|
1414
|
+
return out.slice(0, 10);
|
|
1415
|
+
};
|
|
1416
|
+
}
|