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