@fasl-work/caos-app-shell 0.19.10 → 0.19.11

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/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## [0.19.011] - 2026-11-01
2
+
3
+ ### Fixed
4
+
5
+ - Measure complete scrollable documents through actual readable viewport evidence rather than treating content below the current position as missing. Long executive briefs, source-link titles and technical areas retain the same content floors and hidden/clipped/font checks (issue #170).
6
+ - Retain reached native controls by identity, reject permanent clipping through the full document ancestor chain and reject opaque ancestor covers. Document inspection restores and verifies the original keyboard focus and scrolling state.
7
+
8
+ ### Adoption
9
+
10
+ - Products pin npm `0.19.11` and run the installed gate against the built site. No component API, style override or scientific-data change is required.
11
+
1
12
  ## [0.19.010] - 2026-10-31
2
13
 
3
14
  ### Fixed
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.19.010
1
+ 0.19.011
package/gate/inpage.mjs CHANGED
@@ -100,7 +100,7 @@ export function installLib() {
100
100
  return { left: ar.left + o.left, top: ar.top + o.top, right: ar.right - o.right, bottom: ar.bottom - o.bottom };
101
101
  };
102
102
  /**
103
- * The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` up (below `within`) that
103
+ * The part of `rect` a reader can see: inside `within`, inside every ancestor from `el` to the document root that
104
104
  * hides its overflow (`el` itself only when `self`: its overflow cuts its own text, not its own box) and inside every
105
105
  * `clip-path` of `el` and of those ancestors. Null when nothing is left.
106
106
  */
@@ -110,7 +110,7 @@ export function installLib() {
110
110
  let top = Math.max(rect.top, b.top);
111
111
  let right = Math.min(rect.right, b.right);
112
112
  let bottom = Math.min(rect.bottom, b.bottom);
113
- for (let a = el; a && a !== within && a !== document.body; a = a.parentElement) {
113
+ for (let a = el; a; a = a.parentElement) {
114
114
  const s = getComputedStyle(a);
115
115
  const ar = a.getBoundingClientRect();
116
116
  if (a !== el || self) {
@@ -563,6 +563,8 @@ export function installLib() {
563
563
  // ---- 0.11.0: the required areas of the documentation pages (ADR-0017 section 2.1) ------------------------------
564
564
 
565
565
  let docBound = null; // the page being measured: what lies outside it, or is clipped out of it, is not read
566
+ let docEvidence = null; // scoped to one completed real traversal, never shared across active tabs
567
+ const docSeen = (el) => docEvidence ? docEvidence.complete(el) : readable(el, docBound, 4, 4);
566
568
  const wordsOf = (el) => {
567
569
  // the words a reader reads: visible text outside equations; `linkWords` of them inside links
568
570
  let words = 0;
@@ -570,7 +572,7 @@ export function installLib() {
570
572
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
571
573
  for (let n = walker.nextNode(); n; n = walker.nextNode()) {
572
574
  const parent = n.parentElement;
573
- if (!parent || parent.closest('.katex, script, style') || !readable(parent, docBound, 4, 4, n)) continue;
575
+ if (!parent || parent.closest('.katex, script, style') || !(docEvidence ? docEvidence.nodeComplete(n) : readable(parent, docBound, 4, 4, n))) continue;
574
576
  const k = (n.nodeValue || '').split(/\s+/).filter(Boolean).length;
575
577
  words += k;
576
578
  if (parent.closest('a')) linkWords += k;
@@ -594,19 +596,19 @@ export function installLib() {
594
596
  }
595
597
  return { label: labels.join(' > '), key: ids.join('/'), head: ids[0] ?? null };
596
598
  };
597
- const textOf = (el) => (el.innerText || '').trim().replace(/\s+/g, ' '); // rendered text: a hidden descendant is not in it
599
+ const textOf = (el) => (docEvidence ? docEvidence.text(el) : el.innerText || '').trim().replace(/\s+/g, ' '); // only witnessed text during traversal
598
600
  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
599
601
  /** A part of a statement a reader sees: the declared text is in the rendered text, in the readable part (`sel`) when the element has one. */
600
602
  const statPart = (el, want, sel) => {
601
603
  if (!want) return false;
602
604
  const kids = [...el.querySelectorAll(sel)];
603
- return kids.length ? kids.some((k) => readable(k, docBound, 4, 4) && norm(k.innerText).includes(want)) : norm(el.innerText).includes(want);
605
+ return kids.length ? kids.some((k) => docSeen(k) && norm(textOf(k)).includes(want)) : norm(textOf(el)).includes(want);
604
606
  };
605
607
  /** A quantified statement: visible, a digit in its value, and the unit and the source it declares readable beside it (the `Stat` component). */
606
608
  const validStat = (el) => {
607
- if (!readable(el, docBound, 4, 4)) return false;
609
+ if (!docSeen(el)) return false;
608
610
  const value = el.querySelector('.caos-stat-value');
609
- return /\d/.test((value ? value.innerText : el.innerText) || '') && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
611
+ return /\d/.test(textOf(value || el)) && statPart(el, norm(el.getAttribute('data-stat-unit')), '.caos-stat-unit') && statPart(el, norm(el.getAttribute('data-stat-source')), '.caos-stat-source');
610
612
  };
611
613
 
612
614
  /**
@@ -619,7 +621,7 @@ export function installLib() {
619
621
  const page = document.querySelector('[data-doc-page]');
620
622
  if (!page || !visible(page)) return null;
621
623
  docBound = page;
622
- const seen = (el) => readable(el, page, 4, 4);
624
+ const seen = (el) => docSeen(el);
623
625
  const lede = page.querySelector('.lede');
624
626
  // the document column: the page body without its padding; the lede and the executive view take all of it (Felipe, 2026-10-13)
625
627
  const pageStyle = getComputedStyle(page);
@@ -743,13 +745,14 @@ export function installLib() {
743
745
  // A card the carousel's window hides (`hidden` on its item) is out of sight by design: G16 walks the carousel and fails one it
744
746
  // cannot reach. Any other card that is not seen is not shown, as before 0.14.0.
745
747
  const hiddenByDeck = (el) => {
748
+ if (docEvidence) return false; // a real control must first expose every card
746
749
  const li = el.closest('[data-deck-card]');
747
750
  return Boolean(li) && li.hidden && Boolean(li.closest('[data-deck-mode="carousel"]'));
748
751
  };
749
752
  // The back of a flip card is content the reader gets by turning the card: read every field with the cards turned too (the
750
753
  // same `seen` and `innerText` as for any text), and call a field readable when it is seen in either face.
751
754
  const turned = new Map();
752
- withFlipsTurned(() => {
755
+ if (!docEvidence) withFlipsTurned(() => {
753
756
  for (const e of root.querySelectorAll('[data-exec-field], [data-exec-cdef]')) turned.set(e, { text: textOf(e), seen: seen(e) });
754
757
  });
755
758
  const fullText = (e) => textOf(e) || (turned.get(e) ? turned.get(e).text : '');
@@ -889,6 +892,300 @@ export function installLib() {
889
892
  };
890
893
  };
891
894
 
895
+ /** SHG-079..082: collect glyph evidence by real scrolling, then compute the existing metric once.
896
+ * No DOM-presence fallback: every word fragment must fit the viewport, original clip bounds,
897
+ * scrollports and hit tests. Text-node identities deduplicate overlapping observations.
898
+ */
899
+ G.readDocument = async ({ maxSamples = 600, timeoutMs = 30000 } = {}) => {
900
+ const page = document.querySelector('[data-doc-page]');
901
+ if (!page || !visible(page)) return null;
902
+ const started = performance.now();
903
+ const saved = new Map([...document.querySelectorAll('*')].map((e) => [e, [e.scrollLeft, e.scrollTop]]));
904
+ const windowPosition = [scrollX, scrollY], focused = document.activeElement;
905
+ const state = [...page.querySelectorAll('[role="tab"][aria-selected="true"]')];
906
+ const nodes = new Map(), witnessed = new Set(), flips = new Map(), decks = new Map();
907
+ const inkContext = document.createElement('canvas').getContext('2d');
908
+ if (!inkContext) throw new Error('document glyph measurement canvas is unavailable');
909
+ let samples = 0;
910
+ const check = () => {
911
+ if (++samples > maxSamples || performance.now() - started > timeoutMs) throw new Error(`document traversal exceeded ${maxSamples} samples or ${timeoutMs}ms; active document remains unverified`);
912
+ if (!page.isConnected || state.some((e) => !e.isConnected || e.getAttribute('aria-selected') !== 'true')) throw new Error('document traversal changed the active page/tab');
913
+ };
914
+ const frame = () => new Promise((resolve) => nativeRaf ? nativeRaf(() => nativeRaf(resolve)) : setTimeout(resolve, 32));
915
+ const textNodes = (el) => textNodesOf(el).filter((n) => !n.parentElement.closest('.katex, svg, .sr-only, [data-deck-position]'));
916
+ const available = (el) => visible(el) && !el.closest('[hidden], [inert], [aria-hidden="true"]');
917
+ const nodeComplete = (n) => {
918
+ const row = nodes.get(n);
919
+ return Boolean(row && row.value === n.nodeValue && row.parts.length && row.parts.every((p) => p.count > 0 && p.seen.size === p.count));
920
+ };
921
+ const evidence = {
922
+ nodeComplete,
923
+ text: (el) => el ? textNodes(el).filter(nodeComplete).map((n) => n.nodeValue).join(' ') : '',
924
+ complete: (el) => {
925
+ if (!el) return false;
926
+ if (el.matches('[data-control]') && !witnessed.has(el)) return false;
927
+ const required = textNodes(el).filter((n) => nodes.has(n) || available(n.parentElement));
928
+ return required.length ? required.every(nodeComplete) : witnessed.has(el);
929
+ },
930
+ };
931
+ const pseudoPaint = new Map();
932
+ // This box model supports only ordinary, unmapped coordinates. Apply the same identity
933
+ // contract to the pseudo and its ancestors: separate CSS transform channels do not appear
934
+ // in computed transform, and motion/filter/zoom can also move or expand actual paint.
935
+ const knownPaintCoordinates = (s) =>
936
+ ['transform', 'translate', 'rotate', 'scale', 'offsetPath', 'filter', 'backdropFilter', 'perspective']
937
+ .every((key) => !s[key] || s[key] === 'none')
938
+ && (!s.zoom || Number(s.zoom) === 1)
939
+ && !/layout|paint|strict|content/.test(s.contain)
940
+ && !/transform|translate|rotate|scale|offset|filter|perspective|contain|content-visibility|zoom/.test(s.willChange)
941
+ && s.contentVisibility !== 'auto'
942
+ && (!s.transformStyle || s.transformStyle === 'flat')
943
+ && (!s.positionAnchor || ['auto', 'normal'].includes(s.positionAnchor))
944
+ && (!s.positionArea || s.positionArea === 'none')
945
+ && (!s.positionTryFallbacks || s.positionTryFallbacks === 'none');
946
+ const pseudoCover = (parent, x, y) => {
947
+ for (let a = parent; a; a = a.parentElement) {
948
+ let boxes = pseudoPaint.get(a);
949
+ if (!boxes) {
950
+ boxes = [];
951
+ for (const pseudo of ['::before', '::after']) {
952
+ const s = getComputedStyle(a, pseudo);
953
+ if (s.content === 'none' || s.content === 'normal' || s.display === 'none' || s.visibility !== 'visible' || Number(s.opacity) === 0 || Number(s.zIndex) < 0) continue;
954
+ const background = parseColor(s.backgroundColor);
955
+ const outline = !['none', 'hidden'].includes(s.outlineStyle) && parseFloat(s.outlineWidth) > 0;
956
+ const painted = (background && background.a > 0) || s.backgroundImage !== 'none' || s.boxShadow !== 'none'
957
+ || outline || s.borderImageSource !== 'none'
958
+ || ['Top', 'Right', 'Bottom', 'Left'].some((side) => parseFloat(s[`border${side}Width`]) > 0 && (parseColor(s[`border${side}Color`])?.a ?? 0) > 0);
959
+ if (!painted) continue;
960
+ // Unmapped or expanding paint is unknown, even when its nominal layout box is remote.
961
+ if (!['absolute', 'fixed'].includes(s.position) || !knownPaintCoordinates(s)
962
+ || s.boxShadow !== 'none' || outline || s.borderImageSource !== 'none') { boxes.push(null); continue; }
963
+ // Local CSS dimensions cannot be combined with a viewport-space origin when any
964
+ // ancestor changes that mapping or establishes a non-viewport fixed containing block.
965
+ // We do not have a complete paint transform here: unknown positioned paint fails closed.
966
+ let ambiguous = false;
967
+ for (let ancestor = a; ancestor; ancestor = ancestor.parentElement) {
968
+ const cs = getComputedStyle(ancestor);
969
+ if (!knownPaintCoordinates(cs)) { ambiguous = true; break; }
970
+ }
971
+ if (ambiguous) { boxes.push(null); continue; }
972
+ let cb = s.position === 'fixed' ? null : a;
973
+ while (cb && getComputedStyle(cb).position === 'static' && getComputedStyle(cb).transform === 'none') cb = cb.parentElement;
974
+ if (cb && (cb.scrollLeft || cb.scrollTop)) { boxes.push(null); continue; }
975
+ const r = cb?.getBoundingClientRect();
976
+ const base = r ? { left:r.left + cb.clientLeft, top:r.top + cb.clientTop, width:cb.clientWidth, height:cb.clientHeight }
977
+ : { left:s.position === 'fixed' ? 0 : -scrollX, top:s.position === 'fixed' ? 0 : -scrollY, width:innerWidth, height:innerHeight };
978
+ let width = parseFloat(s.width), height = parseFloat(s.height);
979
+ if (s.boxSizing !== 'border-box') {
980
+ width += parseFloat(s.paddingLeft) + parseFloat(s.paddingRight) + parseFloat(s.borderLeftWidth) + parseFloat(s.borderRightWidth);
981
+ height += parseFloat(s.paddingTop) + parseFloat(s.paddingBottom) + parseFloat(s.borderTopWidth) + parseFloat(s.borderBottomWidth);
982
+ }
983
+ const left = parseFloat(s.left), right = parseFloat(s.right), top = parseFloat(s.top), bottom = parseFloat(s.bottom);
984
+ const px = Number.isFinite(left) ? base.left + left + parseFloat(s.marginLeft) : base.left + base.width - right - width - parseFloat(s.marginRight);
985
+ const py = Number.isFinite(top) ? base.top + top + parseFloat(s.marginTop) : base.top + base.height - bottom - height - parseFloat(s.marginBottom);
986
+ if (![px,py,width,height].every(Number.isFinite)) { boxes.push(null); continue; }
987
+ boxes.push({ left:px, right:px+width, top:py, bottom:py+height });
988
+ }
989
+ pseudoPaint.set(a, boxes);
990
+ }
991
+ if (boxes.some((b) => !b || x >= b.left && x <= b.right && y >= b.top && y <= b.bottom)) return true;
992
+ }
993
+ return false;
994
+ };
995
+ const clearFragment = (rect, parent, control = false) => {
996
+ const b = clipRect(rect, parent, page, true);
997
+ if (!b || rect.width < 0.1 || rect.height < 0.1) return false;
998
+ let left = Math.max(b.left, 0), top = Math.max(b.top, 0);
999
+ let right = Math.min(b.right, innerWidth), bottom = Math.min(b.bottom, innerHeight);
1000
+ for (let a = parent; a; a = a.parentElement) {
1001
+ const s = getComputedStyle(a), r = a.getBoundingClientRect();
1002
+ if (/auto|scroll/.test(s.overflowX)) { left = Math.max(left, r.left + a.clientLeft); right = Math.min(right, r.left + a.clientLeft + a.clientWidth); }
1003
+ if (/auto|scroll/.test(s.overflowY)) { top = Math.max(top, r.top + a.clientTop); bottom = Math.min(bottom, r.top + a.clientTop + a.clientHeight); }
1004
+ }
1005
+ if (left > rect.left + 0.5 || top > rect.top + 0.5 || right < rect.right - 0.5 || bottom < rect.bottom - 0.5) return false;
1006
+ // Hit testing quantizes fractional inline boxes to CSS pixels. Keep samples within fully
1007
+ // contained pixel cells: a narrow visible separator must not probe the adjacent inline box.
1008
+ // Full glyph extent was checked above; this does not admit clipped or covered fragments.
1009
+ const sampleAt = (start, end, fraction) => {
1010
+ const first = Math.ceil(start), last = Math.floor(end) - 1;
1011
+ return first <= last ? Math.max(first, Math.min(last, start + (end - start) * fraction)) : (start + end) / 2;
1012
+ };
1013
+ for (const q of [0.15, 0.5, 0.85]) for (const v of [0.15, 0.5, 0.85]) {
1014
+ const x = sampleAt(rect.left, rect.right, q), y = sampleAt(rect.top, rect.bottom, v);
1015
+ const hit = document.elementFromPoint(x, y);
1016
+ const transparentFallthrough = getComputedStyle(parent).pointerEvents === 'none' && hit?.contains(parent);
1017
+ if (!hit || !(hit === parent || (control ? parent.contains(hit) : transparentFallthrough)) || pseudoCover(parent,x,y)) return false;
1018
+ const link = parent.closest('a[href]');
1019
+ if (link && !(hit === link || link.contains(hit))) return false;
1020
+ }
1021
+ return true;
1022
+ };
1023
+ const sample = () => {
1024
+ check();
1025
+ pseudoPaint.clear();
1026
+ for (const n of textNodes(page)) {
1027
+ const parent = n.parentElement;
1028
+ if (!available(parent)) continue;
1029
+ let row = nodes.get(n);
1030
+ if (row && row.value !== n.nodeValue) throw new Error(`document text changed during traversal (${parent.tagName}: ${row.value.slice(0, 40)}); retry a settled state`);
1031
+ if (!row) {
1032
+ row = { value: n.nodeValue, parts: [...n.nodeValue.matchAll(/\S+/g)].map((m) => ({ start: m.index, end: m.index + m[0].length, count: 0, seen: new Set() })) };
1033
+ nodes.set(n, row);
1034
+ }
1035
+ if (fontPx(parent) < FONT_FLOOR) continue;
1036
+ const style = getComputedStyle(parent);
1037
+ inkContext.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
1038
+ for (const part of row.parts) {
1039
+ if (part.count > 0 && part.seen.size === part.count) continue;
1040
+ const r = document.createRange(); r.setStart(n, part.start); r.setEnd(n, part.end);
1041
+ const boxes = [...r.getClientRects()].filter((b) => b.width > 0 && b.height > 0);
1042
+ if (part.count && part.count !== boxes.length) throw new Error('document layout changed during traversal; retry a settled state');
1043
+ part.count = boxes.length;
1044
+ const ink = inkContext.measureText(n.nodeValue.slice(part.start, part.end));
1045
+ const fontHeight = ink.fontBoundingBoxAscent + ink.fontBoundingBoxDescent;
1046
+ if (!Number.isFinite(fontHeight) || fontHeight <= 0) throw new Error('document glyph font metrics are unavailable');
1047
+ boxes.forEach((b, i) => {
1048
+ // Range boxes include font leading, which may extend above a tight heading's line box.
1049
+ // Judge the actual ink ascent/descent, not invisible leading; retain all clip/hit checks.
1050
+ const scale = b.height / fontHeight;
1051
+ if (fontPx(parent) * scale < FONT_FLOOR) return;
1052
+ const baseline = b.bottom - ink.fontBoundingBoxDescent * scale;
1053
+ const top = baseline - ink.actualBoundingBoxAscent * scale;
1054
+ const bottom = baseline + ink.actualBoundingBoxDescent * scale;
1055
+ if (clearFragment({left:b.left,right:b.right,top,bottom,width:b.width,height:bottom-top}, parent)) part.seen.add(i);
1056
+ });
1057
+ }
1058
+ }
1059
+ for (const e of page.querySelectorAll('figure, svg, canvas, img, .equation')) {
1060
+ const b = e.getBoundingClientRect();
1061
+ if (available(e) && b.bottom > 0 && b.top < innerHeight && readable(e, page, 4, 4)) witnessed.add(e);
1062
+ }
1063
+ // Native inputs have no text nodes. Witness the actual control, never its metadata alone.
1064
+ for (const e of page.querySelectorAll('[data-control]')) {
1065
+ const target = e.matches('input, select, button, textarea, [role="slider"]') ? e
1066
+ : e.querySelector('input, select, button, textarea, [role="slider"]') ?? e;
1067
+ if (available(e) && available(target) && readable(target, page, 4, 4)
1068
+ && clearFragment(target.getBoundingClientRect(), target, true)) witnessed.add(e);
1069
+ }
1070
+ };
1071
+ const scrollable = (e) => {
1072
+ const s = getComputedStyle(e);
1073
+ return available(e) && ((/^(auto|scroll)$/.test(s.overflowY) && e.scrollHeight > e.clientHeight + 1) || (/^(auto|scroll)$/.test(s.overflowX) && e.scrollWidth > e.clientWidth + 1));
1074
+ };
1075
+ const bringSafely = (e) => {
1076
+ const x = scrollX, y = scrollY;
1077
+ bring(e); // only user-scrollable element ancestors; protect an unscrollable document too
1078
+ if (/hidden|clip/.test(getComputedStyle(document.documentElement).overflowY) || /hidden|clip/.test(getComputedStyle(document.body).overflowY)) winScroll(x, y);
1079
+ };
1080
+ const positions = (max, size) => {
1081
+ const out = [0], step = Math.max(16, Math.floor(size * 0.65));
1082
+ for (let p = step; p < max; p += step) {
1083
+ if (out.length > maxSamples) throw new Error('document scroll extent exceeds traversal budget');
1084
+ out.push(p);
1085
+ }
1086
+ if (max > 0) out.push(max);
1087
+ return out;
1088
+ };
1089
+ const sweep = async () => {
1090
+ sample();
1091
+ const containers = [...ancestorsUntil(page, document.body), page, ...page.querySelectorAll('*')].filter(scrollable);
1092
+ const de = document.scrollingElement;
1093
+ if (de && de.scrollHeight > innerHeight + 1 && !/hidden|clip/.test(getComputedStyle(de).overflowY) && !/hidden|clip/.test(getComputedStyle(document.body).overflowY)) containers.unshift(de);
1094
+ const unique = [...new Set(containers)];
1095
+ const owner = (e) => unique.find((p) => p !== e && p.contains(e) && !unique.some((q) => q !== p && q !== e && p.contains(q) && q.contains(e)));
1096
+ const walk = async (e) => {
1097
+ const s = getComputedStyle(e);
1098
+ const maxX = /auto|scroll/.test(s.overflowX) ? e.scrollWidth - e.clientWidth : 0;
1099
+ const maxY = e === de || /auto|scroll/.test(s.overflowY) ? e.scrollHeight - e.clientHeight : 0;
1100
+ for (const y of positions(maxY, Math.min(e.clientHeight, innerHeight))) for (const x of positions(maxX, Math.min(e.clientWidth, innerWidth))) {
1101
+ // A settled card/animated illustration may reduce incidental overflow between frames.
1102
+ // Clamp to the real extent; coverage still requires every fragment, never a partial word.
1103
+ const wantedX = Math.min(x, e.scrollWidth - e.clientWidth), wantedY = Math.min(y, e.scrollHeight - e.clientHeight);
1104
+ setScroll(e, wantedX, wantedY); await frame();
1105
+ if (Math.abs(e.scrollLeft - Math.min(wantedX, e.scrollWidth-e.clientWidth)) > 1 || Math.abs(e.scrollTop - Math.min(wantedY, e.scrollHeight-e.clientHeight)) > 1) throw new Error(`document scroller ${e.className} could not reach its requested position`);
1106
+ sample();
1107
+ for (const child of unique.filter((c) => owner(c) === e)) {
1108
+ const r = child.getBoundingClientRect();
1109
+ if (r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth) await walk(child);
1110
+ }
1111
+ }
1112
+ };
1113
+ for (const e of unique.filter((e) => !owner(e))) { bringSafely(e); await walk(e); }
1114
+ };
1115
+ const click = async (button) => {
1116
+ if (!button || !available(button) || button.disabled) throw new Error('document card control is unavailable');
1117
+ bringSafely(button); await frame();
1118
+ const r = button.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
1119
+ if (!hit || !(hit === button || button.contains(hit))) throw new Error('document card control is covered or clipped');
1120
+ button.click(); await frame();
1121
+ // Await the real transition, without changing attributes/styles or exposing a hidden face.
1122
+ const running = document.getAnimations().filter((a) => a.playState === 'running' && a.effect?.target?.closest?.('[data-flip-card], [data-card-deck]'));
1123
+ let timer;
1124
+ try {
1125
+ await Promise.race([Promise.all(running.map((a) => a.finished)), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('document card transition did not settle')), 1500); })]);
1126
+ } finally { clearTimeout(timer); }
1127
+ await frame();
1128
+ };
1129
+ const signature = (deck) => [...deck.querySelectorAll('[data-deck-card]')].map((c, i) => c.hidden ? '' : i).join(',');
1130
+ const visitFlips = async () => {
1131
+ for (const card of page.querySelectorAll('[data-flip-card]')) {
1132
+ if (!available(card)) continue;
1133
+ if (!flips.has(card)) flips.set(card, card.getAttribute('data-flip-open'));
1134
+ const open = card.getAttribute('data-flip-open') === 'true';
1135
+ await click(card.querySelector(`[data-flip-toggle="${open ? 'back' : 'front'}"]`));
1136
+ if ((card.getAttribute('data-flip-open') === 'true') === open) throw new Error('document flip control did not turn the card');
1137
+ await sweep();
1138
+ await click(card.querySelector(`[data-flip-toggle="${open ? 'front' : 'back'}"]`));
1139
+ }
1140
+ };
1141
+ try {
1142
+ await sweep(); await visitFlips();
1143
+ for (const deck of page.querySelectorAll('[data-deck-mode="carousel"]')) {
1144
+ decks.set(deck, signature(deck));
1145
+ const count = deck.querySelectorAll('[data-deck-card]').length;
1146
+ for (const direction of ['prev', 'next']) {
1147
+ for (let i = 0; ; i++) {
1148
+ const button = deck.querySelector(`[data-deck-${direction}]`);
1149
+ if (!button) throw new Error('document carousel has no traversal control');
1150
+ if (button.getAttribute('aria-disabled') === 'true') break;
1151
+ if (i > count) throw new Error('document carousel traversal did not terminate');
1152
+ const before = signature(deck); await click(button);
1153
+ if (signature(deck) === before) throw new Error('document carousel control did not expose another card');
1154
+ await sweep(); await visitFlips();
1155
+ }
1156
+ }
1157
+ }
1158
+ docEvidence = evidence;
1159
+ return G.docPage();
1160
+ } finally {
1161
+ docEvidence = null;
1162
+ try {
1163
+ for (const [card, open] of flips) if (card.isConnected && card.getAttribute('data-flip-open') !== open) await click(card.querySelector(`[data-flip-toggle="${open === 'true' ? 'front' : 'back'}"]`));
1164
+ for (const [deck, initial] of decks) {
1165
+ const count = deck.querySelectorAll('[data-deck-card]').length;
1166
+ for (let i = 0; signature(deck) !== initial; i++) {
1167
+ if (i > Math.min(count * 2, 32)) throw new Error('document carousel position could not be restored');
1168
+ const first = [...deck.querySelectorAll('[data-deck-card]')].findIndex((c) => !c.hidden);
1169
+ const target = initial.split(',').findIndex((v) => v !== '');
1170
+ await click(deck.querySelector(`[data-deck-${first > target ? 'prev' : 'next'}]`));
1171
+ }
1172
+ }
1173
+ } finally {
1174
+ for (const [e, [x, y]] of saved) if (e.isConnected) setScroll(e, x, y);
1175
+ winScroll(...windowPosition);
1176
+ if (focused === document.body) {
1177
+ if (document.activeElement !== focused) document.activeElement?.blur?.();
1178
+ } else if (focused?.isConnected) focused.focus({ preventScroll: true });
1179
+ await frame();
1180
+ if (focused && document.activeElement !== focused) throw new Error('document focus could not be restored');
1181
+ if (state.some((e) => !e.isConnected || e.getAttribute('aria-selected') !== 'true')) throw new Error('document tab state could not be restored');
1182
+ for (const [card, open] of flips) if (!card.isConnected || card.getAttribute('data-flip-open') !== open) throw new Error('document flip state could not be restored');
1183
+ for (const [deck, initial] of decks) if (!deck.isConnected || signature(deck) !== initial) throw new Error('document carousel state could not be restored');
1184
+ for (const [e, [x, y]] of saved) if (e.isConnected && (Math.abs(e.scrollLeft - x) > 1 || Math.abs(e.scrollTop - y) > 1)) throw new Error('document scroll position could not be restored');
1185
+ }
1186
+ }
1187
+ };
1188
+
892
1189
  /** A tall view must move when scrolled (S1, G5). Restores the positions it changed. */
893
1190
  G.scrolls = () => {
894
1191
  const out = [];
package/gate/run.mjs CHANGED
@@ -480,8 +480,12 @@ export async function runGate(o) {
480
480
  if (f.refsDeclaredNoBlock) fail('ADR-0017.4', where, `${f.refsDeclaredNoBlock} section(s) cite references the page never lists (no references block in their page or tab)`);
481
481
  if (f.uncaptioned) fail('ADR-0017.2', where, `${f.uncaptioned} equation(s) without a caption`);
482
482
  if (s.walkState?.doc) {
483
- const d = await page.evaluate(() => window.__caosGate.docPage());
484
- if (d) s.walkState.doc.push(d);
483
+ try {
484
+ const d = await page.evaluate(() => window.__caosGate.readDocument());
485
+ if (d) s.walkState.doc.push(d);
486
+ } catch (e) {
487
+ fail('ADR-0017.2', where, `document readability traversal could not complete: ${e.message.split('\n')[0]}`);
488
+ }
485
489
  }
486
490
  for (const r of f.repeatedTicks ?? []) {
487
491
  // named x ticks (UPlotChart declares the repeated names): a name used twice, wherever the two ticks sit on the axis
package/package.json CHANGED
@@ -1,100 +1,100 @@
1
- {
2
- "name": "@fasl-work/caos-app-shell",
3
- "version": "0.19.10",
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
- "license": "MIT",
6
- "author": "Felipe Santibáñez-Leal",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/fsantibanezleal/CAOS_APP_SHELL.git"
10
- },
11
- "type": "module",
12
- "publishConfig": {
13
- "access": "public"
14
- },
15
- "sideEffects": [
16
- "*.css"
17
- ],
18
- "exports": {
19
- ".": {
20
- "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
22
- },
23
- "./chart": {
24
- "types": "./dist/chart.d.ts",
25
- "import": "./dist/chart.js"
26
- },
27
- "./keys": {
28
- "types": "./dist/keys.d.ts",
29
- "import": "./dist/keys.js"
30
- },
31
- "./styles.css": "./styles.css",
32
- "./chart.css": "./chart.css",
33
- "./reserved-classes.json": "./reserved-classes.json",
34
- "./executive.schema.json": "./src/content/executive.schema.json",
35
- "./serve": "./gate/serve.mjs",
36
- "./gate": "./gate/run.mjs",
37
- "./package.json": "./package.json"
38
- },
39
- "files": [
40
- "dist",
41
- "styles.css",
42
- "chart.css",
43
- "bin",
44
- "gate",
45
- "reserved-classes.json",
46
- "src/content/executive.schema.json",
47
- "VERSION",
48
- "CHANGELOG.md"
49
- ],
50
- "scripts": {
51
- "build": "tsup",
52
- "test": "node --import tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
53
- "test:browser": "node --import tsx --test \"e2e/**/*.test.ts\"",
54
- "prepare": "npm run build",
55
- "prepublishOnly": "npm run build",
56
- "prebuild": "node scripts/reserved-classes.mjs",
57
- "test:gate": "node --test test/gate-selftest.mjs",
58
- "check:sdd": "python scripts/check_sdd.py ."
59
- },
60
- "peerDependencies": {
61
- "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
62
- "lucide-react": ">=0.400.0",
63
- "react": "^18 || ^19",
64
- "react-dom": "^18 || ^19",
65
- "react-router": "^6 || ^7 || ^8",
66
- "zustand": "^4 || ^5",
67
- "playwright": ">=1.40",
68
- "uplot": "^1.6.30"
69
- },
70
- "devDependencies": {
71
- "@playwright/test": "1.63.0",
72
- "@types/react": "^19.0.0",
73
- "@types/react-dom": "^19.0.0",
74
- "esbuild": "0.28.1",
75
- "katex": "0.19.0",
76
- "lucide-react": "1.43.0",
77
- "react": "^19.0.0",
78
- "react-dom": "^19.0.0",
79
- "react-router": "^8.3.0",
80
- "tsup": "^8.3.5",
81
- "tsx": "^4.23.0",
82
- "typescript": "^5.7.3",
83
- "zustand": "^5.0.2",
84
- "uplot": "1.6.32"
85
- },
86
- "overrides": {
87
- "esbuild": "0.28.1"
88
- },
89
- "bin": {
90
- "caos-shell-gate": "bin/caos-shell-gate.mjs"
91
- },
92
- "peerDependenciesMeta": {
93
- "playwright": {
94
- "optional": true
95
- },
96
- "uplot": {
97
- "optional": true
98
- }
99
- }
100
- }
1
+ {
2
+ "name": "@fasl-work/caos-app-shell",
3
+ "version": "0.19.11",
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
+ "license": "MIT",
6
+ "author": "Felipe Santibáñez-Leal",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/fsantibanezleal/CAOS_APP_SHELL.git"
10
+ },
11
+ "type": "module",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "sideEffects": [
16
+ "*.css"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./chart": {
24
+ "types": "./dist/chart.d.ts",
25
+ "import": "./dist/chart.js"
26
+ },
27
+ "./keys": {
28
+ "types": "./dist/keys.d.ts",
29
+ "import": "./dist/keys.js"
30
+ },
31
+ "./styles.css": "./styles.css",
32
+ "./chart.css": "./chart.css",
33
+ "./reserved-classes.json": "./reserved-classes.json",
34
+ "./executive.schema.json": "./src/content/executive.schema.json",
35
+ "./serve": "./gate/serve.mjs",
36
+ "./gate": "./gate/run.mjs",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "styles.css",
42
+ "chart.css",
43
+ "bin",
44
+ "gate",
45
+ "reserved-classes.json",
46
+ "src/content/executive.schema.json",
47
+ "VERSION",
48
+ "CHANGELOG.md"
49
+ ],
50
+ "scripts": {
51
+ "build": "tsup",
52
+ "test": "node --import tsx --test \"test/**/*.test.ts\" \"test/**/*.test.tsx\"",
53
+ "test:browser": "node --import tsx --test \"e2e/**/*.test.ts\"",
54
+ "prepare": "npm run build",
55
+ "prepublishOnly": "npm run build",
56
+ "prebuild": "node scripts/reserved-classes.mjs",
57
+ "test:gate": "node --test test/gate-selftest.mjs",
58
+ "check:sdd": "python scripts/check_sdd.py ."
59
+ },
60
+ "peerDependencies": {
61
+ "katex": "^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0",
62
+ "lucide-react": ">=0.400.0",
63
+ "react": "^18 || ^19",
64
+ "react-dom": "^18 || ^19",
65
+ "react-router": "^6 || ^7 || ^8",
66
+ "zustand": "^4 || ^5",
67
+ "playwright": ">=1.40",
68
+ "uplot": "^1.6.30"
69
+ },
70
+ "devDependencies": {
71
+ "@playwright/test": "1.63.0",
72
+ "@types/react": "^19.0.0",
73
+ "@types/react-dom": "^19.0.0",
74
+ "esbuild": "0.28.1",
75
+ "katex": "0.19.0",
76
+ "lucide-react": "1.43.0",
77
+ "react": "^19.0.0",
78
+ "react-dom": "^19.0.0",
79
+ "react-router": "^8.3.0",
80
+ "tsup": "^8.3.5",
81
+ "tsx": "^4.23.0",
82
+ "typescript": "^5.7.3",
83
+ "zustand": "^5.0.2",
84
+ "uplot": "1.6.32"
85
+ },
86
+ "overrides": {
87
+ "esbuild": "0.28.1"
88
+ },
89
+ "bin": {
90
+ "caos-shell-gate": "bin/caos-shell-gate.mjs"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "playwright": {
94
+ "optional": true
95
+ },
96
+ "uplot": {
97
+ "optional": true
98
+ }
99
+ }
100
+ }