@fasl-work/caos-app-shell 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/bin/caos-shell-gate.mjs +3 -1
- package/dist/index.d.ts +180 -2
- package/dist/index.js +547 -122
- package/gate/areas.mjs +67 -0
- package/gate/inpage.mjs +151 -5
- package/gate/run.mjs +260 -4
- package/package.json +1 -1
- package/reserved-classes.json +44 -0
- package/styles.css +69 -5
package/gate/areas.mjs
CHANGED
|
@@ -629,3 +629,70 @@ export function docFloorFailures({ route, lang, states, expectedTopics }) {
|
|
|
629
629
|
}
|
|
630
630
|
return { failures, waivers, topics: topics.size };
|
|
631
631
|
}
|
|
632
|
+
|
|
633
|
+
// ------------------------------------------------------------------------------------------ flip cards and animation
|
|
634
|
+
// Shell 0.14.0 (CAOS_MANAGE plans/app-shell/0-14-0-plan.md decisions 3 and 4).
|
|
635
|
+
|
|
636
|
+
/** The limits of a small card, in the interface language; a copy of FLIP_LIMITS of src/content/FlipCard.tsx (test/flipCard.test.tsx keeps them equal). */
|
|
637
|
+
export const FLIP_LIMITS = { title: { en: 8, es: 10 }, back: { en: 40, es: 50 } };
|
|
638
|
+
|
|
639
|
+
/** The longest an illustration may play: one play, or one cycle of a loop, with its delay, in seconds. */
|
|
640
|
+
export const ANIMATION_MAX_SECONDS = 10;
|
|
641
|
+
|
|
642
|
+
const seconds = (v) => {
|
|
643
|
+
const m = /^\s*(\d*\.?\d+)\s*(ms|s)?\s*$/i.exec(String(v ?? ''));
|
|
644
|
+
return m ? Number(m[1]) / (m[2]?.toLowerCase() === 'ms' ? 1000 : 1) : 0;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* What an SVG file says about its animation: `css` (keyframes or an `animation` declaration), `smil` (animate, animateTransform,
|
|
649
|
+
* animateMotion, set), whether it carries a `@media (prefers-reduced-motion: reduce)` block, whether that block stops the CSS
|
|
650
|
+
* animation (`animation: none` or `animation-name: none`), and the longest cycle with its longest delay, in seconds (a loop counts one
|
|
651
|
+
* cycle; an indefinite or absent duration counts as zero).
|
|
652
|
+
*/
|
|
653
|
+
export function analyseSvgAnimation(text) {
|
|
654
|
+
const src = String(text ?? '');
|
|
655
|
+
const styles = [...src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).concat([...src.matchAll(/\sstyle="([^"]*)"/gi)].map((m) => m[1]));
|
|
656
|
+
const css = styles.join('\n');
|
|
657
|
+
const smilEls = [...src.matchAll(/<(animate|animateTransform|animateMotion|set)\b([^>]*)>/gi)];
|
|
658
|
+
const hasKeyframes = /@keyframes/i.test(css);
|
|
659
|
+
const hasAnimationDecl = /(^|[;{\s])animation(-name)?\s*:\s*(?!none\b)[^;}]+/i.test(css.replace(/@media[^{]*\(prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]*?\}\s*\}/gi, ''));
|
|
660
|
+
const cssAnimated = hasKeyframes || hasAnimationDecl;
|
|
661
|
+
const smil = smilEls.length > 0;
|
|
662
|
+
const block = /@media\s*\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)\s*\{([\s\S]*?\}\s*)\}/i.exec(css);
|
|
663
|
+
const reducedBlock = Boolean(block);
|
|
664
|
+
const neutralises = Boolean(block && /animation(-name)?\s*:\s*none/i.test(block[1]));
|
|
665
|
+
let maxDur = 0;
|
|
666
|
+
let maxDelay = 0;
|
|
667
|
+
let maxCount = 1;
|
|
668
|
+
const outsideReduced = css.replace(/@media[^{]*\(prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]*?\}\s*\}/gi, '');
|
|
669
|
+
// the shorthand `animation: name 8s 3s infinite`: the first time is the duration, the second the delay
|
|
670
|
+
for (const m of outsideReduced.matchAll(/(?:^|[;{\s])animation\s*:\s*([^;}]+)/gi)) {
|
|
671
|
+
const times = [...m[1].matchAll(/(\d*\.?\d+)(ms|s)\b/gi)].map((t) => seconds(t[0]));
|
|
672
|
+
maxDur = Math.max(maxDur, times[0] ?? 0);
|
|
673
|
+
maxDelay = Math.max(maxDelay, times[1] ?? 0);
|
|
674
|
+
// a finite count of cycles plays that many times (infinite counts one)
|
|
675
|
+
if (!/infinite/i.test(m[1])) maxCount = Math.max(maxCount, Number((/(?:^|\s)(\d*\.?\d+)(?=\s|$)/.exec(m[1].replace(/(\d*\.?\d+)(ms|s)/gi, ' ')) ?? [])[1] ?? 1));
|
|
676
|
+
}
|
|
677
|
+
for (const m of outsideReduced.matchAll(/(?:^|[;{\s])animation-iteration-count\s*:\s*([^;}]+)/gi)) if (!/infinite/i.test(m[1])) maxCount = Math.max(maxCount, Number(m[1].split(',')[0]) || 1);
|
|
678
|
+
for (const m of outsideReduced.matchAll(/(?:^|[;{\s])animation-duration\s*:\s*([^;}]+)/gi)) maxDur = Math.max(maxDur, seconds(m[1].split(',')[0]));
|
|
679
|
+
for (const m of outsideReduced.matchAll(/(?:^|[;{\s])animation-delay\s*:\s*([^;}]+)/gi)) maxDelay = Math.max(maxDelay, seconds(m[1].split(',')[0]));
|
|
680
|
+
// the longest duration with the longest delay: a safe bound (the rules of the file are not matched to their elements)
|
|
681
|
+
let longest = maxDur * maxCount + maxDelay;
|
|
682
|
+
for (const m of smilEls) {
|
|
683
|
+
const attr = (n) => new RegExp(`\\s${n}="([^"]*)"`, 'i').exec(m[2])?.[1];
|
|
684
|
+
const reps = Number(attr('repeatCount')) || 1;
|
|
685
|
+
longest = Math.max(longest, seconds(attr('dur')) * reps + seconds(attr('begin')));
|
|
686
|
+
}
|
|
687
|
+
return { animated: cssAnimated || smil, css: cssAnimated, smil, reducedBlock, neutralises, seconds: Math.round(longest * 1000) / 1000 };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The problems of one illustration's animation (empty for a still); `name` is how the page calls it. */
|
|
691
|
+
export function animationFailures(a, name) {
|
|
692
|
+
if (!a || !a.animated) return [];
|
|
693
|
+
const out = [];
|
|
694
|
+
if (!a.reducedBlock) out.push(`illustration "${name}" is animated and has no @media (prefers-reduced-motion: reduce) block that shows its static final frame`);
|
|
695
|
+
else if (a.css && !a.neutralises) out.push(`illustration "${name}" is animated and its reduced-motion block does not stop its CSS animation (animation: none)`);
|
|
696
|
+
if (a.seconds > ANIMATION_MAX_SECONDS) out.push(`illustration "${name}" plays for ${Math.round(a.seconds)} s; the limit is ${ANIMATION_MAX_SECONDS} s`);
|
|
697
|
+
return out;
|
|
698
|
+
}
|
package/gate/inpage.mjs
CHANGED
|
@@ -622,6 +622,20 @@ export function installLib() {
|
|
|
622
622
|
const executive = (() => {
|
|
623
623
|
if (!execRoots.length) return { count: 0 };
|
|
624
624
|
const root = execRoots[0];
|
|
625
|
+
// 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
|
|
626
|
+
// cannot reach. Any other card that is not seen is not shown, as before 0.14.0.
|
|
627
|
+
const hiddenByDeck = (el) => {
|
|
628
|
+
const li = el.closest('[data-deck-card]');
|
|
629
|
+
return Boolean(li) && li.hidden && Boolean(li.closest('[data-deck-mode="carousel"]'));
|
|
630
|
+
};
|
|
631
|
+
// The back of a flip card is content the reader gets by turning the card: read every field with the cards turned too (the
|
|
632
|
+
// same `seen` and `innerText` as for any text), and call a field readable when it is seen in either face.
|
|
633
|
+
const turned = new Map();
|
|
634
|
+
withFlipsTurned(() => {
|
|
635
|
+
for (const e of root.querySelectorAll('[data-exec-field], [data-exec-cdef]')) turned.set(e, { text: textOf(e), seen: seen(e) });
|
|
636
|
+
});
|
|
637
|
+
const fullText = (e) => textOf(e) || (turned.get(e) ? turned.get(e).text : '');
|
|
638
|
+
const isSeen = (e) => seen(e) || Boolean(turned.get(e) && turned.get(e).seen);
|
|
625
639
|
const isTok = (v) => {
|
|
626
640
|
const x = String(v || '').trim().toLowerCase();
|
|
627
641
|
return !x || ['none', 'currentcolor', 'transparent', 'inherit', 'initial', 'unset', 'context-fill', 'context-stroke'].includes(x) || x.startsWith('var(--') || x.startsWith('url(');
|
|
@@ -674,7 +688,8 @@ export function installLib() {
|
|
|
674
688
|
});
|
|
675
689
|
// the rows each part of the view shows in this state (the floors), counted once per kind
|
|
676
690
|
const items = {};
|
|
677
|
-
|
|
691
|
+
// a card of a carousel counts although the window hides it (G16 proves every card can be reached); a flip card counts whole
|
|
692
|
+
for (const el of root.querySelectorAll('[data-exec-item]')) if (seen(el) || hiddenByDeck(el)) items[el.getAttribute('data-exec-item')] = (items[el.getAttribute('data-exec-item')] ?? 0) + 1;
|
|
678
693
|
const landingEl = root.querySelector('[data-exec-landing]');
|
|
679
694
|
const landing = {
|
|
680
695
|
present: Boolean(landingEl && seen(landingEl)),
|
|
@@ -683,8 +698,8 @@ export function installLib() {
|
|
|
683
698
|
};
|
|
684
699
|
// the 4 C's a reader sees in the proposal: the name, the definition and what each means for the proposal
|
|
685
700
|
const cs = [...root.querySelectorAll('[data-exec-c]')]
|
|
686
|
-
.filter(seen)
|
|
687
|
-
.map((li) => ({ name: textOf(li.querySelector('[data-exec-cname]') || li), def:
|
|
701
|
+
.filter((li) => seen(li) || hiddenByDeck(li))
|
|
702
|
+
.map((li) => ({ name: textOf(li.querySelector('[data-exec-cname]') || li), def: fullText(li.querySelector('[data-exec-cdef]') || li), applies: fullText(li.querySelector('[data-exec-field$=".applies"]') || li.querySelector('span') || li) }));
|
|
688
703
|
const links = [...root.querySelectorAll('[data-exec-links] a')].map((a) => ({ kind: a.getAttribute('data-exec-link'), text: textOf(a), href: a.getAttribute('href') }));
|
|
689
704
|
// the sub-tabs of the view: their labels (all of them, shown or not), and the one selected
|
|
690
705
|
const tabEls = [...root.querySelectorAll('[data-exec-tabs] [role="tab"]')];
|
|
@@ -712,7 +727,7 @@ export function installLib() {
|
|
|
712
727
|
items,
|
|
713
728
|
cs,
|
|
714
729
|
links,
|
|
715
|
-
fields: [...root.querySelectorAll('[data-exec-field]')].map((e) => ({ path: e.getAttribute('data-exec-field'), text:
|
|
730
|
+
fields: [...root.querySelectorAll('[data-exec-field]')].map((e) => ({ path: e.getAttribute('data-exec-field'), text: fullText(e), readable: isSeen(e) || hiddenByDeck(e) })),
|
|
716
731
|
// the blocks of this state narrower than the document column (an illustration may stay centred inside its figure)
|
|
717
732
|
narrow: [...root.querySelectorAll('.subtabs, .caos-exec-notes-wrap, [data-exec-section], [data-exec-section] > *')]
|
|
718
733
|
.filter((e) => visible(e))
|
|
@@ -721,7 +736,7 @@ export function installLib() {
|
|
|
721
736
|
.map(([name, w]) => `${name} ${w}px`),
|
|
722
737
|
column: columnWidth,
|
|
723
738
|
// everything the view prints in this state, for the check that no rubric label or question is on the page
|
|
724
|
-
text: textOf(root),
|
|
739
|
+
text: `${textOf(root)} ${[...turned.entries()].filter(([e]) => e.closest('[data-flip-back]')).map(([, v]) => v.text).join(' ')}`,
|
|
725
740
|
tabs: tabEls.map(tabLabel),
|
|
726
741
|
activeTab: tabEls.findIndex((t) => t.getAttribute('aria-selected') === 'true'),
|
|
727
742
|
// each source note: the link it holds (its target and its text) and the text beside the link
|
|
@@ -909,6 +924,137 @@ export function installLib() {
|
|
|
909
924
|
return { checked, unreachable: out.slice(0, 8), unreachableCount: out.length };
|
|
910
925
|
};
|
|
911
926
|
|
|
927
|
+
/**
|
|
928
|
+
* Runs `fn` with every closed flip card turned (and no transition, so the faces are settled), then turns them back. The gate
|
|
929
|
+
* reads a back with the same measures it uses for any text; a back the card can never show stays unreadable.
|
|
930
|
+
*/
|
|
931
|
+
const withFlipsTurned = (fn) => {
|
|
932
|
+
const cards = [...document.querySelectorAll('[data-flip-card][data-flip-open="false"]')];
|
|
933
|
+
if (cards.length === 0) return fn();
|
|
934
|
+
const st = document.createElement('style');
|
|
935
|
+
st.textContent = '.caos-flip, .caos-flip * { transition: none !important; }';
|
|
936
|
+
document.head.append(st);
|
|
937
|
+
for (const c of cards) c.setAttribute('data-flip-open', 'true');
|
|
938
|
+
try {
|
|
939
|
+
return fn();
|
|
940
|
+
} finally {
|
|
941
|
+
for (const c of cards) c.setAttribute('data-flip-open', 'false');
|
|
942
|
+
st.remove();
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
// ---- G16: flip cards and card decks (shell 0.14.0)
|
|
946
|
+
const inTree = (el) => {
|
|
947
|
+
// reachable by assistive technology and by the keyboard: rendered, and no ancestor hides it or makes it inert
|
|
948
|
+
if (!el.checkVisibility({ checkVisibilityCSS: true })) return false;
|
|
949
|
+
for (let e = el; e; e = e.parentElement) if (e.getAttribute('aria-hidden') === 'true' || e.hasAttribute('inert') || e.hidden) return false;
|
|
950
|
+
return true;
|
|
951
|
+
};
|
|
952
|
+
/** A control a keyboard or a pointer can reach: rendered, no `inert` ancestor. `aria-hidden` does not take a control out of the tab order. */
|
|
953
|
+
const reachable = (el) => {
|
|
954
|
+
if (!el.checkVisibility({ checkVisibilityCSS: true })) return false;
|
|
955
|
+
for (let e = el; e; e = e.parentElement) if (e.hasAttribute('inert')) return false;
|
|
956
|
+
return true;
|
|
957
|
+
};
|
|
958
|
+
const countWords = (t) => String(t || '').split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
959
|
+
/** What the flip cards and the card decks of this state are: their words, their buttons, what the hidden face exposes, where a deck ends. */
|
|
960
|
+
G.cards = () => {
|
|
961
|
+
const squash = (e) => (e ? (e.textContent || '').replace(/\s+/g, ' ').trim() : '');
|
|
962
|
+
const vw = window.innerWidth;
|
|
963
|
+
const cards = [...document.querySelectorAll('[data-flip-card]')].map((card, index) => {
|
|
964
|
+
const open = card.getAttribute('data-flip-open') === 'true';
|
|
965
|
+
const front = card.querySelector('.caos-flip-front');
|
|
966
|
+
const back = card.querySelector('[data-flip-back]');
|
|
967
|
+
const hiddenFace = open ? front : back;
|
|
968
|
+
const toggles = [...card.querySelectorAll('[data-flip-toggle]')];
|
|
969
|
+
const owned = toggles.length > 0 && toggles.every((t) => { const id = t.getAttribute('aria-controls'); return id && document.getElementById(id) && t.hasAttribute('aria-expanded'); });
|
|
970
|
+
const exposed = hiddenFace ? hiddenFace.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])') : [];
|
|
971
|
+
return {
|
|
972
|
+
index,
|
|
973
|
+
label: squash(card.querySelector('[data-flip-title]')).slice(0, 40) || `card ${index + 1}`,
|
|
974
|
+
titleWords: countWords(squash(card.querySelector('[data-flip-title]'))),
|
|
975
|
+
backWords: countWords(squash(card.querySelector('[data-flip-content]'))),
|
|
976
|
+
open,
|
|
977
|
+
shown: inTree(card) || card.getClientRects().length > 0,
|
|
978
|
+
buttons: toggles.length > 0 && toggles.every((t) => t.tagName === 'BUTTON'),
|
|
979
|
+
wired: owned,
|
|
980
|
+
hiddenInTree: Boolean(hiddenFace) && inTree(hiddenFace),
|
|
981
|
+
hiddenFocusable: [...exposed].some(reachable),
|
|
982
|
+
contentShown: (() => { const c = card.querySelector('[data-flip-content]'); if (!c || !c.checkVisibility({ checkVisibilityCSS: true })) return false; const r = c.getBoundingClientRect(); return r.height > 0 && r.width > 0; })(),
|
|
983
|
+
deck: Array.from(document.querySelectorAll('[data-card-deck]')).findIndex((d) => d.contains(card)),
|
|
984
|
+
};
|
|
985
|
+
});
|
|
986
|
+
const decks = [...document.querySelectorAll('[data-card-deck]')].map((deck, index) => {
|
|
987
|
+
const track = deck.querySelector('[data-deck-track]');
|
|
988
|
+
const box = deck.getBoundingClientRect();
|
|
989
|
+
const lis = [...deck.querySelectorAll('[data-deck-card]')];
|
|
990
|
+
const shownCards = lis.filter((li) => li.checkVisibility({ checkVisibilityCSS: true }));
|
|
991
|
+
const outside = shownCards.filter((li) => { const r = li.getBoundingClientRect(); return r.right > box.right + 1 || r.left < box.left - 1; });
|
|
992
|
+
const next = deck.querySelector('[data-deck-next]');
|
|
993
|
+
const prev = deck.querySelector('[data-deck-prev]');
|
|
994
|
+
return {
|
|
995
|
+
index,
|
|
996
|
+
label: deck.getAttribute('aria-label') || (track && track.getAttribute('aria-label')) || `deck ${index + 1}`,
|
|
997
|
+
mode: deck.getAttribute('data-deck-mode'),
|
|
998
|
+
total: lis.length,
|
|
999
|
+
visible: lis.map((li, i) => (li.checkVisibility({ checkVisibilityCSS: true }) ? i : -1)).filter((i) => i >= 0),
|
|
1000
|
+
flip: deck.hasAttribute('data-deck-flip'),
|
|
1001
|
+
controls: Boolean(next && prev) && next.checkVisibility({ checkVisibilityCSS: true }) && prev.checkVisibility({ checkVisibilityCSS: true }),
|
|
1002
|
+
atEnd: next ? next.getAttribute('aria-disabled') === 'true' : true,
|
|
1003
|
+
atStart: prev ? prev.getAttribute('aria-disabled') === 'true' : true,
|
|
1004
|
+
sideways: Math.max(deck.scrollWidth - deck.clientWidth, track ? track.scrollWidth - track.clientWidth : 0) > 1 || box.right > vw + 1 || outside.length > 0 || (track ? track.getBoundingClientRect().right > box.right + 1 : false),
|
|
1005
|
+
outside: outside.length,
|
|
1006
|
+
docW: document.documentElement.scrollWidth,
|
|
1007
|
+
vw,
|
|
1008
|
+
};
|
|
1009
|
+
});
|
|
1010
|
+
return { lang: (document.documentElement.lang || 'en').slice(0, 2) === 'es' ? 'es' : 'en', cards, decks };
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
// ---- G17: animated illustrations (shell 0.14.0)
|
|
1014
|
+
/** The SVG drawings of the page (the executive illustrations and the figures): their markup, and what still moves in them. */
|
|
1015
|
+
G.animations = () => {
|
|
1016
|
+
const items = [...document.querySelectorAll('figure svg, [data-exec-illustration] svg')].filter((v, i, a) => a.indexOf(v) === i).map((svg, i) => {
|
|
1017
|
+
const fig = svg.closest('[data-exec-illustration]');
|
|
1018
|
+
const smil = svg.querySelector('animate, animateTransform, animateMotion, set');
|
|
1019
|
+
const css = document.getAnimations().filter((a) => a.effect && a.effect.target && svg.contains(a.effect.target) && a.playState === 'running').length;
|
|
1020
|
+
return { name: fig ? fig.getAttribute('data-exec-illustration') : `figure ${i + 1}`, svg: svg.outerHTML, runningCss: css, runningSmil: smil ? !svg.animationsPaused() : false, smil: Boolean(smil) };
|
|
1021
|
+
});
|
|
1022
|
+
return { items };
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
/** The documentation figures drawn by an `img` of an SVG file: their sources (the runner fetches and analyses them). */
|
|
1026
|
+
G.figureImages = () => [...document.querySelectorAll('figure img')].filter((i) => /\.svg(\?|#|$)/i.test(i.getAttribute('src') || '')).map((i, k) => ({ name: `figure ${k + 1}`, src: i.currentSrc || i.src }));
|
|
1027
|
+
|
|
1028
|
+
// ---- G18: Focus mode for the App (shell 0.14.0)
|
|
1029
|
+
/** The App or Focus mode as the reader meets it: which one, the case and the group, the URL, the page and the rail. */
|
|
1030
|
+
G.focusFacts = () => {
|
|
1031
|
+
const shell = document.querySelector('[data-focus-shell]');
|
|
1032
|
+
const stage = document.querySelector('[data-case-workbench]');
|
|
1033
|
+
const rail = document.querySelector('.caos-focus-rail');
|
|
1034
|
+
const lists = [...document.querySelectorAll('[data-focus-shell] [role="tablist"]')].filter(visible);
|
|
1035
|
+
const rows = lists.map((t) => new Set([...t.querySelectorAll('[role="tab"]')].map((b) => Math.round(b.getBoundingClientRect().top))).size);
|
|
1036
|
+
const panel = stage ? stage.querySelector('[role="tabpanel"]') : null;
|
|
1037
|
+
const st = document.querySelector('.caos-focus-stage');
|
|
1038
|
+
const box = st ? st.getBoundingClientRect() : null;
|
|
1039
|
+
return {
|
|
1040
|
+
focus: Boolean(shell),
|
|
1041
|
+
app: Boolean(document.querySelector('[data-workbench]')),
|
|
1042
|
+
caseId: stage ? stage.getAttribute('data-case') : null,
|
|
1043
|
+
group: shell ? (stage ? stage.getAttribute('data-focus-group') : null) : panel ? panel.getAttribute('data-panel') : null,
|
|
1044
|
+
search: location.search,
|
|
1045
|
+
subview: (() => { const t = document.querySelector('[data-case-workbench] .subtab.active'); return t ? t.getAttribute('data-tab') : null; })(),
|
|
1046
|
+
nestedMain: document.querySelectorAll('main').length > 1 || Boolean(document.querySelector('main main')),
|
|
1047
|
+
vw: window.innerWidth,
|
|
1048
|
+
vh: window.innerHeight,
|
|
1049
|
+
docW: document.documentElement.scrollWidth,
|
|
1050
|
+
docH: document.documentElement.scrollHeight,
|
|
1051
|
+
rail: rail ? { scroll: rail.scrollHeight, client: rail.clientHeight } : null,
|
|
1052
|
+
tabRows: rows,
|
|
1053
|
+
share: box ? (box.width * box.height) / (window.innerWidth * window.innerHeight) : 0,
|
|
1054
|
+
exitInside: (() => { const e = document.querySelector('[data-focus-exit]'); if (!e) return null; const r = e.getBoundingClientRect(); return r.width > 0 && r.right <= window.innerWidth + 1 && r.bottom <= window.innerHeight + 1; })(),
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
|
|
912
1058
|
/** Animation frames and DOM mutations over a window at rest (G8). */
|
|
913
1059
|
G.idle = (ms) =>
|
|
914
1060
|
new Promise((done) => {
|
package/gate/run.mjs
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
// G12 sticky lists a vertical sub-tab list is still in view at the end of a long section
|
|
24
24
|
// G13 contrast every visible text at WCAG AA against the colour behind it (primary size, both themes; first language, and every language on a documentation route)
|
|
25
25
|
// G14 captures index gate-output/index.html shows every capture by route and mode, the failures first
|
|
26
|
+
// G16 flip cards, decks a flip card is a small card (title and back within the limits), a real button that turns with Enter and
|
|
27
|
+
// Space, its hidden face out of the accessibility tree; a carousel reaches every card, never scrolls sideways
|
|
28
|
+
// G17 animated drawings an animated SVG has its reduced-motion block, plays for 10 s at most and is still under reduced motion
|
|
29
|
+
// G18 Focus mode the App's Focus control opens the case's open group in the full viewport and returns to the same case and
|
|
30
|
+
// group (by the exit control and by Escape); no page scroll, the rail without scroll, one tab row
|
|
26
31
|
// plus the ADR-0071 and ADR-0017 measures (viewport, one-row tabs, rail without scroll, centred prose, captions,
|
|
27
32
|
// references).
|
|
28
33
|
|
|
@@ -31,7 +36,7 @@ import { join } from 'node:path';
|
|
|
31
36
|
import { decodePng, paintedBox, unionArea } from './png.mjs';
|
|
32
37
|
import { servePages } from './serve.mjs';
|
|
33
38
|
import { installLib } from './inpage.mjs';
|
|
34
|
-
import { DOC_ROUTES, docFloorFailures } from './areas.mjs';
|
|
39
|
+
import { DOC_ROUTES, FLIP_LIMITS, analyseSvgAnimation, animationFailures, docFloorFailures } from './areas.mjs';
|
|
35
40
|
|
|
36
41
|
export const DEFAULTS = {
|
|
37
42
|
sizes: '390x844,768x1024,1280x800,1600x900,2560x1440',
|
|
@@ -49,6 +54,7 @@ export const DEFAULTS = {
|
|
|
49
54
|
maxShots: 40,
|
|
50
55
|
wideFont: true,
|
|
51
56
|
wideFontSizes: '390x844,1280x800',
|
|
57
|
+
requireFocus: false,
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
/** The wide fallback font of the G10 pass: wider than the Windows and macOS sans fonts; DejaVu Sans is what a Linux
|
|
@@ -107,6 +113,7 @@ export async function runGate(o) {
|
|
|
107
113
|
};
|
|
108
114
|
|
|
109
115
|
const failures = [];
|
|
116
|
+
const focusReported = new Set(); // G18: an App without a Focus control is reported once per route and mode
|
|
110
117
|
const waivers = []; // ADR-0017 2.1: every waiver a documentation page declares, with its verdict
|
|
111
118
|
// The smallest measured values against each floor, so a pass shows its margin (and a CI font change that eats it
|
|
112
119
|
// is visible before it fails).
|
|
@@ -311,7 +318,7 @@ export async function runGate(o) {
|
|
|
311
318
|
// ADR-0017 2.1: the documentation floors are measured once per language, at the primary size and first theme
|
|
312
319
|
const docFloors = size === primarySize && theme === themes[0] && DOC_ROUTES.includes(route);
|
|
313
320
|
const walkState = { tested: new Set(), keyedViews: 0, controls: 0, tablists: 0, doc: docFloors ? [] : null };
|
|
314
|
-
await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
|
|
321
|
+
await processState(page, sink, { ...at, trail: '' }, { route, isWorkbench, primary, idle: idleHere, interact: idleHere, first: true, size, w, h, theme, lang, walkState, contrast });
|
|
315
322
|
|
|
316
323
|
if (primary) {
|
|
317
324
|
// G4: the trailing-slash form a static host serves for a materialised route.
|
|
@@ -331,10 +338,10 @@ export async function runGate(o) {
|
|
|
331
338
|
sink.splice(0);
|
|
332
339
|
}
|
|
333
340
|
|
|
334
|
-
const leaves = await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, size, w, h, theme, lang, walkState, contrast });
|
|
341
|
+
const leaves = await walkTabs(page, sink, { ...at }, [], new Set(), { route, isWorkbench, primary, idle: primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
|
|
335
342
|
if (walkState.doc) docResult(at, lang, walkState.doc, Math.max(1, leaves));
|
|
336
343
|
|
|
337
|
-
if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, size, w, h, theme, lang, walkState, contrast });
|
|
344
|
+
if (isWorkbench) await walkCases(page, sink, at, { route, isWorkbench, primary, interact: idleHere, size, w, h, theme, lang, walkState, contrast });
|
|
338
345
|
|
|
339
346
|
if (primary && isWorkbench) {
|
|
340
347
|
const f = await page.evaluate(() => window.__caosGate.facts());
|
|
@@ -484,6 +491,9 @@ export async function runGate(o) {
|
|
|
484
491
|
const reach = await page.evaluate(() => window.__caosGate.reach());
|
|
485
492
|
if (reach.unreachableCount) fail('G5', where, `${reach.unreachableCount} control(s) not reachable by the pointer: ${reach.unreachable.join('; ')}`);
|
|
486
493
|
|
|
494
|
+
await cardChecks(page, where, s);
|
|
495
|
+
if (s.primary) await animationChecks(page, where);
|
|
496
|
+
|
|
487
497
|
if (s.primary && s.isWorkbench && s.walkState) await reactivity(page, sink, where, s);
|
|
488
498
|
drain(sink, where);
|
|
489
499
|
const failedHere = failures.some((x) => x.route === where.route && x.mode === where.mode && x.trail === where.trail && x.check !== 'G3');
|
|
@@ -650,6 +660,7 @@ export async function runGate(o) {
|
|
|
650
660
|
if (shownCase !== caseId) fail('G9', where, `asked for case ${caseId}; the workbench shows ${shownCase}`);
|
|
651
661
|
await processState(page, sink, where, { ...s, first: false, idle: false });
|
|
652
662
|
if (s.primary) await walkTabs(page, sink, where, [], new Set(), { ...s, idle: false });
|
|
663
|
+
await focusVisit(page, sink, where, s, caseId, pickedIndex(pick, caseId), total);
|
|
653
664
|
}
|
|
654
665
|
if (s.primary && pick.length) {
|
|
655
666
|
// A deep link selects the case it names (G9).
|
|
@@ -667,6 +678,251 @@ export async function runGate(o) {
|
|
|
667
678
|
if (total === 0 && !opt.singleCase) fail('G9', at, 'the workbench offers no case ([data-control="case"]); declare --single-case if it has one');
|
|
668
679
|
}
|
|
669
680
|
|
|
681
|
+
// ---------------------------------------------------------------------------------------------------- G16, G17, G18
|
|
682
|
+
function pickedIndex(list, id) {
|
|
683
|
+
return list.indexOf(id);
|
|
684
|
+
}
|
|
685
|
+
function idleAnimations(page) {
|
|
686
|
+
return page.waitForFunction('document.getAnimations().filter((a) => a.transitionProperty === "transform" || a.transitionProperty === "visibility" || a.animationName === "caos-deck-in").length === 0', undefined, { timeout: 3000 }).catch(() => undefined);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** G16: the flip cards and the card decks of the state (facts), and, in the first theme and language, their interactions. */
|
|
690
|
+
async function cardChecks(page, where, s) {
|
|
691
|
+
const f = await page.evaluate(() => window.__caosGate.cards());
|
|
692
|
+
if (f.cards.length === 0 && f.decks.length === 0) return;
|
|
693
|
+
for (const c of f.cards) {
|
|
694
|
+
const tl = FLIP_LIMITS.title[f.lang];
|
|
695
|
+
const bl = FLIP_LIMITS.back[f.lang];
|
|
696
|
+
if (c.titleWords > tl) fail('G16', where, `flip card "${c.label}": a title of ${c.titleWords} words; the limit is ${tl} (${f.lang}); a flip card is a small card, a long one stays plain`);
|
|
697
|
+
if (c.backWords > bl) fail('G16', where, `flip card "${c.label}": a back of ${c.backWords} words; the limit is ${bl} (${f.lang}); a flip card is a small card, a long one stays plain`);
|
|
698
|
+
if (!c.buttons || !c.wired) fail('G16', where, `flip card "${c.label}": its faces are not real buttons with aria-expanded and aria-controls naming the back`);
|
|
699
|
+
if (c.hiddenInTree) fail('G16', where, `flip card "${c.label}": the hidden ${c.open ? 'face' : 'back'} is in the accessibility tree (aria-hidden, inert or visibility must take it out)`);
|
|
700
|
+
else if (c.hiddenFocusable) fail('G16', where, `flip card "${c.label}": the hidden ${c.open ? 'face' : 'back'} holds a control the keyboard reaches`);
|
|
701
|
+
}
|
|
702
|
+
for (const d of f.decks) {
|
|
703
|
+
if (d.sideways) fail('G16', where, `card deck "${d.label}": the carousel scrolls sideways (${d.outside} card(s) past its box, the deck or its track wider than the page); it must page, never scroll`);
|
|
704
|
+
if (d.docW > d.vw + 1) fail('G16', where, `card deck "${d.label}": the page is ${d.docW}px wide in a ${d.vw}px viewport`);
|
|
705
|
+
if (d.mode === 'carousel' && d.visible.length === 0) fail('G16', where, `card deck "${d.label}": the carousel shows no card`);
|
|
706
|
+
if (d.mode === 'carousel' && !d.controls) {
|
|
707
|
+
for (let k = 0; k < d.total; k += 1) if (!d.visible.includes(k)) fail('G16', where, `card deck "${d.label}": card ${k + 1} of ${d.total} cannot be reached: the carousel hides it and shows no previous and next controls`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!s.interact) return;
|
|
711
|
+
try {
|
|
712
|
+
// a carousel pages through every card with its next button
|
|
713
|
+
for (const d of f.decks) {
|
|
714
|
+
if (d.mode !== 'carousel' || !d.controls) continue;
|
|
715
|
+
const deck = page.locator('[data-card-deck]').nth(d.index);
|
|
716
|
+
const reached = new Set(d.visible);
|
|
717
|
+
let st = d;
|
|
718
|
+
let guard = 0;
|
|
719
|
+
while (!st.atStart && guard < d.total + 2) {
|
|
720
|
+
await deck.locator('[data-deck-prev]').click({ timeout: 4000 });
|
|
721
|
+
st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
|
|
722
|
+
guard += 1;
|
|
723
|
+
}
|
|
724
|
+
for (const i of st.visible) reached.add(i);
|
|
725
|
+
guard = 0;
|
|
726
|
+
while (!st.atEnd && guard < d.total + 1) {
|
|
727
|
+
await deck.locator('[data-deck-next]').click({ timeout: 4000 });
|
|
728
|
+
await idleAnimations(page);
|
|
729
|
+
st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
|
|
730
|
+
for (const i of st.visible) reached.add(i);
|
|
731
|
+
if (st.sideways) fail('G16', where, `card deck "${d.label}": the carousel scrolls sideways after the next button (${st.outside} card(s) past its box)`);
|
|
732
|
+
guard += 1;
|
|
733
|
+
}
|
|
734
|
+
for (let k = 0; k < d.total; k += 1) if (!reached.has(k)) fail('G16', where, `card deck "${d.label}": card ${k + 1} of ${d.total} cannot be reached with the next button`);
|
|
735
|
+
guard = 0;
|
|
736
|
+
while (!st.atStart && guard < d.total + 2) {
|
|
737
|
+
await deck.locator('[data-deck-prev]').click({ timeout: 4000 }).catch(() => undefined);
|
|
738
|
+
st = (await page.evaluate(() => window.__caosGate.cards())).decks[d.index];
|
|
739
|
+
guard += 1;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// one flip card of each deck (and the loose ones): the face takes focus, Enter turns it, focus follows, Space turns it back
|
|
743
|
+
const f2 = await page.evaluate(() => window.__caosGate.cards());
|
|
744
|
+
const done = new Set();
|
|
745
|
+
for (const c of f2.cards) {
|
|
746
|
+
if (!c.shown || c.open || done.has(c.deck)) continue;
|
|
747
|
+
done.add(c.deck);
|
|
748
|
+
const card = page.locator('[data-flip-card]').nth(c.index);
|
|
749
|
+
const toggle = card.locator('[data-flip-toggle="front"]');
|
|
750
|
+
const focused = await toggle.evaluate((el) => { el.focus(); return document.activeElement === el; }).catch(() => false);
|
|
751
|
+
if (!focused) {
|
|
752
|
+
fail('G16', where, `flip card "${c.label}" does not turn with the keyboard: its face cannot take focus (it is not a button)`);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
await page.keyboard.press('Enter');
|
|
756
|
+
await idleAnimations(page);
|
|
757
|
+
const after = (await page.evaluate(() => window.__caosGate.cards())).cards[c.index];
|
|
758
|
+
if (!after || !after.open) {
|
|
759
|
+
fail('G16', where, `flip card "${c.label}" does not turn with the keyboard: Enter on its face left it closed`);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const active = await page.evaluate(() => { const a = document.activeElement; return { visible: Boolean(a) && a.checkVisibility({ checkVisibilityCSS: true }), inCard: Boolean(a) && Boolean(a.closest('[data-flip-card]')) }; });
|
|
763
|
+
if (!active.visible || !active.inCard) fail('G16', where, `flip card "${c.label}": after Enter, keyboard focus is not on a control of the face that shows`);
|
|
764
|
+
if (after.hiddenInTree) fail('G16', where, `flip card "${c.label}": the hidden face is in the accessibility tree while the back shows`);
|
|
765
|
+
if (!after.contentShown) fail('G16', where, `flip card "${c.label}": the back shows no content after it turns (its content has no visible box)`);
|
|
766
|
+
if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', where, `${m} (a flip card turned)`);
|
|
767
|
+
if (!done.has('shot')) {
|
|
768
|
+
done.add('shot');
|
|
769
|
+
await saveShot(page, `flipcard_open_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
|
|
770
|
+
}
|
|
771
|
+
await page.keyboard.press('Space');
|
|
772
|
+
await idleAnimations(page);
|
|
773
|
+
const closed = (await page.evaluate(() => window.__caosGate.cards())).cards[c.index];
|
|
774
|
+
if (!closed || closed.open) fail('G16', where, `flip card "${c.label}" does not turn back with the keyboard: Space on its back left it open`);
|
|
775
|
+
}
|
|
776
|
+
} catch (e) {
|
|
777
|
+
fail('G16', where, `a flip card or a card deck could not be used: ${e.message.split('\n')[0]}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** G17: every animated SVG of the state has its reduced-motion block, plays for ten seconds at most and stands still under reduced motion. */
|
|
782
|
+
async function animationChecks(page, where) {
|
|
783
|
+
const a = await page.evaluate(() => window.__caosGate.animations());
|
|
784
|
+
const animated = [];
|
|
785
|
+
for (const it of a.items) {
|
|
786
|
+
const analysis = analyseSvgAnimation(it.svg);
|
|
787
|
+
for (const m of animationFailures(analysis, it.name)) fail('G17', where, m);
|
|
788
|
+
if (analysis.animated) animated.push(it.name);
|
|
789
|
+
}
|
|
790
|
+
// a figure drawn by an img of an SVG file: the file is analysed (an img takes no page style, so its own block is what counts)
|
|
791
|
+
const imgs = await page.evaluate(() => window.__caosGate.figureImages());
|
|
792
|
+
for (const im of imgs) {
|
|
793
|
+
const text = await page.evaluate(async (u) => { try { return await (await fetch(u)).text(); } catch (e) { return ''; } }, im.src);
|
|
794
|
+
for (const m of animationFailures(analyseSvgAnimation(text), im.name)) fail('G17', where, m);
|
|
795
|
+
}
|
|
796
|
+
if (animated.length === 0) return;
|
|
797
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
798
|
+
try {
|
|
799
|
+
await page.waitForTimeout(250);
|
|
800
|
+
const b = await page.evaluate(() => window.__caosGate.animations());
|
|
801
|
+
for (const it of b.items) {
|
|
802
|
+
if (!animated.includes(it.name)) continue;
|
|
803
|
+
if (it.runningCss > 0 || it.runningSmil) fail('G17', where, `illustration "${it.name}" keeps moving under reduced motion (${it.runningCss} CSS animation(s) running${it.smil ? `, SMIL ${it.runningSmil ? 'playing' : 'paused'}` : ''})`);
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
await page.emulateMedia({ reducedMotion: null });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* G18: Focus mode of the cases, by clicking. The App is put on its last group, and on the last sub-view of a group that has
|
|
812
|
+
* sub-views (the first group is where the walk leaves it, so a Focus that fell back to the first would go unseen); Focus must
|
|
813
|
+
* open on that case, group and sub-view, keep the keyboard inside its frame, hold one landmark and return to the same place.
|
|
814
|
+
*/
|
|
815
|
+
async function focusVisit(page, sink, where, s, caseId, index, total) {
|
|
816
|
+
const enter = page.locator('[data-focus-enter]').filter({ visible: true }).first();
|
|
817
|
+
if ((await enter.count()) === 0) {
|
|
818
|
+
if (opt.requireFocus && !focusReported.has(`${s.route} ${where.mode}`)) {
|
|
819
|
+
focusReported.add(`${s.route} ${where.mode}`);
|
|
820
|
+
fail('G18', { ...where, trail: `case ${caseId} > Focus` }, 'the App offers no Focus control ([data-focus-enter]): ADR-0070 requires a visible control in the App that opens the selected case in focus mode');
|
|
821
|
+
}
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const tabs = page.locator('[data-case-workbench] [role="tablist"]').first().locator('[role="tab"]');
|
|
825
|
+
const n = await tabs.count();
|
|
826
|
+
const targets = n > 1 ? [n - 1] : [0];
|
|
827
|
+
if (s.primary && n > 2) {
|
|
828
|
+
// and the first group that holds sub-views
|
|
829
|
+
for (let i = 0; i < n - 1; i += 1) {
|
|
830
|
+
await tabs.nth(i).click({ timeout: 4000 }).catch(() => undefined);
|
|
831
|
+
await settle(page);
|
|
832
|
+
if ((await page.locator('[data-case-workbench] .subtablist').count()) > 0) {
|
|
833
|
+
targets.push(i);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const [k, t] of targets.entries()) {
|
|
839
|
+
await tabs.nth(t).click({ timeout: 4000 }).catch(() => undefined);
|
|
840
|
+
await settle(page);
|
|
841
|
+
const subs = page.locator('[data-case-workbench] .subtablist [role="tab"]');
|
|
842
|
+
const sn = await subs.count();
|
|
843
|
+
if (sn > 1) {
|
|
844
|
+
await subs.nth(sn - 1).click({ timeout: 4000 }).catch(() => undefined);
|
|
845
|
+
await settle(page);
|
|
846
|
+
}
|
|
847
|
+
await focusVisitOne(page, sink, where, s, caseId, index + k, total, enter);
|
|
848
|
+
}
|
|
849
|
+
await tabs.nth(0).click({ timeout: 4000 }).catch(() => undefined);
|
|
850
|
+
await settle(page);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function focusVisitOne(page, sink, where, s, caseId, index, total, enter) {
|
|
854
|
+
const at = { ...where, trail: `case ${caseId} > Focus` };
|
|
855
|
+
const facts = () => page.evaluate(() => window.__caosGate.focusFacts());
|
|
856
|
+
const label = `Focus mode of case ${caseId}`;
|
|
857
|
+
const before = await facts();
|
|
858
|
+
try {
|
|
859
|
+
await enter.evaluate((el) => window.__caosGate.bringEl(el)).catch(() => undefined);
|
|
860
|
+
await enter.click({ timeout: 5000 });
|
|
861
|
+
} catch (e) {
|
|
862
|
+
fail('G18', at, `${label}: the Focus control could not be clicked: ${e.message.split('\n')[0]}`);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const why = await settle(page);
|
|
866
|
+
if (why) fail('G18', at, `${label}: never settled: ${why}`);
|
|
867
|
+
const f = await facts();
|
|
868
|
+
if (!f.focus) {
|
|
869
|
+
fail('G18', at, `${label}: the Focus control did not open Focus mode`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (f.caseId !== caseId) fail('G18', at, `${label} shows case ${f.caseId}`);
|
|
873
|
+
if (f.group !== before.group) fail('G18', at, `${label} shows group ${f.group}, the App had ${before.group}`);
|
|
874
|
+
if (f.subview !== before.subview) fail('G18', at, `${label} shows sub-view ${f.subview}, the App had ${before.subview}`);
|
|
875
|
+
const q = new URLSearchParams(f.search);
|
|
876
|
+
if (q.get('focus') !== '1' || (total > 1 && q.get('case') !== caseId)) fail('G18', at, `${label}: the URL (${f.search || 'no query'}) does not hold ?focus=1 and the case`);
|
|
877
|
+
if (f.docW > f.vw + 1 || f.docH > f.vh + 1) fail('G18', at, `${label}: the page scrolls (the document is ${f.docW}x${f.docH}px in a ${f.vw}x${f.vh}px viewport); Focus mode is the viewport`);
|
|
878
|
+
if (f.nestedMain) fail('G18', at, `${label}: the page holds a main inside a main (the Focus frame must not be a second landmark inside the App's main)`);
|
|
879
|
+
// the keyboard stays in the Focus frame: the App behind it is out of the tab order
|
|
880
|
+
const strayed = [];
|
|
881
|
+
for (let i = 0; i < 14; i += 1) {
|
|
882
|
+
await page.keyboard.press('Tab');
|
|
883
|
+
const where2 = await page.evaluate(() => { const a = document.activeElement; return !a || a === document.body || a.closest('[data-focus-shell]') ? '' : (a.getAttribute('aria-label') || a.textContent || a.tagName).trim().slice(0, 30); });
|
|
884
|
+
if (where2) strayed.push(where2);
|
|
885
|
+
}
|
|
886
|
+
if (strayed.length) fail('G18', at, `${label}: Tab leaves the Focus frame (focus reached ${[...new Set(strayed)].slice(0, 4).map((x) => `"${x}"`).join(', ')} of the App behind it; make the rest of the page inert)`);
|
|
887
|
+
const big = s.w >= opt.viewportMinWidth;
|
|
888
|
+
if (big && f.rail && f.rail.scroll > f.rail.client + 1) fail('G18', at, `${label}: the rail scrolls (${f.rail.scroll}px of content in ${f.rail.client}px)`);
|
|
889
|
+
if (f.tabRows.some((r) => r > 1)) fail('G18', at, `${label}: a tab row wraps onto ${Math.max(...f.tabRows)} rows`);
|
|
890
|
+
if (big && f.share < opt.instrumentMin) fail('G18', at, `${label}: the stage covers ${(100 * f.share).toFixed(1)}% of the viewport (floor ${(100 * opt.instrumentMin).toFixed(0)}%)`);
|
|
891
|
+
if (f.exitInside === false) fail('G18', at, `${label}: the exit control is outside the viewport`);
|
|
892
|
+
for (const m of await page.evaluate(() => window.__caosGate.drawingText())) fail('G10', at, m);
|
|
893
|
+
if (s.contrast) for (const m of await page.evaluate(() => window.__caosGate.contrast())) fail('G13', at, m);
|
|
894
|
+
if (index === 0) await saveShot(page, `focus_${slug(s.route)}_${s.w}x${s.h}_${s.theme}_${s.lang}.png`);
|
|
895
|
+
// leave: the exit control for one case, Escape for the next, so both ways are walked
|
|
896
|
+
const byButton = index % 2 === 0;
|
|
897
|
+
let left = true;
|
|
898
|
+
if (byButton) {
|
|
899
|
+
try {
|
|
900
|
+
await page.locator('[data-focus-exit]').first().click({ timeout: 3000 });
|
|
901
|
+
} catch (e) {
|
|
902
|
+
fail('G18', at, `${label}: the exit control could not be used: ${e.message.split('\n')[0]}`);
|
|
903
|
+
left = false;
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
await page.keyboard.press('Escape');
|
|
907
|
+
}
|
|
908
|
+
if (!left) await page.keyboard.press('Escape');
|
|
909
|
+
const why2 = await settle(page);
|
|
910
|
+
if (why2) fail('G18', at, `${label}: never settled after leaving: ${why2}`);
|
|
911
|
+
const g = await facts();
|
|
912
|
+
if (g.focus) {
|
|
913
|
+
fail('G18', at, `${label}: ${byButton && left ? 'the exit control' : 'Escape'} does not return to the App`);
|
|
914
|
+
await page.goto(`${urlFor(s.route)}?case=${encodeURIComponent(caseId)}`, { waitUntil: 'load', timeout: 30000 }).catch(() => undefined);
|
|
915
|
+
await settle(page);
|
|
916
|
+
} else {
|
|
917
|
+
if (!g.app) fail('G18', at, `${label}: back from Focus mode there is no App on the page`);
|
|
918
|
+
if (g.caseId !== caseId) fail('G18', at, `${label}: back in the App on case ${g.caseId}, it was left on case ${caseId}`);
|
|
919
|
+
if (g.group !== before.group) fail('G18', at, `${label}: back in the App on group ${g.group}, it was left on group ${before.group}`);
|
|
920
|
+
if (g.subview !== before.subview) fail('G18', at, `${label}: back in the App on sub-view ${g.subview}, it was left on sub-view ${before.subview}`);
|
|
921
|
+
if (new URLSearchParams(g.search).has('focus')) fail('G18', at, `${label}: the URL still holds ?focus after the return`);
|
|
922
|
+
}
|
|
923
|
+
drain(sink, at);
|
|
924
|
+
}
|
|
925
|
+
|
|
670
926
|
async function workbenchKey(page) {
|
|
671
927
|
return page.evaluate(() => document.querySelector('[data-case-workbench]')?.getAttribute('data-state-key') ?? null);
|
|
672
928
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fasl-work/caos-app-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|