@fasl-work/caos-app-shell 0.19.10 → 0.19.12
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 +23 -0
- package/VERSION +1 -1
- package/gate/artifacts.mjs +29 -0
- package/gate/inpage.mjs +306 -9
- package/gate/run.mjs +13 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## [0.19.012] - 2026-11-01
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- Verify complete JSON artifact documents when navigation interrupts a page-owned body read or a conditional response has no body. One bounded GET preserves the observed URL and credentials while removing range/cache preconditions; transport retries are recorded. HTML, empty, malformed and failed responses remain failures.
|
|
6
|
+
- Keep artifact validation independent of page-context lifetime without changing readiness declarations, idle-rate limits, traversal rules or component styles.
|
|
7
|
+
- Permit the gate self-test to use the configured external temporary root; CI uses its runner temporary root.
|
|
8
|
+
|
|
9
|
+
### Adoption
|
|
10
|
+
|
|
11
|
+
- Pin npm 0.19.12 and run the installed gate against the deployed origin. Components and scientific calculations are unchanged.
|
|
12
|
+
|
|
13
|
+
## [0.19.011] - 2026-11-01
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- 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).
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
### Adoption
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
1
24
|
## [0.19.010] - 2026-10-31
|
|
2
25
|
|
|
3
26
|
### Fixed
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.19.
|
|
1
|
+
0.19.012
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Complete JSON verification, including a bounded retry after a page-owned read is interrupted. */
|
|
2
|
+
export async function readJsonArtifact(response) {
|
|
3
|
+
const url = response.url();
|
|
4
|
+
let text = '', transportRetry = false, originalReadError;
|
|
5
|
+
try {
|
|
6
|
+
// The partial header map omits browser cookies; capture complete on-wire headers before teardown.
|
|
7
|
+
const headers = { ...(await response.request().allHeaders()) };
|
|
8
|
+
try {
|
|
9
|
+
if (response.status() === 304) throw new Error('Conditional response has no complete document body');
|
|
10
|
+
text = await response.text();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
transportRetry = true;
|
|
13
|
+
originalReadError = String(error);
|
|
14
|
+
// Request the full original document, retaining credentials but not cache/range preconditions.
|
|
15
|
+
for (const name of Object.keys(headers)) {
|
|
16
|
+
if (['if-none-match', 'if-modified-since', 'range', 'if-range', 'content-length', 'host', 'connection'].includes(name.toLowerCase())) delete headers[name];
|
|
17
|
+
}
|
|
18
|
+
const retry = await fetch(url, { headers, redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(10000) });
|
|
19
|
+
if (!retry.ok) throw new Error(`Complete artifact GET answered HTTP ${retry.status}`);
|
|
20
|
+
text = await retry.text();
|
|
21
|
+
}
|
|
22
|
+
const first = text.trimStart().slice(0, 1);
|
|
23
|
+
const value = JSON.parse(text);
|
|
24
|
+
if (value === null || typeof value !== 'object') throw new Error('Artifact must be a JSON object or array');
|
|
25
|
+
return { ok: true, first, transportRetry, ...(originalReadError ? { originalReadError } : {}) };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return { ok: false, first: text.trimStart().slice(0, 1), transportRetry, error: String(error), ...(originalReadError ? { originalReadError } : {}) };
|
|
28
|
+
}
|
|
29
|
+
}
|
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`
|
|
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
|
|
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, ' '); //
|
|
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
|
-
|
|
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 (!
|
|
609
|
+
if (!docSeen(el)) return false;
|
|
608
610
|
const value = el.querySelector('.caos-stat-value');
|
|
609
|
-
return /\d/.test((value
|
|
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) =>
|
|
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
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
|
|
39
39
|
// references).
|
|
40
40
|
|
|
41
|
+
import { readJsonArtifact } from './artifacts.mjs';
|
|
41
42
|
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
42
43
|
import { join } from 'node:path';
|
|
43
44
|
import { decodePng, paintedBox, unionArea } from './png.mjs';
|
|
@@ -135,6 +136,7 @@ export async function runGate(o) {
|
|
|
135
136
|
let states = 0;
|
|
136
137
|
const fail = (check, where, message) => failures.push({ check, route: where.route ?? '', mode: where.mode ?? '', trail: where.trail ?? '', message });
|
|
137
138
|
const artifacts = new Map();
|
|
139
|
+
const artifactChecks = [];
|
|
138
140
|
|
|
139
141
|
const browser = await chromium.launch();
|
|
140
142
|
const openContext = async ([w, h], theme, lang, wide = false) => {
|
|
@@ -193,10 +195,7 @@ export async function runGate(o) {
|
|
|
193
195
|
if ((type === 'fetch' || type === 'xhr') && pathname.endsWith('.json') && r.status() < 400 && !artifacts.has(pathname)) {
|
|
194
196
|
artifacts.set(
|
|
195
197
|
pathname,
|
|
196
|
-
r
|
|
197
|
-
(t) => t.trimStart().slice(0, 1),
|
|
198
|
-
() => '',
|
|
199
|
-
),
|
|
198
|
+
readJsonArtifact(r),
|
|
200
199
|
);
|
|
201
200
|
}
|
|
202
201
|
});
|
|
@@ -444,8 +443,9 @@ export async function runGate(o) {
|
|
|
444
443
|
|
|
445
444
|
// ---- G4: artifacts answered as JSON.
|
|
446
445
|
for (const [pathname, first] of artifacts) {
|
|
447
|
-
const
|
|
448
|
-
|
|
446
|
+
const result = await first;
|
|
447
|
+
artifactChecks.push({ path: pathname, ...result });
|
|
448
|
+
if (!result.ok) fail('G4', { route: pathname }, `the artifact ${pathname} did not answer JSON (starts with "${result.first}"; ${result.error ?? 'the HTML fallback?'})`);
|
|
449
449
|
}
|
|
450
450
|
} finally {
|
|
451
451
|
await browser.close();
|
|
@@ -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
|
-
|
|
484
|
-
|
|
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
|
|
@@ -1127,6 +1131,7 @@ export async function runGate(o) {
|
|
|
1127
1131
|
checks,
|
|
1128
1132
|
captures: captures.length,
|
|
1129
1133
|
failures,
|
|
1134
|
+
artifactChecks,
|
|
1130
1135
|
};
|
|
1131
1136
|
writeFileSync(join(opt.out, 'gate-report.json'), JSON.stringify(report, null, 2));
|
|
1132
1137
|
// G14: the captures are for a person to read (the gate measures, it does not judge content); one page shows them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fasl-work/caos-app-shell",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.12",
|
|
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",
|