@fasl-work/caos-app-shell 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -14
- package/bin/caos-shell-gate.mjs +5 -2
- package/dist/chart.d.ts +30 -5
- package/dist/chart.js +86 -25
- package/dist/{chunk-LOSSTLPP.js → chunk-ZLJCB7DD.js} +43 -4
- package/dist/index.d.ts +248 -111
- package/dist/index.js +308 -22
- package/dist/{tokens-CSV0MOgR.d.ts → tokens-DkyEXX5g.d.ts} +21 -4
- package/gate/inpage.mjs +257 -8
- package/gate/run.mjs +129 -9
- package/package.json +1 -1
- package/reserved-classes.json +239 -3
- package/styles.css +445 -367
package/gate/inpage.mjs
CHANGED
|
@@ -154,10 +154,10 @@ export function installLib() {
|
|
|
154
154
|
if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
|
|
155
155
|
// The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
|
|
156
156
|
// ancestors that CONTAINS it. Any label nearby is not enough: the rail's own aria-label would excuse everything.
|
|
157
|
-
const full = (el.textContent || '').trim().replace(
|
|
157
|
+
const full = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
158
158
|
let named = false;
|
|
159
159
|
for (let i = 0, t = el; i < 3 && t; i += 1, t = t.parentElement) {
|
|
160
|
-
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(
|
|
160
|
+
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/\s+/g, ' ');
|
|
161
161
|
if (full && label.includes(full)) {
|
|
162
162
|
named = true;
|
|
163
163
|
break;
|
|
@@ -276,6 +276,10 @@ export function installLib() {
|
|
|
276
276
|
pageText: pageBody ? (pageBody.innerText || '').trim().length : 0,
|
|
277
277
|
referenceLists: [...document.querySelectorAll('.reference-list')].filter(visible).length,
|
|
278
278
|
uncaptioned: [...document.querySelectorAll('.equation')].filter((e) => visible(e) && !e.querySelector('.equation-caption')).length,
|
|
279
|
+
// charts whose axis gives a tick the label of the tick before it (UPlotChart declares data-ticks-repeat)
|
|
280
|
+
repeatedTicks: [...document.querySelectorAll('[data-ticks-repeat]')]
|
|
281
|
+
.filter((el) => visible(el) && Number(el.getAttribute('data-ticks-repeat')) > 0)
|
|
282
|
+
.map((el) => ({ label: describe(el.closest('[data-plot]') || el), n: Number(el.getAttribute('data-ticks-repeat')) })),
|
|
279
283
|
sectionsWithoutRefs: [...document.querySelectorAll('[data-no-refs-reason="missing"]')].filter(visible).length,
|
|
280
284
|
};
|
|
281
285
|
};
|
|
@@ -319,19 +323,24 @@ export function installLib() {
|
|
|
319
323
|
const ar = a.getBoundingClientRect();
|
|
320
324
|
let r = el.getBoundingClientRect();
|
|
321
325
|
// An element larger than the container is aligned by its start: aligning its end pushed its start out of
|
|
322
|
-
// view, and the probe then landed on whatever covers the container (known shell defect 18).
|
|
326
|
+
// view, and the probe then landed on whatever covers the container (known shell defect 18). An element that
|
|
327
|
+
// fits is scrolled until its end shows with a margin, never past its start: with less slack than the margin,
|
|
328
|
+
// the margin pushed its start out and the probe reported the element it had moved (known shell defect 25).
|
|
323
329
|
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && a.scrollWidth > a.clientWidth) {
|
|
324
330
|
if (r.left < ar.left || r.width > ar.width) setScroll(a, a.scrollLeft - (ar.left - r.left), a.scrollTop);
|
|
325
|
-
else if (r.right > ar.right) setScroll(a, a.scrollLeft + (r.right - ar.right + 8), a.scrollTop);
|
|
331
|
+
else if (r.right > ar.right) setScroll(a, a.scrollLeft + Math.min(r.right - ar.right + 8, r.left - ar.left), a.scrollTop);
|
|
326
332
|
}
|
|
327
333
|
r = el.getBoundingClientRect();
|
|
328
334
|
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight) {
|
|
329
335
|
if (r.top < ar.top || r.height > ar.height) setScroll(a, a.scrollLeft, a.scrollTop - (ar.top - r.top));
|
|
330
|
-
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + (r.bottom - ar.bottom + 8));
|
|
336
|
+
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + Math.min(r.bottom - ar.bottom + 8, r.top - ar.top));
|
|
331
337
|
}
|
|
332
338
|
}
|
|
339
|
+
// the document is scrolled so the element shows below a header stuck at the top of the viewport (0.8.0: the
|
|
340
|
+
// sticky header sticks in a scrolling document since known shell defect 26 was fixed)
|
|
341
|
+
const cover = el.closest('header, [role="banner"]') ? 0 : stuckTop();
|
|
333
342
|
const r = el.getBoundingClientRect();
|
|
334
|
-
if (r.top <
|
|
343
|
+
if (r.top < cover || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - (cover + (window.innerHeight - cover) / 2));
|
|
335
344
|
const r2 = el.getBoundingClientRect();
|
|
336
345
|
if (r2.left < 0 || r2.right > window.innerWidth) winScroll(window.scrollX + r2.left + r2.width / 2 - window.innerWidth / 2, window.scrollY);
|
|
337
346
|
};
|
|
@@ -343,8 +352,22 @@ export function installLib() {
|
|
|
343
352
|
};
|
|
344
353
|
|
|
345
354
|
/** The viewport cut by every ancestor that clips its content (overflow other than visible). */
|
|
355
|
+
/** The bottom of a page header stuck to the top of the viewport (sticky or fixed), or 0: what it covers is not in
|
|
356
|
+
* view for a reader. */
|
|
357
|
+
function stuckTop() {
|
|
358
|
+
let bottom = 0;
|
|
359
|
+
for (const h of document.querySelectorAll('header, [role="banner"]')) {
|
|
360
|
+
if (h.parentElement && h.parentElement.closest('header, [role="banner"], [role="dialog"]')) continue;
|
|
361
|
+
const s = getComputedStyle(h);
|
|
362
|
+
if (s.position !== 'sticky' && s.position !== 'fixed') continue;
|
|
363
|
+
const r = h.getBoundingClientRect();
|
|
364
|
+
if (r.top <= 1 && r.bottom > 0 && r.width >= window.innerWidth * 0.5) bottom = Math.max(bottom, r.bottom);
|
|
365
|
+
}
|
|
366
|
+
return bottom;
|
|
367
|
+
}
|
|
368
|
+
|
|
346
369
|
const visibleArea = (el) => {
|
|
347
|
-
const area = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
370
|
+
const area = { left: 0, top: el.closest('header, [role="banner"]') ? 0 : stuckTop(), right: window.innerWidth, bottom: window.innerHeight };
|
|
348
371
|
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
349
372
|
const s = getComputedStyle(a);
|
|
350
373
|
if (s.overflowX === 'visible' && s.overflowY === 'visible') continue;
|
|
@@ -378,7 +401,8 @@ export function installLib() {
|
|
|
378
401
|
// pointer lands on a fragment, so the largest one is tested.
|
|
379
402
|
const frags = [...el.getClientRects()].filter((q) => q.width >= 1 && q.height >= 1);
|
|
380
403
|
const r = frags.length ? frags.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
|
|
381
|
-
|
|
404
|
+
// a 1x1 box is the visually hidden pattern (the skip link, screen-reader text): not a pointer target
|
|
405
|
+
if (r.width <= 1 || r.height <= 1) continue;
|
|
382
406
|
checked += 1;
|
|
383
407
|
// The area the element can show in: the viewport cut by every ancestor that clips (known shell defect 18; an
|
|
384
408
|
// element that fits the window but not its scroll container was reported as never brought into view).
|
|
@@ -476,6 +500,11 @@ export function installLib() {
|
|
|
476
500
|
G.controls = () =>
|
|
477
501
|
[...document.querySelectorAll('[data-control]')]
|
|
478
502
|
.filter((c) => visible(c) && c.getAttribute('data-control') !== 'case')
|
|
503
|
+
// a control that cannot act in this selection is not moved (known shell defect 20): every input in it disabled
|
|
504
|
+
.filter((c) => {
|
|
505
|
+
const inputs = c.matches('input, select, button, textarea') ? [c] : [...c.querySelectorAll('input, select, button, textarea')];
|
|
506
|
+
return inputs.length === 0 || inputs.some((e) => !e.disabled);
|
|
507
|
+
})
|
|
479
508
|
.map((c) => {
|
|
480
509
|
let kind = 'unknown';
|
|
481
510
|
if (c.tagName === 'SELECT' || c.querySelector('select')) kind = 'select';
|
|
@@ -491,4 +520,224 @@ export function installLib() {
|
|
|
491
520
|
[...document.querySelectorAll('[data-plot], [data-readout]')]
|
|
492
521
|
.filter(visible)
|
|
493
522
|
.map((v) => ({ label: describe(v), key: v.getAttribute('data-state-key') || '', stale: v.getAttribute('data-stale') === '1' }));
|
|
523
|
+
|
|
524
|
+
// ---- 0.8.0 measures ----------------------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
/** The boxes a text element occupies on screen: one per line (tspan) when it has several. */
|
|
527
|
+
const textBoxes = (t) => {
|
|
528
|
+
const spans = [...t.querySelectorAll('tspan')].filter((s) => (s.textContent || '').trim());
|
|
529
|
+
const parts = spans.length ? spans : [t];
|
|
530
|
+
return parts
|
|
531
|
+
.map((p) => p.getBoundingClientRect())
|
|
532
|
+
.filter((r) => r.width > 0.5 && r.height > 0.5)
|
|
533
|
+
.map((r) => ({ left: r.left, top: r.top, right: r.right, bottom: r.bottom }));
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* G10, text in drawings: in every drawing a reader looks at (a declared chart, any SVG in the instrument, any figure
|
|
538
|
+
* in a document) each label lies inside the drawing and inside the part of it its containers show, and no two
|
|
539
|
+
* labels overlap. Measured on the rendered glyph boxes, so a label sized for one font and drawn in a wider one fails
|
|
540
|
+
* (the wide-font pass renders every page in a wider fallback font). A halo drawn as a second identical label at the
|
|
541
|
+
* same place is not an overlap. A drawing that crosses labels on purpose declares `data-text-overlap="allowed"`.
|
|
542
|
+
*/
|
|
543
|
+
G.drawingText = () => {
|
|
544
|
+
const out = [];
|
|
545
|
+
const svgs = new Set([
|
|
546
|
+
...document.querySelectorAll('svg[data-chart], [data-instrument] svg, .prose svg, figure svg'),
|
|
547
|
+
]);
|
|
548
|
+
for (const svg of svgs) {
|
|
549
|
+
if (svg.parentElement && svg.parentElement.closest('svg')) continue;
|
|
550
|
+
if (svg.classList.contains('lucide') || !visible(svg)) continue;
|
|
551
|
+
const sr = svg.getBoundingClientRect();
|
|
552
|
+
if (sr.width < 48 || sr.height < 24) continue;
|
|
553
|
+
const area = visibleArea(svg);
|
|
554
|
+
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) };
|
|
555
|
+
const name = describe(svg.closest('[data-plot]') || svg);
|
|
556
|
+
const texts = [...svg.querySelectorAll('text')].filter((t) => (t.textContent || '').trim() && visible(t));
|
|
557
|
+
const items = [];
|
|
558
|
+
for (const t of texts) {
|
|
559
|
+
for (const b of textBoxes(t)) {
|
|
560
|
+
// a label outside the visible part of the drawing: cut, or drawn where no one sees it
|
|
561
|
+
if (b.top >= box.bottom || b.bottom <= box.top) {
|
|
562
|
+
if (sr.bottom <= area.bottom + 1 && sr.top >= area.top - 1) out.push(`${name}: the label "${(t.textContent || '').trim().slice(0, 40)}" lies outside the drawing`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const cutX = Math.max(0, box.left - b.left) + Math.max(0, b.right - box.right);
|
|
566
|
+
const cutY = Math.max(0, box.top - b.top) + Math.max(0, b.bottom - box.bottom);
|
|
567
|
+
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`);
|
|
568
|
+
items.push({ t, b, text: (t.textContent || '').trim() });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (svg.closest('[data-text-overlap="allowed"]') || items.length > 600) continue;
|
|
572
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
573
|
+
for (let j = i + 1; j < items.length; j += 1) {
|
|
574
|
+
const a = items[i];
|
|
575
|
+
const c = items[j];
|
|
576
|
+
if (a.t === c.t || a.t.contains(c.t) || c.t.contains(a.t)) continue;
|
|
577
|
+
const w = Math.min(a.b.right, c.b.right) - Math.max(a.b.left, c.b.left);
|
|
578
|
+
const h = Math.min(a.b.bottom, c.b.bottom) - Math.max(a.b.top, c.b.top);
|
|
579
|
+
if (w <= 1.5 || h <= 2.5) continue;
|
|
580
|
+
// a halo: the same label drawn twice at the same place
|
|
581
|
+
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;
|
|
582
|
+
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`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return [...new Set(out)];
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* G11, numbers in Spanish: a page in Spanish writes decimals with a comma (conventions/languages.md). Reads every
|
|
591
|
+
* visible text, SVG labels included, outside code, formulas, versions and anything marked translate="no"; a number
|
|
592
|
+
* with a decimal point fails, a Spanish thousands group (27.345) passes. A canvas is not read.
|
|
593
|
+
*/
|
|
594
|
+
G.decimals = () => {
|
|
595
|
+
const out = [];
|
|
596
|
+
const skip = 'code, pre, kbd, samp, .katex, [data-version], [data-build], [translate="no"], script, style, [aria-hidden="true"]';
|
|
597
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
598
|
+
const re = /(?<![\w.,/:@#])(\d+)\.(\d+)(?![\w/@]|\.\d)/g;
|
|
599
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
600
|
+
const text = n.nodeValue || '';
|
|
601
|
+
if (!/\d\.\d/.test(text)) continue;
|
|
602
|
+
const el = n.parentElement;
|
|
603
|
+
if (!el || el.closest(skip) || !shown(el)) continue;
|
|
604
|
+
for (const m of text.matchAll(re)) {
|
|
605
|
+
// a thousands group of the Spanish locale: 1 to 3 digits, not starting with 0, then groups of exactly 3
|
|
606
|
+
const token = text.slice(m.index).match(/^\d+(?:\.\d+)+/)?.[0] ?? m[0];
|
|
607
|
+
if (/^[1-9]\d{0,2}(\.\d{3})+$/.test(token)) continue;
|
|
608
|
+
out.push(`"${text.trim().slice(0, 60)}" writes ${m[0]} with a decimal point (${describe(el)})`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return [...new Set(out)].slice(0, 12);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/** The scroll container of an element: the nearest ancestor that scrolls vertically, or the document. */
|
|
615
|
+
const scroller = (el) => {
|
|
616
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
617
|
+
const s = getComputedStyle(a);
|
|
618
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight + 1) return a;
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* G12, the vertical sub-tab list stays in view: on a vertical SubTabs whose panel is taller than its scroll box,
|
|
625
|
+
* scroll to the end of the panel and require the active sub-tab to be visible (known shell defect 24). Restores the
|
|
626
|
+
* position it changed.
|
|
627
|
+
*/
|
|
628
|
+
G.sticky = () => {
|
|
629
|
+
const out = [];
|
|
630
|
+
for (const st of document.querySelectorAll('.subtabs-vertical')) {
|
|
631
|
+
if (!visible(st)) continue;
|
|
632
|
+
const list = st.querySelector(':scope > .subtablist');
|
|
633
|
+
const active = list && list.querySelector('.subtab.active');
|
|
634
|
+
if (!list || !active || getComputedStyle(list).flexDirection !== 'column') continue;
|
|
635
|
+
const sc = scroller(st);
|
|
636
|
+
const viewH = sc ? sc.clientHeight : window.innerHeight;
|
|
637
|
+
const rect = st.getBoundingClientRect();
|
|
638
|
+
if (rect.height <= viewH + 4) continue;
|
|
639
|
+
const before = sc ? sc.scrollTop : window.scrollY;
|
|
640
|
+
// scroll so the end of the sub-tab block is at the bottom of the scroll box
|
|
641
|
+
const delta = rect.bottom - (sc ? sc.getBoundingClientRect().bottom : window.innerHeight);
|
|
642
|
+
if (sc) setScroll(sc, sc.scrollLeft, sc.scrollTop + delta);
|
|
643
|
+
else winScroll(window.scrollX, window.scrollY + delta);
|
|
644
|
+
const a = active.getBoundingClientRect();
|
|
645
|
+
const area = visibleArea(active);
|
|
646
|
+
const headerBottom = (() => {
|
|
647
|
+
const h = document.querySelector('.site-header');
|
|
648
|
+
if (!h || sc) return 0;
|
|
649
|
+
const hs = getComputedStyle(h);
|
|
650
|
+
return hs.position === 'sticky' || hs.position === 'fixed' ? h.getBoundingClientRect().bottom : 0;
|
|
651
|
+
})();
|
|
652
|
+
const top = Math.max(area.top, headerBottom);
|
|
653
|
+
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)`);
|
|
654
|
+
if (sc) setScroll(sc, sc.scrollLeft, before);
|
|
655
|
+
else winScroll(window.scrollX, before);
|
|
656
|
+
}
|
|
657
|
+
return out;
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const parseColor = (c) => {
|
|
661
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || '');
|
|
662
|
+
if (!m) return null;
|
|
663
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
664
|
+
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
|
|
665
|
+
};
|
|
666
|
+
const over = (top, bottom) => ({
|
|
667
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
668
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
669
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
670
|
+
a: 1,
|
|
671
|
+
});
|
|
672
|
+
const lum = (c) => {
|
|
673
|
+
const ch = (v) => {
|
|
674
|
+
const x = v / 255;
|
|
675
|
+
return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
|
676
|
+
};
|
|
677
|
+
return 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b);
|
|
678
|
+
};
|
|
679
|
+
/** The colour behind an element: its ancestors' backgrounds composited, or null when an image or gradient is there. */
|
|
680
|
+
const backdrop = (el) => {
|
|
681
|
+
const layers = [];
|
|
682
|
+
for (let a = el; a; a = a.parentElement) {
|
|
683
|
+
const s = getComputedStyle(a);
|
|
684
|
+
if (s.backgroundImage && s.backgroundImage !== 'none') return null;
|
|
685
|
+
const c = parseColor(s.backgroundColor);
|
|
686
|
+
if (c && c.a > 0) {
|
|
687
|
+
layers.push(c);
|
|
688
|
+
if (c.a >= 1) break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
let base = { r: 255, g: 255, b: 255, a: 1 };
|
|
692
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) base = over(layers[i], base);
|
|
693
|
+
return base;
|
|
694
|
+
};
|
|
695
|
+
const opacityOf = (el) => {
|
|
696
|
+
let o = 1;
|
|
697
|
+
for (let a = el; a; a = a.parentElement) o *= Number(getComputedStyle(a).opacity || 1);
|
|
698
|
+
return o;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* G13, text contrast (WCAG 2.2 AA, 1.4.3): every visible text a reader can read, SVG labels included, against the
|
|
703
|
+
* colour behind it: 4.5:1, or 3:1 for large text (24px, or 18.66px bold). Disabled controls are exempt, as WCAG
|
|
704
|
+
* exempts inactive components; text over an image or a gradient is not judged.
|
|
705
|
+
*/
|
|
706
|
+
G.contrast = () => {
|
|
707
|
+
const out = [];
|
|
708
|
+
const seen = new Set();
|
|
709
|
+
// a colour transition still running reads as a colour between two states (a chip just selected read 3.89:1 at
|
|
710
|
+
// 87% of its way to white): finish every finite transition first; looping animations are left alone
|
|
711
|
+
if (document.getAnimations) {
|
|
712
|
+
for (const a of document.getAnimations()) {
|
|
713
|
+
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : Infinity;
|
|
714
|
+
if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition && Number.isFinite(Number(end))) a.finish();
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
718
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
719
|
+
if (!(n.nodeValue || '').trim()) continue;
|
|
720
|
+
const el = n.parentElement;
|
|
721
|
+
if (!el || seen.has(el)) continue;
|
|
722
|
+
seen.add(el);
|
|
723
|
+
if (el.closest('script, style, [aria-hidden="true"], .sr-only, .skip-link, :disabled, [aria-disabled="true"], .katex-mathml') || !visible(el)) continue;
|
|
724
|
+
const s = getComputedStyle(el);
|
|
725
|
+
const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
726
|
+
const fg = parseColor(isSvg ? s.fill : s.color);
|
|
727
|
+
if (!fg) continue;
|
|
728
|
+
const host = isSvg ? el.closest('svg')?.parentElement ?? el : el;
|
|
729
|
+
const bg = backdrop(host);
|
|
730
|
+
if (!bg) continue;
|
|
731
|
+
const alpha = fg.a * opacityOf(el);
|
|
732
|
+
const shownColor = over({ ...fg, a: alpha }, bg);
|
|
733
|
+
const l1 = lum(shownColor);
|
|
734
|
+
const l2 = lum(bg);
|
|
735
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
736
|
+
const px = parseFloat(s.fontSize) || 16;
|
|
737
|
+
const bold = Number(s.fontWeight) >= 700;
|
|
738
|
+
const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5;
|
|
739
|
+
if (ratio + 1e-6 < need) out.push(`${describe(el)}: contrast ${ratio.toFixed(2)}:1, below ${need}:1 (${s.color || s.fill} on rgb(${Math.round(bg.r)}, ${Math.round(bg.g)}, ${Math.round(bg.b)}))`);
|
|
740
|
+
}
|
|
741
|
+
return out.slice(0, 10);
|
|
742
|
+
};
|
|
494
743
|
}
|
package/gate/run.mjs
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
// waiting for declared state (data-state, data-stale, data-drawn), never networkidle
|
|
16
16
|
// G8 idle at rest no sustained animation frames or DOM mutations after load and after a tab switch
|
|
17
17
|
// G9 reactivity each registered control changes the selection key and no view keeps an old one; the
|
|
18
|
-
// rendered case is the case asked for
|
|
18
|
+
// rendered case is the case asked for; a disabled control is not moved
|
|
19
|
+
// G10 text in drawings every label of a chart, an instrument SVG or a document figure inside the drawing and
|
|
20
|
+
// clear of the others, also when every page is rendered in a wider fallback font (the
|
|
21
|
+
// wide-font pass: Verdana, else DejaVu Sans, the Linux runner's font)
|
|
22
|
+
// G11 Spanish numbers a page in Spanish writes no decimal point (canvas text excepted)
|
|
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)
|
|
25
|
+
// G14 captures index gate-output/index.html shows every capture by route and mode, the failures first
|
|
19
26
|
// plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
|
|
20
27
|
// references).
|
|
21
28
|
|
|
22
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
30
|
import { join } from 'node:path';
|
|
24
31
|
import { decodePng, paintedBox, unionArea } from './png.mjs';
|
|
25
32
|
import { servePages } from './serve.mjs';
|
|
@@ -38,8 +45,14 @@ export const DEFAULTS = {
|
|
|
38
45
|
viewportMinWidth: 1280,
|
|
39
46
|
out: 'gate-output',
|
|
40
47
|
maxShots: 40,
|
|
48
|
+
wideFont: true,
|
|
49
|
+
wideFontSizes: '390x844,1280x800',
|
|
41
50
|
};
|
|
42
51
|
|
|
52
|
+
/** The wide fallback font of the G10 pass: wider than the Windows and macOS sans fonts; DejaVu Sans is what a Linux
|
|
53
|
+
* reader (and the CI runner) gets. Applied before the first frame, so a drawing that measures its text measures it. */
|
|
54
|
+
const WIDE_FONT = ':root, :root[data-theme] { --font-sans: Verdana, "DejaVu Sans", sans-serif !important; }';
|
|
55
|
+
|
|
43
56
|
const RATE_LIMIT = 5; // frames or mutations per second at rest above which a loop is reported (G8)
|
|
44
57
|
|
|
45
58
|
async function loadKeys() {
|
|
@@ -103,8 +116,26 @@ export async function runGate(o) {
|
|
|
103
116
|
const artifacts = new Map();
|
|
104
117
|
|
|
105
118
|
const browser = await chromium.launch();
|
|
106
|
-
const openContext = async ([w, h], theme, lang) => {
|
|
119
|
+
const openContext = async ([w, h], theme, lang, wide = false) => {
|
|
107
120
|
const ctx = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });
|
|
121
|
+
if (wide) {
|
|
122
|
+
await ctx.addInitScript((css) => {
|
|
123
|
+
const add = () => {
|
|
124
|
+
const st = document.createElement('style');
|
|
125
|
+
st.setAttribute('data-caos-gate-font', '');
|
|
126
|
+
st.textContent = css;
|
|
127
|
+
document.documentElement.appendChild(st);
|
|
128
|
+
};
|
|
129
|
+
if (document.documentElement) add();
|
|
130
|
+
else
|
|
131
|
+
new MutationObserver((_, o) => {
|
|
132
|
+
if (document.documentElement) {
|
|
133
|
+
o.disconnect();
|
|
134
|
+
add();
|
|
135
|
+
}
|
|
136
|
+
}).observe(document, { childList: true });
|
|
137
|
+
}, WIDE_FONT);
|
|
138
|
+
}
|
|
108
139
|
await ctx.addInitScript(
|
|
109
140
|
([tk, lk, t, l]) => {
|
|
110
141
|
try {
|
|
@@ -223,6 +254,7 @@ export async function runGate(o) {
|
|
|
223
254
|
const mode = `${w}x${h} ${theme} ${lang}`;
|
|
224
255
|
const primary = size === primarySize && theme === themes[0] && lang === langs[0];
|
|
225
256
|
const idleHere = theme === themes[0] && lang === langs[0];
|
|
257
|
+
const contrast = size === primarySize && lang === langs[0];
|
|
226
258
|
const ctx = await openContext(size, theme, lang);
|
|
227
259
|
const { page, sink } = await newPage(ctx);
|
|
228
260
|
for (const route of routes) {
|
|
@@ -248,7 +280,7 @@ export async function runGate(o) {
|
|
|
248
280
|
if (route !== '/' && id.activeHref && routeOf(id.activeHref) !== route) fail('G4', at, `loaded directly, the page shows route ${routeOf(id.activeHref)}`);
|
|
249
281
|
|
|
250
282
|
const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
|
|
251
|
-
await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState });
|
|
283
|
+
await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
|
|
252
284
|
|
|
253
285
|
if (primary && route !== '/') {
|
|
254
286
|
// G4: the trailing-slash form a static host serves for a materialised route.
|
|
@@ -268,9 +300,9 @@ export async function runGate(o) {
|
|
|
268
300
|
sink.splice(0);
|
|
269
301
|
}
|
|
270
302
|
|
|
271
|
-
await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState });
|
|
303
|
+
await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState, contrast });
|
|
272
304
|
|
|
273
|
-
if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState });
|
|
305
|
+
if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState, contrast });
|
|
274
306
|
|
|
275
307
|
if (primary && isWorkbench) {
|
|
276
308
|
const f = await page.evaluate(() => window.__caosGate.facts());
|
|
@@ -311,6 +343,39 @@ export async function runGate(o) {
|
|
|
311
343
|
}
|
|
312
344
|
}
|
|
313
345
|
|
|
346
|
+
// ---- G10: the wide-font pass. Every route, tab and sampled case again, in Spanish (the longer labels) when it is
|
|
347
|
+
// measured, rendered in the wide fallback font from the first frame: what a reader with wider fonts sees.
|
|
348
|
+
if (opt.wideFont !== false && opt.wideFontSizes) {
|
|
349
|
+
const wideSizes = String(opt.wideFontSizes).split(',').map((x) => x.trim()).filter(Boolean).map((x) => x.split('x').map(Number));
|
|
350
|
+
const wlang = langs.includes('es') ? 'es' : langs[0];
|
|
351
|
+
const theme = themes[0];
|
|
352
|
+
for (const size of wideSizes) {
|
|
353
|
+
const [w, h] = size;
|
|
354
|
+
const mode = `${w}x${h} ${theme} ${wlang} wide-font`;
|
|
355
|
+
const ctx = await openContext(size, theme, wlang, true);
|
|
356
|
+
const { page, sink } = await newPage(ctx);
|
|
357
|
+
for (const route of routes) {
|
|
358
|
+
const isWorkbench = workbenchRoutes.has(route);
|
|
359
|
+
const at = { route, mode };
|
|
360
|
+
const res = await page.goto(urlFor(route), { waitUntil: 'load', timeout: 30000 }).catch((e) => e);
|
|
361
|
+
const status = res && typeof res.status === 'function' ? res.status() : `error ${res?.message ?? res}`;
|
|
362
|
+
if (status !== 200) {
|
|
363
|
+
fail('G4', at, `loaded directly, ${urlFor(route)} answered ${status}`);
|
|
364
|
+
drain(sink, at);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const why = await settle(page);
|
|
368
|
+
if (why) fail('G7', at, `never settled: ${why}`);
|
|
369
|
+
const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0 };
|
|
370
|
+
const s = { route, isWorkbench, primary: false, idle: false, size, w, h, theme, lang: wlang, walkState, wide: true };
|
|
371
|
+
await processState(page, sink, { ...at, trail: '' }, { ...s, first: true });
|
|
372
|
+
await walkTabs(page, sink, { ...at }, [], new Set(), s);
|
|
373
|
+
if (isWorkbench) await walkCases(page, sink, at, s);
|
|
374
|
+
}
|
|
375
|
+
await ctx.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
314
379
|
// ---- G4: artifacts answered as JSON.
|
|
315
380
|
for (const [pathname, first] of artifacts) {
|
|
316
381
|
const c = await first;
|
|
@@ -345,6 +410,13 @@ export async function runGate(o) {
|
|
|
345
410
|
if (f.offCentre !== null && f.offCentre > 2) fail('ADR-0017.1', where, `the prose page is off-centre by ${f.offCentre.toFixed(1)}px`);
|
|
346
411
|
if (f.referenceLists) fail('ADR-0017.4', where, `${f.referenceLists} bibliography dump(s); references belong at the end of each section`);
|
|
347
412
|
if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
|
|
413
|
+
for (const r of f.repeatedTicks ?? []) {
|
|
414
|
+
fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
|
|
415
|
+
}
|
|
416
|
+
for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', where, m);
|
|
417
|
+
if (s.lang === 'es') for (const m of await page.evaluate(() => window.__caosGate.decimals())) fail('G11', where, m);
|
|
418
|
+
if (s.w > 760) for (const m of await page.evaluate(() => window.__caosGate.sticky())) fail('G12', where, m);
|
|
419
|
+
if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, m);
|
|
348
420
|
if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
|
|
349
421
|
if (s.first && !s.isWorkbench && f.pageText < opt.textFloor) fail('G4', where, `the page body holds ${f.pageText} characters of text (floor ${opt.textFloor}); the route rendered without its content`);
|
|
350
422
|
|
|
@@ -357,7 +429,7 @@ export async function runGate(o) {
|
|
|
357
429
|
}
|
|
358
430
|
}
|
|
359
431
|
|
|
360
|
-
if (s.first) await saveShot(page, `${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
|
|
432
|
+
if (s.first) await saveShot(page, `${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}.png`);
|
|
361
433
|
|
|
362
434
|
if (s.idle) {
|
|
363
435
|
const idle = await page.evaluate((ms) => window.__caosGate.idle(ms), opt.idleMs);
|
|
@@ -373,7 +445,7 @@ export async function runGate(o) {
|
|
|
373
445
|
if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
|
|
374
446
|
drain(sink, where);
|
|
375
447
|
const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
|
|
376
|
-
if (failedHere) await saveShot(page, `fail_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}_${shots + 1}.png`, true);
|
|
448
|
+
if (failedHere) await saveShot(page, `fail_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}${s.wide ? '_wide' : ''}_${shots + 1}.png`, true);
|
|
377
449
|
}
|
|
378
450
|
|
|
379
451
|
async function paintedChecks(page, f, where, big) {
|
|
@@ -467,6 +539,8 @@ export async function runGate(o) {
|
|
|
467
539
|
if (i !== tl.active) {
|
|
468
540
|
const tab = page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(i);
|
|
469
541
|
try {
|
|
542
|
+
// a reader scrolls the tab clear of the sticky header first; so does the gate, then clicks it
|
|
543
|
+
await tab.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
|
|
470
544
|
await tab.click({ timeout: 5000 });
|
|
471
545
|
} catch (e) {
|
|
472
546
|
fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
|
|
@@ -583,13 +657,59 @@ export async function runGate(o) {
|
|
|
583
657
|
function finish() {
|
|
584
658
|
const checks = {};
|
|
585
659
|
for (const f of failures) checks[f.check] = (checks[f.check] ?? 0) + 1;
|
|
586
|
-
const report = { ok: failures.length === 0, base, expectBrand: opt.expectBrand, sizes: opt.sizes, themes: opt.themes, langs: opt.langs, states, minima, checks, failures };
|
|
587
660
|
mkdirSync(opt.out, { recursive: true });
|
|
661
|
+
const captures = existsSync(shotsDir) ? readdirSync(shotsDir).filter((n) => n.endsWith('.png')).sort() : [];
|
|
662
|
+
const report = {
|
|
663
|
+
ok: failures.length === 0,
|
|
664
|
+
base,
|
|
665
|
+
expectBrand: opt.expectBrand,
|
|
666
|
+
sizes: opt.sizes,
|
|
667
|
+
themes: opt.themes,
|
|
668
|
+
langs: opt.langs,
|
|
669
|
+
wideFontSizes: opt.wideFont === false ? '' : opt.wideFontSizes,
|
|
670
|
+
states,
|
|
671
|
+
minima,
|
|
672
|
+
checks,
|
|
673
|
+
captures: captures.length,
|
|
674
|
+
failures,
|
|
675
|
+
};
|
|
588
676
|
writeFileSync(join(opt.out, 'gate-report.json'), JSON.stringify(report, null, 2));
|
|
677
|
+
// G14: the captures are for a person to read (the gate measures, it does not judge content); one page shows them.
|
|
678
|
+
writeFileSync(join(opt.out, 'index.html'), captureIndex(report, captures));
|
|
589
679
|
return report;
|
|
590
680
|
}
|
|
591
681
|
}
|
|
592
682
|
|
|
683
|
+
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
684
|
+
|
|
685
|
+
/** G14: one page with every capture, grouped by route, the failure captures and the failure list first. */
|
|
686
|
+
export function captureIndex(report, captures) {
|
|
687
|
+
const groups = new Map();
|
|
688
|
+
for (const name of captures) {
|
|
689
|
+
const key = name.startsWith('fail_') ? 'failures' : name.split('_')[0];
|
|
690
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
691
|
+
groups.get(key).push(name);
|
|
692
|
+
}
|
|
693
|
+
const order = ['failures', ...[...groups.keys()].filter((k) => k !== 'failures')];
|
|
694
|
+
const rows = report.failures
|
|
695
|
+
.slice(0, 400)
|
|
696
|
+
.map((f) => `<tr><td>${esc(f.check)}</td><td>${esc(f.route)}</td><td>${esc(f.mode)}</td><td>${esc(f.trail)}</td><td>${esc(f.message)}</td></tr>`)
|
|
697
|
+
.join('');
|
|
698
|
+
const figure = (n) => `<figure><a href="shots/${esc(n)}"><img loading="lazy" src="shots/${esc(n)}" alt="${esc(n)}"></a><figcaption>${esc(n.replace(/\.png$/, ''))}</figcaption></figure>`;
|
|
699
|
+
const sections = order
|
|
700
|
+
.filter((k) => groups.has(k))
|
|
701
|
+
.map((k) => `<h2>${esc(k)} (${groups.get(k).length})</h2><div class="grid">${groups.get(k).map(figure).join('')}</div>`)
|
|
702
|
+
.join('');
|
|
703
|
+
const style =
|
|
704
|
+
'body{font:14px system-ui,sans-serif;margin:16px;background:#f6f8fa;color:#1f2328}h1{font-size:18px}h2{font-size:15px;margin-top:24px}' +
|
|
705
|
+
'table{border-collapse:collapse;font-size:12px}td{border-bottom:1px solid #d0d7de;padding:3px 6px;vertical-align:top}' +
|
|
706
|
+
'.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}' +
|
|
707
|
+
'img{width:100%;height:auto;display:block}figcaption{font-size:11px;color:#57606a;margin-top:4px}';
|
|
708
|
+
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`;
|
|
709
|
+
const table = rows ? `<table><tr><td>check</td><td>route</td><td>mode</td><td>trail</td><td>message</td></tr>${rows}</table>` : '';
|
|
710
|
+
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`;
|
|
711
|
+
}
|
|
712
|
+
|
|
593
713
|
function slug(route) {
|
|
594
714
|
return route === '/' ? 'app' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
|
|
595
715
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fasl-work/caos-app-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|