@fasl-work/caos-app-shell 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gate/inpage.mjs CHANGED
@@ -31,6 +31,105 @@ export function installLib() {
31
31
  return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) !== 0;
32
32
  };
33
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
+ };
34
133
  const path = (el) => {
35
134
  const parts = [];
36
135
  let e = el;
@@ -200,6 +299,8 @@ export function installLib() {
200
299
  let instrument = null;
201
300
  const surfaces = [];
202
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
203
304
  if (inst && visible(inst)) {
204
305
  const ir = inst.getBoundingClientRect();
205
306
  const cx0 = Math.max(0, ir.left);
@@ -209,6 +310,32 @@ export function installLib() {
209
310
  instrument = { x: cx0, y: cy0, w: Math.max(0, cx1 - cx0), h: Math.max(0, cy1 - cy0) };
210
311
  const ctx = inst.querySelector('.caos-cw-context');
211
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;
212
339
  for (const el of inst.querySelectorAll('canvas, svg, img, table, video')) {
213
340
  const tag = el.tagName.toLowerCase();
214
341
  if (tag === 'svg' && el.parentElement && el.parentElement.closest('svg')) continue;
@@ -254,6 +381,18 @@ export function installLib() {
254
381
  ? { case: wb.getAttribute('data-case'), key: wb.getAttribute('data-state-key'), replayOnly: wb.getAttribute('data-replay-only') === '1' }
255
382
  : null;
256
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
+
257
396
  let offCentre = null;
258
397
  const proseBody = document.querySelector('.page-body:not(.wide)');
259
398
  if (proseBody && visible(proseBody)) {
@@ -278,19 +417,217 @@ export function installLib() {
278
417
  instrument,
279
418
  surfaces,
280
419
  prose,
420
+ unavailable,
421
+ drawn,
281
422
  workbench,
282
423
  offCentre,
283
424
  pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
284
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,
285
430
  uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
286
431
  // charts whose axis gives a tick the label of the tick before it (UPlotChart declares data-ticks-repeat)
287
432
  repeatedTicks: [...document.querySelectorAll('[data-ticks-repeat]')]
288
433
  .filter((el) => visible(el) && Number(el.getAttribute('data-ticks-repeat')) > 0)
289
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 })),
290
439
  sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
291
440
  };
292
441
  };
293
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
+
294
631
  /** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
295
632
  G.scrolls = () => {
296
633
  const out = [];
@@ -486,6 +823,7 @@ export function installLib() {
486
823
  owner,
487
824
  name: tl.getAttribute('aria-label') || '',
488
825
  source: Boolean(tl.closest('.cs-source-tabs') || tl.classList.contains('cs-source-tabs')),
826
+ inSection: Boolean(tl.closest('[data-doc-section]')),
489
827
  tabs: tabs.map((t) => (t.getAttribute('data-tab') || (t.textContent || '').trim()).slice(0, 40)),
490
828
  active: tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
491
829
  };
package/gate/run.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  // wide-font pass: Verdana, else DejaVu Sans, the Linux runner's font)
22
22
  // G11 Spanish numbers a page in Spanish writes no decimal point (canvas text excepted)
23
23
  // G12 sticky lists a vertical sub-tab list is still in view at the end of a long section
24
- // G13 contrast every visible text at WCAG AA against the colour behind it (primary size, both themes)
24
+ // G13 contrast every visible text at WCAG AA against the colour behind it (primary size, both themes; first language, and every language on a documentation route)
25
25
  // G14 captures index gate-output/index.html shows every capture by route and mode, the failures first
26
26
  // plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
27
27
  // references).
@@ -31,6 +31,7 @@ import { join } from 'node:path';
31
31
  import { decodePng, paintedBox, unionArea } from './png.mjs';
32
32
  import { servePages } from './serve.mjs';
33
33
  import { installLib } from './inpage.mjs';
34
+ import { DOC_ROUTES, docFloorFailures } from './areas.mjs';
34
35
 
35
36
  export const DEFAULTS = {
36
37
  sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
@@ -42,6 +43,7 @@ export const DEFAULTS = {
42
43
  textFloor: 400,
43
44
  instrumentMin: 0.5,
44
45
  stageFillMin: 0.3,
46
+ stageFloor: 140,
45
47
  viewportMinWidth: 1280,
46
48
  out: 'gate-output',
47
49
  maxShots: 40,
@@ -104,6 +106,7 @@ export async function runGate(o) {
104
106
  };
105
107
 
106
108
  const failures = [];
109
+ const waivers = []; // ADR-0017 2.1: every waiver a documentation page declares, with its verdict
107
110
  // The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
108
111
  // is visible before it fails).
109
112
  const minima = { drawnShare: null, stageFill: null };
@@ -254,12 +257,13 @@ export async function runGate(o) {
254
257
  const mode = `${w}x${h} ${theme} ${lang}`;
255
258
  const primary = size === primarySize && theme === themes[0] && lang === langs[0];
256
259
  const idleHere = theme === themes[0] && lang === langs[0];
257
- const contrast = size === primarySize && lang === langs[0];
258
260
  const ctx = await openContext(size, theme, lang);
259
261
  const { page, sink } = await newPage(ctx);
260
262
  for (const route of routes) {
261
263
  const isWorkbench = workbenchRoutes.has(route);
262
264
  const at = { route, mode };
265
+ // G13 runs in the first language; the documentation routes (the prose a reader reads) in every language
266
+ const contrast = size === primarySize && (lang === langs[0] || DOC_ROUTES.includes(route));
263
267
  const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
264
268
  const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
265
269
  if (status !== 200) {
@@ -279,7 +283,9 @@ export async function runGate(o) {
279
283
  if (!String(id.lang ?? '').startsWith(lang)) fail('G2', at, `asked for ${lang} through ${LANG_STORAGE_KEY}; <html lang> is ${id.lang}`);
280
284
  if (route !== '/' && id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
281
285
 
282
- const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
286
+ // ADR-0017 2.1: the documentation floors are measured once per language, at the primary size and first theme
287
+ const docFloors = size === primarySize && theme === themes[0] && DOC_ROUTES.includes(route);
288
+ const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0, doc: docFloors ? [] : null };
283
289
  await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
284
290
 
285
291
  if (primary && route !== '/') {
@@ -300,7 +306,8 @@ export async function runGate(o) {
300
306
  sink.splice(0);
301
307
  }
302
308
 
303
- await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState, contrast });
309
+ const leaves = await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState, contrast });
310
+ if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
304
311
 
305
312
  if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState, contrast });
306
313
 
@@ -410,11 +417,19 @@ export async function runGate(o) {
410
417
  if (f.truncatedCount) fail('G5', where, `${f.truncatedCount} truncated text(s) without the full text in a title: ${f.truncated.join('; ')}`);
411
418
  for (const c of f.clipped) fail('G5', where, c);
412
419
  if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
413
- if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography dump(s); references belong at the end of each section`);
420
+ if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography outside the end-of-page references block (a .reference-list); give each DocSection its refs and let the page list them once`);
421
+ if (f.referencesNotLast) fail('ADR-0017.4', where, `${f.referencesNotLast} references block(s) with content after them; references must be the last block of the page or tab`);
422
+ if (f.refsDeclaredNoBlock) fail('ADR-0017.4', where, `${f.refsDeclaredNoBlock} section(s) cite references the page never lists (no references block in their page or tab)`);
414
423
  if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
424
+ if (s.walkState?.doc) {
425
+ const d = await page.evaluate(() => window.__caosGate.docPage());
426
+ if (d) s.walkState.doc.push(d);
427
+ }
415
428
  for (const r of f.repeatedTicks ?? []) {
416
429
  fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
417
430
  }
431
+ // a filling card whose note leaves its drawing under the floor (CAOS_APP_SHELL#95); below 1280 px the document scrolls
432
+ if (big) for (const c of f.fillStages ?? []) if (c.h < opt.stageFloor) fail('G6', where, `the drawing of ${c.label} keeps ${Math.round(c.h)} px under its note (floor ${opt.stageFloor} px)`);
418
433
  for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
419
434
  if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
420
435
  if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
@@ -452,6 +467,13 @@ export async function runGate(o) {
452
467
 
453
468
  async function paintedChecks(page, f, where, big) {
454
469
  if (f.prose) return; // the context write-up is prose, measured as a document, not as a drawing
470
+ if (f.surfaces.length === 0 && f.unavailable) {
471
+ // a declared absence a reader can see and read (ADR-0080 decision 3): nothing to draw, every other check still ran
472
+ if (f.drawn > 0) fail('G6', where, `a drawing is present but not visible beside a data-state="unavailable" note (${f.drawn} drawing element(s) rendered in the instrument, collapsed, hidden or too small to see): draw it, or remove it`);
473
+ else if (f.unavailable === 'unexplained') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but gives no reason (the text a reader sees needs at least 12 letters or digits in two words or more, saying why the data is absent)');
474
+ else if (f.unavailable === 'unseen') fail('G6', where, 'the instrument declares data-state="unavailable" and draws nothing, but no reader can see the note (it needs a visible box of at least 40 x 12 px inside the instrument, not transparent, not clipped, not off-screen)');
475
+ return;
476
+ }
455
477
  if (f.surfaces.length === 0) {
456
478
  fail('G6', where, 'nothing is drawn in the instrument (no canvas, svg drawing, image or table)');
457
479
  return;
@@ -527,7 +549,9 @@ export async function runGate(o) {
527
549
  }
528
550
  }
529
551
 
552
+ /** Walks every tab of every list owned by `trail`; returns the number of leaf topics found (a tab with no list inside it is one; a list inside a documentation section adds none). */
530
553
  async function walkTabs(page, sink, where, trail, handled, s) {
554
+ let leaves = 0;
531
555
  const lists = (await page.evaluate(() => window.__caosGate.tablists())).filter((l) => !handled.has(l.key) && !l.source);
532
556
  const parentKey = trail.length ? trail[trail.length - 1].key : null;
533
557
  const mine = lists.filter((l) => (parentKey ? l.owner === parentKey : l.owner === null));
@@ -546,13 +570,16 @@ export async function runGate(o) {
546
570
  await tab.click({ timeout: 5000 });
547
571
  } catch (e) {
548
572
  fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
573
+ if (!tl.inSection) leaves += 1;
549
574
  continue;
550
575
  }
551
576
  const why = await settle(page);
552
577
  if (why) fail('G7', at, `never settled: ${why}`);
553
578
  await processState(page, sink, at, { ...s, first: false, idle: s.idle });
554
579
  }
555
- await walkTabs(page, sink, where, here, new Set(handled), s);
580
+ const inner = await walkTabs(page, sink, where, here, new Set(handled), s);
581
+ // the tabs of a list inside a documentation section are visited, but they are parts of that section, not topics
582
+ if (!tl.inSection) leaves += inner === 0 ? 1 : inner;
556
583
  }
557
584
  if (tl.active >= 0 && tl.tabs.length > 1) {
558
585
  await page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(tl.active).click({ timeout: 5000 }).catch(() => undefined);
@@ -560,6 +587,14 @@ export async function runGate(o) {
560
587
  sink.splice(0);
561
588
  }
562
589
  }
590
+ return leaves;
591
+ }
592
+
593
+ /** ADR-0017 2.1: applies the area table (gate/areas.mjs) to what a documentation page showed in one language. */
594
+ function docResult(at, lang, states, expectedTopics) {
595
+ const r = docFloorFailures({ route: at.route, lang, states, expectedTopics });
596
+ for (const f of r.failures) fail('ADR-0017.2', { ...at, trail: f.trail }, f.message);
597
+ for (const w of r.waivers) waivers.push({ route: at.route, lang, ...w });
563
598
  }
564
599
 
565
600
  async function walkCases(page, sink, at, s) {
@@ -671,6 +706,7 @@ export async function runGate(o) {
671
706
  wideFontSizes: opt.wideFont === false ? '' : opt.wideFontSizes,
672
707
  states,
673
708
  minima,
709
+ waivers,
674
710
  checks,
675
711
  captures: captures.length,
676
712
  failures,
@@ -708,8 +744,10 @@ export function captureIndex(report, captures) {
708
744
  '.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}figure{margin:0;background:#fff;border:1px solid #d0d7de;padding:6px}' +
709
745
  'img{width:100%;height:auto;display:block}figcaption{font-size:11px;color:#57606a;margin-top:4px}';
710
746
  const head = `caos-shell-gate: ${esc(report.expectBrand)} at ${esc(report.base)}: ${report.ok ? 'OK' : `${report.failures.length} failure(s)`} in ${report.states} states, ${captures.length} captures`;
747
+ const wrows = (report.waivers ?? []).map((w) => `<tr><td>${esc(w.route)}</td><td>${esc(w.lang)}</td><td>${esc(w.floor)}</td><td>${esc(w.reason)}</td><td>${w.valid ? 'accepted' : 'refused'}</td></tr>`).join('');
748
+ const waiverTable = wrows ? `<h2>Waivers (${report.waivers.length})</h2><table><tr><td>route</td><td>language</td><td>floor</td><td>reason</td><td>verdict</td></tr>${wrows}</table>` : '';
711
749
  const table = rows ? `<table><tr><td>check</td><td>route</td><td>mode</td><td>trail</td><td>message</td></tr>${rows}</table>` : '';
712
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${head}</title><style>${style}</style></head><body><h1>${head}</h1>${table}${sections}</body></html>\n`;
750
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${head}</title><style>${style}</style></head><body><h1>${head}</h1>${table}${waiverTable}${sections}</body></html>\n`;
713
751
  }
714
752
 
715
753
  function slug(route) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fasl-work/caos-app-shell",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Shared web-app shell, workbench primitives, content primitives, design system and measured gate for the CAOS / Faena public apps (ADR-0016, ADR-0017, ADR-0071).",
5
5
  "license": "MIT",
6
6
  "author": "Felipe Santibáñez-Leal",
@@ -50,7 +50,8 @@
50
50
  "prepare": "npm run build",
51
51
  "prepublishOnly": "npm run build",
52
52
  "prebuild": "node scripts/reserved-classes.mjs",
53
- "test:gate": "node --test test/gate-selftest.mjs"
53
+ "test:gate": "node --test test/gate-selftest.mjs",
54
+ "check:sdd": "python scripts/check_sdd.py ."
54
55
  },
55
56
  "peerDependencies": {
56
57
  "katex": "^0.16.0",
@@ -35,6 +35,8 @@
35
35
  "caos-cw-context",
36
36
  "caos-cw-panel",
37
37
  "caos-cw-panel-head",
38
+ "caos-doc-reference-list",
39
+ "caos-doc-references",
38
40
  "caos-doc-section",
39
41
  "caos-field",
40
42
  "caos-focus-actions",
@@ -52,6 +54,8 @@
52
54
  "caos-gauge-track",
53
55
  "caos-gauge-value",
54
56
  "caos-gauge-zone",
57
+ "caos-glossary",
58
+ "caos-glossary-item",
55
59
  "caos-knob",
56
60
  "caos-knob-head",
57
61
  "caos-knob-value",
@@ -69,6 +73,9 @@
69
73
  "caos-prov-published",
70
74
  "caos-prov-real",
71
75
  "caos-prov-synthetic",
76
+ "caos-qa",
77
+ "caos-qa-answer",
78
+ "caos-qa-question",
72
79
  "caos-readout",
73
80
  "caos-readout-head",
74
81
  "caos-readout-row",
@@ -79,6 +86,8 @@
79
86
  "caos-stale",
80
87
  "caos-stale-note",
81
88
  "caos-stale-overlay",
89
+ "caos-stat-source",
90
+ "caos-stat-value",
82
91
  "caos-surface",
83
92
  "caos-surface-actions",
84
93
  "caos-surface-body",
@@ -268,6 +277,8 @@
268
277
  "caos-cw-context",
269
278
  "caos-cw-panel",
270
279
  "caos-cw-panel-head",
280
+ "caos-doc-reference-list",
281
+ "caos-doc-references",
271
282
  "caos-doc-section",
272
283
  "caos-field",
273
284
  "caos-focus-actions",
@@ -285,6 +296,8 @@
285
296
  "caos-gauge-track",
286
297
  "caos-gauge-value",
287
298
  "caos-gauge-zone",
299
+ "caos-glossary",
300
+ "caos-glossary-item",
288
301
  "caos-knob",
289
302
  "caos-knob-head",
290
303
  "caos-knob-value",
@@ -302,6 +315,9 @@
302
315
  "caos-prov-published",
303
316
  "caos-prov-real",
304
317
  "caos-prov-synthetic",
318
+ "caos-qa",
319
+ "caos-qa-answer",
320
+ "caos-qa-question",
305
321
  "caos-readout",
306
322
  "caos-readout-head",
307
323
  "caos-readout-row",
@@ -312,6 +328,8 @@
312
328
  "caos-stale",
313
329
  "caos-stale-note",
314
330
  "caos-stale-overlay",
331
+ "caos-stat-source",
332
+ "caos-stat-value",
315
333
  "caos-surface",
316
334
  "caos-surface-actions",
317
335
  "caos-surface-body",