@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/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(/s+/g, ' ');
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(/s+/g, ' ');
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
- right: r.right,
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 < 0 || r.bottom > window.innerHeight) winScroll(window.scrollX, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
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
- if (r.width < 1 || r.height < 1) continue;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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.7.2",
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",