@fasl-work/caos-app-shell 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -14
- package/bin/caos-shell-gate.mjs +5 -2
- package/dist/chart.d.ts +28 -3
- package/dist/chart.js +59 -23
- 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 +280 -9
- package/gate/run.mjs +126 -9
- package/package.json +1 -1
- package/reserved-classes.json +239 -3
- package/styles.css +445 -367
package/gate/inpage.mjs
CHANGED
|
@@ -17,6 +17,11 @@ export function installLib() {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
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
|
+
|
|
20
25
|
const visible = (el) => {
|
|
21
26
|
if (!el || el.nodeType !== 1) return false;
|
|
22
27
|
const r = el.getBoundingClientRect();
|
|
@@ -154,10 +159,10 @@ export function installLib() {
|
|
|
154
159
|
if (s.textOverflow !== 'ellipsis' || el.scrollWidth <= el.clientWidth + 1 || !visible(el)) continue;
|
|
155
160
|
// The full text must be readable somewhere: a title (or accessible name) on the element or its two nearest
|
|
156
161
|
// 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(
|
|
162
|
+
const full = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
158
163
|
let named = false;
|
|
159
164
|
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(
|
|
165
|
+
const label = `${t.getAttribute('title') || ''} ${t.getAttribute('aria-label') || ''}`.replace(/\s+/g, ' ');
|
|
161
166
|
if (full && label.includes(full)) {
|
|
162
167
|
named = true;
|
|
163
168
|
break;
|
|
@@ -233,7 +238,9 @@ export function installLib() {
|
|
|
233
238
|
y,
|
|
234
239
|
w: x2 - x,
|
|
235
240
|
h: y2 - y,
|
|
236
|
-
|
|
241
|
+
// a table or drawing inside a box that SCROLLS sideways (a wide table on a phone, ADR-0071 rule 3) ends where
|
|
242
|
+
// its box ends: a reader scrolls it; one cut by a box that hides its overflow still extends past the page
|
|
243
|
+
right: Math.min(r.right, ...[...ancestorsUntil(el, inst)].filter((a) => ['auto', 'scroll'].includes(getComputedStyle(a).overflowX)).map((a) => a.getBoundingClientRect().right)),
|
|
237
244
|
stageKey: path(stageEl),
|
|
238
245
|
stage: clipRect(stageEl.getBoundingClientRect()),
|
|
239
246
|
viewKey: path(viewEl),
|
|
@@ -323,19 +330,24 @@ export function installLib() {
|
|
|
323
330
|
const ar = a.getBoundingClientRect();
|
|
324
331
|
let r = el.getBoundingClientRect();
|
|
325
332
|
// An element larger than the container is aligned by its start: aligning its end pushed its start out of
|
|
326
|
-
// view, and the probe then landed on whatever covers the container (known shell defect 18).
|
|
333
|
+
// view, and the probe then landed on whatever covers the container (known shell defect 18). An element that
|
|
334
|
+
// fits is scrolled until its end shows with a margin, never past its start: with less slack than the margin,
|
|
335
|
+
// the margin pushed its start out and the probe reported the element it had moved (known shell defect 25).
|
|
327
336
|
if ((s.overflowX === 'auto' || s.overflowX === 'scroll') && a.scrollWidth > a.clientWidth) {
|
|
328
337
|
if (r.left < ar.left || r.width > ar.width) setScroll(a, a.scrollLeft - (ar.left - r.left), a.scrollTop);
|
|
329
|
-
else if (r.right > ar.right) setScroll(a, a.scrollLeft + (r.right - ar.right + 8), a.scrollTop);
|
|
338
|
+
else if (r.right > ar.right) setScroll(a, a.scrollLeft + Math.min(r.right - ar.right + 8, r.left - ar.left), a.scrollTop);
|
|
330
339
|
}
|
|
331
340
|
r = el.getBoundingClientRect();
|
|
332
341
|
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight) {
|
|
333
342
|
if (r.top < ar.top || r.height > ar.height) setScroll(a, a.scrollLeft, a.scrollTop - (ar.top - r.top));
|
|
334
|
-
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + (r.bottom - ar.bottom + 8));
|
|
343
|
+
else if (r.bottom > ar.bottom) setScroll(a, a.scrollLeft, a.scrollTop + Math.min(r.bottom - ar.bottom + 8, r.top - ar.top));
|
|
335
344
|
}
|
|
336
345
|
}
|
|
346
|
+
// the document is scrolled so the element shows below a header stuck at the top of the viewport (0.8.0: the
|
|
347
|
+
// sticky header sticks in a scrolling document since known shell defect 26 was fixed)
|
|
348
|
+
const cover = el.closest('header, [role="banner"]') ? 0 : stuckTop();
|
|
337
349
|
const r = el.getBoundingClientRect();
|
|
338
|
-
if (r.top <
|
|
350
|
+
if (r.top < cover || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - (cover + (window.innerHeight - cover) / 2));
|
|
339
351
|
const r2 = el.getBoundingClientRect();
|
|
340
352
|
if (r2.left < 0 || r2.right > window.innerWidth) winScroll(window.scrollX + r2.left + r2.width / 2 - window.innerWidth / 2, window.scrollY);
|
|
341
353
|
};
|
|
@@ -347,8 +359,22 @@ export function installLib() {
|
|
|
347
359
|
};
|
|
348
360
|
|
|
349
361
|
/** The viewport cut by every ancestor that clips its content (overflow other than visible). */
|
|
362
|
+
/** The bottom of a page header stuck to the top of the viewport (sticky or fixed), or 0: what it covers is not in
|
|
363
|
+
* view for a reader. */
|
|
364
|
+
function stuckTop() {
|
|
365
|
+
let bottom = 0;
|
|
366
|
+
for (const h of document.querySelectorAll('header, [role="banner"]')) {
|
|
367
|
+
if (h.parentElement && h.parentElement.closest('header, [role="banner"], [role="dialog"]')) continue;
|
|
368
|
+
const s = getComputedStyle(h);
|
|
369
|
+
if (s.position !== 'sticky' && s.position !== 'fixed') continue;
|
|
370
|
+
const r = h.getBoundingClientRect();
|
|
371
|
+
if (r.top <= 1 && r.bottom > 0 && r.width >= window.innerWidth * 0.5) bottom = Math.max(bottom, r.bottom);
|
|
372
|
+
}
|
|
373
|
+
return bottom;
|
|
374
|
+
}
|
|
375
|
+
|
|
350
376
|
const visibleArea = (el) => {
|
|
351
|
-
const area = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
|
|
377
|
+
const area = { left: 0, top: el.closest('header, [role="banner"]') ? 0 : stuckTop(), right: window.innerWidth, bottom: window.innerHeight };
|
|
352
378
|
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
353
379
|
const s = getComputedStyle(a);
|
|
354
380
|
if (s.overflowX === 'visible' && s.overflowY === 'visible') continue;
|
|
@@ -382,7 +408,8 @@ export function installLib() {
|
|
|
382
408
|
// pointer lands on a fragment, so the largest one is tested.
|
|
383
409
|
const frags = [...el.getClientRects()].filter((q) => q.width >= 1 && q.height >= 1);
|
|
384
410
|
const r = frags.length ? frags.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
|
|
385
|
-
|
|
411
|
+
// a 1x1 box is the visually hidden pattern (the skip link, screen-reader text): not a pointer target
|
|
412
|
+
if (r.width <= 1 || r.height <= 1) continue;
|
|
386
413
|
checked += 1;
|
|
387
414
|
// The area the element can show in: the viewport cut by every ancestor that clips (known shell defect 18; an
|
|
388
415
|
// element that fits the window but not its scroll container was reported as never brought into view).
|
|
@@ -480,6 +507,11 @@ export function installLib() {
|
|
|
480
507
|
G.controls = () =>
|
|
481
508
|
[...document.querySelectorAll('[data-control]')]
|
|
482
509
|
.filter((c) => visible(c) && c.getAttribute('data-control') !== 'case')
|
|
510
|
+
// a control that cannot act in this selection is not moved (known shell defect 20): every input in it disabled
|
|
511
|
+
.filter((c) => {
|
|
512
|
+
const inputs = c.matches('input, select, button, textarea') ? [c] : [...c.querySelectorAll('input, select, button, textarea')];
|
|
513
|
+
return inputs.length === 0 || inputs.some((e) => !e.disabled);
|
|
514
|
+
})
|
|
483
515
|
.map((c) => {
|
|
484
516
|
let kind = 'unknown';
|
|
485
517
|
if (c.tagName === 'SELECT' || c.querySelector('select')) kind = 'select';
|
|
@@ -495,4 +527,243 @@ export function installLib() {
|
|
|
495
527
|
[...document.querySelectorAll('[data-plot], [data-readout]')]
|
|
496
528
|
.filter(visible)
|
|
497
529
|
.map((v) => ({ label: describe(v), key: v.getAttribute('data-state-key') || '', stale: v.getAttribute('data-stale') === '1' }));
|
|
530
|
+
|
|
531
|
+
// ---- 0.8.0 measures ----------------------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
/** The part of the page the ancestors of an element let it show when they CUT their content (overflow hidden or
|
|
534
|
+
* clip). A scroll container does not cut (a reader scrolls it), and neither does the viewport. */
|
|
535
|
+
const cutArea = (el) => {
|
|
536
|
+
const area = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity };
|
|
537
|
+
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
538
|
+
const s = getComputedStyle(a);
|
|
539
|
+
const ar = a.getBoundingClientRect();
|
|
540
|
+
if (s.overflowX === 'hidden' || s.overflowX === 'clip') {
|
|
541
|
+
area.left = Math.max(area.left, ar.left);
|
|
542
|
+
area.right = Math.min(area.right, ar.right);
|
|
543
|
+
}
|
|
544
|
+
if (s.overflowY === 'hidden' || s.overflowY === 'clip') {
|
|
545
|
+
area.top = Math.max(area.top, ar.top);
|
|
546
|
+
area.bottom = Math.min(area.bottom, ar.bottom);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return area;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/** The boxes a text element occupies on screen: one per line (tspan) when it has several. */
|
|
553
|
+
const textBoxes = (t) => {
|
|
554
|
+
const spans = [...t.querySelectorAll('tspan')].filter((s) => (s.textContent || '').trim());
|
|
555
|
+
const parts = spans.length ? spans : [t];
|
|
556
|
+
return parts
|
|
557
|
+
.map((p) => p.getBoundingClientRect())
|
|
558
|
+
.filter((r) => r.width > 0.5 && r.height > 0.5)
|
|
559
|
+
.map((r) => ({ left: r.left, top: r.top, right: r.right, bottom: r.bottom }));
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* G10, text in drawings: in every drawing a reader looks at (a declared chart, any SVG in the instrument, any figure
|
|
564
|
+
* in a document) each label lies inside the drawing and inside the part of it its containers show, and no two
|
|
565
|
+
* labels overlap. Measured on the rendered glyph boxes, so a label sized for one font and drawn in a wider one fails
|
|
566
|
+
* (the wide-font pass renders every page in a wider fallback font). A halo drawn as a second identical label at the
|
|
567
|
+
* same place is not an overlap. A drawing that crosses labels on purpose declares `data-text-overlap="allowed"`.
|
|
568
|
+
*/
|
|
569
|
+
G.drawingText = () => {
|
|
570
|
+
const out = [];
|
|
571
|
+
const svgs = new Set([
|
|
572
|
+
...document.querySelectorAll('svg[data-chart], [data-instrument] svg, .prose svg, figure svg'),
|
|
573
|
+
]);
|
|
574
|
+
for (const svg of svgs) {
|
|
575
|
+
if (svg.parentElement && svg.parentElement.closest('svg')) continue;
|
|
576
|
+
if (svg.classList.contains('lucide') || !visible(svg)) continue;
|
|
577
|
+
const sr = svg.getBoundingClientRect();
|
|
578
|
+
if (sr.width < 48 || sr.height < 24) continue;
|
|
579
|
+
const area = cutArea(svg);
|
|
580
|
+
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) };
|
|
581
|
+
const name = describe(svg.closest('[data-plot]') || svg);
|
|
582
|
+
const texts = [...svg.querySelectorAll('text')].filter((t) => (t.textContent || '').trim() && visible(t));
|
|
583
|
+
const items = [];
|
|
584
|
+
for (const t of texts) {
|
|
585
|
+
for (const b of textBoxes(t)) {
|
|
586
|
+
// a label outside the visible part of the drawing: cut, or drawn where no one sees it
|
|
587
|
+
if (b.top >= box.bottom || b.bottom <= box.top) {
|
|
588
|
+
out.push(`${name}: the label "${(t.textContent || '').trim().slice(0, 40)}" lies outside the drawing`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const cutX = Math.max(0, box.left - b.left) + Math.max(0, b.right - box.right);
|
|
592
|
+
const cutY = Math.max(0, box.top - b.top) + Math.max(0, b.bottom - box.bottom);
|
|
593
|
+
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`);
|
|
594
|
+
items.push({ t, b, text: (t.textContent || '').trim() });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (svg.closest('[data-text-overlap="allowed"]') || items.length > 600) continue;
|
|
598
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
599
|
+
for (let j = i + 1; j < items.length; j += 1) {
|
|
600
|
+
const a = items[i];
|
|
601
|
+
const c = items[j];
|
|
602
|
+
if (a.t === c.t || a.t.contains(c.t) || c.t.contains(a.t)) continue;
|
|
603
|
+
const w = Math.min(a.b.right, c.b.right) - Math.max(a.b.left, c.b.left);
|
|
604
|
+
const h = Math.min(a.b.bottom, c.b.bottom) - Math.max(a.b.top, c.b.top);
|
|
605
|
+
if (w <= 1.5 || h <= 2.5) continue;
|
|
606
|
+
// a halo: the same label drawn twice at the same place
|
|
607
|
+
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;
|
|
608
|
+
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`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return [...new Set(out)];
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* G11, numbers in Spanish: a page in Spanish writes decimals with a comma (conventions/languages.md). Reads every
|
|
617
|
+
* visible text, SVG labels included, outside code, formulas, versions and anything marked translate="no"; a number
|
|
618
|
+
* with a decimal point fails, a Spanish thousands group (27.345) passes. A canvas is not read.
|
|
619
|
+
*/
|
|
620
|
+
G.decimals = () => {
|
|
621
|
+
const out = [];
|
|
622
|
+
const skip = 'code, pre, kbd, samp, .katex, [data-version], [data-build], [translate="no"], script, style, [aria-hidden="true"]';
|
|
623
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
624
|
+
const re = /(?<![\w.,/:@#])(\d+)\.(\d+)(?![\w/@]|\.\d)/g;
|
|
625
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
626
|
+
const text = n.nodeValue || '';
|
|
627
|
+
if (!/\d\.\d/.test(text)) continue;
|
|
628
|
+
const el = n.parentElement;
|
|
629
|
+
if (!el || el.closest(skip) || !shown(el)) continue;
|
|
630
|
+
for (const m of text.matchAll(re)) {
|
|
631
|
+
// a thousands group of the Spanish locale: 1 to 3 digits, not starting with 0, then groups of exactly 3
|
|
632
|
+
const token = text.slice(m.index).match(/^\d+(?:\.\d+)+/)?.[0] ?? m[0];
|
|
633
|
+
if (/^[1-9]\d{0,2}(\.\d{3})+$/.test(token)) continue;
|
|
634
|
+
out.push(`"${text.trim().slice(0, 60)}" writes ${m[0]} with a decimal point (${describe(el)})`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return [...new Set(out)].slice(0, 12);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/** The scroll container of an element: the nearest ancestor that scrolls vertically, or the document. */
|
|
641
|
+
const scroller = (el) => {
|
|
642
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
643
|
+
const s = getComputedStyle(a);
|
|
644
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && a.scrollHeight > a.clientHeight + 1) return a;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* G12, the vertical sub-tab list stays in view: on a vertical SubTabs whose panel is taller than its scroll box,
|
|
651
|
+
* scroll to the end of the panel and require the active sub-tab to be visible (known shell defect 24). Restores the
|
|
652
|
+
* position it changed.
|
|
653
|
+
*/
|
|
654
|
+
G.sticky = () => {
|
|
655
|
+
const out = [];
|
|
656
|
+
for (const st of document.querySelectorAll('.subtabs-vertical')) {
|
|
657
|
+
if (!visible(st)) continue;
|
|
658
|
+
const list = st.querySelector(':scope > .subtablist');
|
|
659
|
+
const active = list && list.querySelector('.subtab.active');
|
|
660
|
+
if (!list || !active || getComputedStyle(list).flexDirection !== 'column') continue;
|
|
661
|
+
const sc = scroller(st);
|
|
662
|
+
const viewH = sc ? sc.clientHeight : window.innerHeight;
|
|
663
|
+
const rect = st.getBoundingClientRect();
|
|
664
|
+
if (rect.height <= viewH + 4) continue;
|
|
665
|
+
const before = sc ? sc.scrollTop : window.scrollY;
|
|
666
|
+
// scroll so the end of the sub-tab block is at the bottom of the scroll box
|
|
667
|
+
const delta = rect.bottom - (sc ? sc.getBoundingClientRect().bottom : window.innerHeight);
|
|
668
|
+
if (sc) setScroll(sc, sc.scrollLeft, sc.scrollTop + delta);
|
|
669
|
+
else winScroll(window.scrollX, window.scrollY + delta);
|
|
670
|
+
const a = active.getBoundingClientRect();
|
|
671
|
+
const area = visibleArea(active);
|
|
672
|
+
const headerBottom = (() => {
|
|
673
|
+
const h = document.querySelector('.site-header');
|
|
674
|
+
if (!h || sc) return 0;
|
|
675
|
+
const hs = getComputedStyle(h);
|
|
676
|
+
return hs.position === 'sticky' || hs.position === 'fixed' ? h.getBoundingClientRect().bottom : 0;
|
|
677
|
+
})();
|
|
678
|
+
const top = Math.max(area.top, headerBottom);
|
|
679
|
+
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)`);
|
|
680
|
+
if (sc) setScroll(sc, sc.scrollLeft, before);
|
|
681
|
+
else winScroll(window.scrollX, before);
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const parseColor = (c) => {
|
|
687
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || '');
|
|
688
|
+
if (!m) return null;
|
|
689
|
+
const p = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
690
|
+
return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
|
|
691
|
+
};
|
|
692
|
+
const over = (top, bottom) => ({
|
|
693
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
694
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
695
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
696
|
+
a: 1,
|
|
697
|
+
});
|
|
698
|
+
const lum = (c) => {
|
|
699
|
+
const ch = (v) => {
|
|
700
|
+
const x = v / 255;
|
|
701
|
+
return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
|
702
|
+
};
|
|
703
|
+
return 0.2126 * ch(c.r) + 0.7152 * ch(c.g) + 0.0722 * ch(c.b);
|
|
704
|
+
};
|
|
705
|
+
/** The colour behind an element: its ancestors' backgrounds composited, or null when an image or gradient is there. */
|
|
706
|
+
const backdrop = (el) => {
|
|
707
|
+
const layers = [];
|
|
708
|
+
for (let a = el; a; a = a.parentElement) {
|
|
709
|
+
const s = getComputedStyle(a);
|
|
710
|
+
if (s.backgroundImage && s.backgroundImage !== 'none') return null;
|
|
711
|
+
const c = parseColor(s.backgroundColor);
|
|
712
|
+
if (c && c.a > 0) {
|
|
713
|
+
layers.push(c);
|
|
714
|
+
if (c.a >= 1) break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
let base = { r: 255, g: 255, b: 255, a: 1 };
|
|
718
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) base = over(layers[i], base);
|
|
719
|
+
return base;
|
|
720
|
+
};
|
|
721
|
+
const opacityOf = (el) => {
|
|
722
|
+
let o = 1;
|
|
723
|
+
for (let a = el; a; a = a.parentElement) o *= Number(getComputedStyle(a).opacity || 1);
|
|
724
|
+
return o;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* G13, text contrast (WCAG 2.2 AA, 1.4.3): every visible text a reader can read, SVG labels included, against the
|
|
729
|
+
* colour behind it: 4.5:1, or 3:1 for large text (24px, or 18.66px bold). Disabled controls are exempt, as WCAG
|
|
730
|
+
* exempts inactive components; text over an image or a gradient is not judged.
|
|
731
|
+
*/
|
|
732
|
+
G.contrast = () => {
|
|
733
|
+
const out = [];
|
|
734
|
+
const seen = new Set();
|
|
735
|
+
// a colour transition still running reads as a colour between two states (a chip just selected read 3.89:1 at
|
|
736
|
+
// 87% of its way to white): finish every finite transition first; looping animations are left alone
|
|
737
|
+
if (document.getAnimations) {
|
|
738
|
+
for (const a of document.getAnimations()) {
|
|
739
|
+
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : Infinity;
|
|
740
|
+
if (typeof CSSTransition !== 'undefined' && a instanceof CSSTransition && Number.isFinite(Number(end))) a.finish();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
744
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
745
|
+
if (!(n.nodeValue || '').trim()) continue;
|
|
746
|
+
const el = n.parentElement;
|
|
747
|
+
if (!el || seen.has(el)) continue;
|
|
748
|
+
seen.add(el);
|
|
749
|
+
if (el.closest('script, style, [aria-hidden="true"], .sr-only, .skip-link, :disabled, [aria-disabled="true"], .katex-mathml') || !visible(el)) continue;
|
|
750
|
+
const s = getComputedStyle(el);
|
|
751
|
+
const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
752
|
+
const fg = parseColor(isSvg ? s.fill : s.color);
|
|
753
|
+
if (!fg) continue;
|
|
754
|
+
const host = isSvg ? el.closest('svg')?.parentElement ?? el : el;
|
|
755
|
+
const bg = backdrop(host);
|
|
756
|
+
if (!bg) continue;
|
|
757
|
+
const alpha = fg.a * opacityOf(el);
|
|
758
|
+
const shownColor = over({ ...fg, a: alpha }, bg);
|
|
759
|
+
const l1 = lum(shownColor);
|
|
760
|
+
const l2 = lum(bg);
|
|
761
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
762
|
+
const px = parseFloat(s.fontSize) || 16;
|
|
763
|
+
const bold = Number(s.fontWeight) >= 700;
|
|
764
|
+
const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5;
|
|
765
|
+
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)}))`);
|
|
766
|
+
}
|
|
767
|
+
return out.slice(0, 10);
|
|
768
|
+
};
|
|
498
769
|
}
|
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;
|
|
@@ -348,6 +413,10 @@ export async function runGate(o) {
|
|
|
348
413
|
for (const r of f.repeatedTicks ?? []) {
|
|
349
414
|
fail('G6', where, `${r.label}: ${r.n} axis tick label(s) repeat the label before them (give the axis fixed decimals)`);
|
|
350
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);
|
|
351
420
|
if (f.sectionsWithoutRefs) fail('ADR-0017.4', where, `${f.sectionsWithoutRefs} section(s) end without references or a stated reason`);
|
|
352
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`);
|
|
353
422
|
|
|
@@ -360,7 +429,7 @@ export async function runGate(o) {
|
|
|
360
429
|
}
|
|
361
430
|
}
|
|
362
431
|
|
|
363
|
-
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`);
|
|
364
433
|
|
|
365
434
|
if (s.idle) {
|
|
366
435
|
const idle = await page.evaluate((ms) => window.__caosGate.idle(ms), opt.idleMs);
|
|
@@ -376,7 +445,7 @@ export async function runGate(o) {
|
|
|
376
445
|
if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
|
|
377
446
|
drain(sink, where);
|
|
378
447
|
const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
|
|
379
|
-
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);
|
|
380
449
|
}
|
|
381
450
|
|
|
382
451
|
async function paintedChecks(page, f, where, big) {
|
|
@@ -470,6 +539,8 @@ export async function runGate(o) {
|
|
|
470
539
|
if (i !== tl.active) {
|
|
471
540
|
const tab = page.locator(tl.key).locator('[role="tab"]').filter({ visible: true }).nth(i);
|
|
472
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);
|
|
473
544
|
await tab.click({ timeout: 5000 });
|
|
474
545
|
} catch (e) {
|
|
475
546
|
fail('G7', at, `the tab could not be clicked: ${e.message.split('\n')[0]}`);
|
|
@@ -586,13 +657,59 @@ export async function runGate(o) {
|
|
|
586
657
|
function finish() {
|
|
587
658
|
const checks = {};
|
|
588
659
|
for (const f of failures) checks[f.check] = (checks[f.check] ?? 0) + 1;
|
|
589
|
-
const report = { ok: failures.length === 0, base, expectBrand: opt.expectBrand, sizes: opt.sizes, themes: opt.themes, langs: opt.langs, states, minima, checks, failures };
|
|
590
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
|
+
};
|
|
591
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));
|
|
592
679
|
return report;
|
|
593
680
|
}
|
|
594
681
|
}
|
|
595
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
|
+
|
|
596
713
|
function slug(route) {
|
|
597
714
|
return route === '/' ? 'app' : route.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
|
|
598
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.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",
|