@jitsusama/agentic-harness.core 0.1.0 → 0.2.0
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/dist/bin/cli.js +7 -0
- package/dist/bin/web.d.ts +30 -0
- package/dist/bin/web.js +97 -0
- package/dist/web/audit/index.d.ts +1 -0
- package/dist/web/audit/index.js +1 -0
- package/dist/web/audit/motion.d.ts +87 -0
- package/dist/web/audit/motion.js +239 -0
- package/dist/web/design/index.d.ts +1 -0
- package/dist/web/design/index.js +1 -0
- package/dist/web/design/typography.d.ts +71 -0
- package/dist/web/design/typography.js +221 -0
- package/dist/web/hydration/capture.d.ts +37 -0
- package/dist/web/hydration/capture.js +96 -0
- package/dist/web/hydration/index.d.ts +10 -0
- package/dist/web/hydration/index.js +10 -0
- package/dist/web/hydration/judge.d.ts +56 -0
- package/dist/web/hydration/judge.js +191 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.js +1 -0
- package/dist/web/perf/index.d.ts +1 -1
- package/dist/web/perf/index.js +1 -1
- package/dist/web/perf/view.js +19 -1
- package/dist/web/perf/vitals.d.ts +29 -0
- package/dist/web/perf/vitals.js +70 -0
- package/dist/web/session.d.ts +31 -2
- package/dist/web/session.js +75 -2
- package/package.json +144 -144
- package/dist/memory/db.d.ts +0 -15
- package/dist/memory/db.js +0 -25
package/dist/bin/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ import { runNotesAction } from "./notes.js";
|
|
|
27
27
|
import { processExec } from "./process-exec.js";
|
|
28
28
|
import { runQuestAction } from "./quest.js";
|
|
29
29
|
import { runSlackAuthLogin, runSlackAuthStatus } from "./slack-auth.js";
|
|
30
|
+
import { runWebCheck } from "./web.js";
|
|
30
31
|
/** Where a loop's state lives when the caller doesn't override it. */
|
|
31
32
|
const DEFAULT_STATE_FILE = ".agentic-harness/tdd-loop.json";
|
|
32
33
|
/** Where quest state lives when the caller doesn't override it. */
|
|
@@ -218,6 +219,12 @@ async function main() {
|
|
|
218
219
|
return;
|
|
219
220
|
}
|
|
220
221
|
}
|
|
222
|
+
if (options.domain === "web" && options.command === "check") {
|
|
223
|
+
const result = await runWebCheck(await readStdin());
|
|
224
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
225
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
221
228
|
if (options.domain === "slack-auth") {
|
|
222
229
|
if (options.command === "status") {
|
|
223
230
|
process.stdout.write(`${JSON.stringify(await runSlackAuthStatus())}\n`);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web check commands: one browser, one page, one verdict.
|
|
3
|
+
*
|
|
4
|
+
* This is the CLI adapter for the judgment checks a hook-and-skill
|
|
5
|
+
* consumer cannot reach through a library import: it opens a
|
|
6
|
+
* session, runs one check against one URL and answers JSON with
|
|
7
|
+
* the same rendered report the pi tools show. Stateless per
|
|
8
|
+
* invocation like every other command here; the browser lives and
|
|
9
|
+
* dies inside the call.
|
|
10
|
+
*/
|
|
11
|
+
/** The checks this command knows how to run. */
|
|
12
|
+
export declare const WEB_CHECK_KINDS: readonly ["motion", "typography", "hydration", "perf"];
|
|
13
|
+
/** What arrives on stdin. */
|
|
14
|
+
export interface WebCheckInput {
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/** For perf: how many loads to sample. Defaults to 3. */
|
|
18
|
+
readonly samples?: number;
|
|
19
|
+
}
|
|
20
|
+
/** What goes out on stdout. */
|
|
21
|
+
export interface WebCheckOutput {
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly kind?: string;
|
|
24
|
+
readonly url?: string;
|
|
25
|
+
/** The same rendered verdict the pi tools show. */
|
|
26
|
+
readonly report?: string;
|
|
27
|
+
readonly error?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Run one web check against one URL. */
|
|
30
|
+
export declare function runWebCheck(raw: string): Promise<WebCheckOutput>;
|
package/dist/bin/web.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web check commands: one browser, one page, one verdict.
|
|
3
|
+
*
|
|
4
|
+
* This is the CLI adapter for the judgment checks a hook-and-skill
|
|
5
|
+
* consumer cannot reach through a library import: it opens a
|
|
6
|
+
* session, runs one check against one URL and answers JSON with
|
|
7
|
+
* the same rendered report the pi tools show. Stateless per
|
|
8
|
+
* invocation like every other command here; the browser lives and
|
|
9
|
+
* dies inside the call.
|
|
10
|
+
*/
|
|
11
|
+
import { tallyFindings } from "../web/audit/index.js";
|
|
12
|
+
import { analyseMotion } from "../web/audit/motion.js";
|
|
13
|
+
import { renderAudit } from "../web/audit/report.js";
|
|
14
|
+
import { analyseTypography, renderTypography, } from "../web/design/typography.js";
|
|
15
|
+
import { judgeHydration, renderHydration } from "../web/hydration/index.js";
|
|
16
|
+
import { measureSamples, renderVitals } from "../web/perf/index.js";
|
|
17
|
+
import { BrowserSession } from "../web/session.js";
|
|
18
|
+
/** The checks this command knows how to run. */
|
|
19
|
+
export const WEB_CHECK_KINDS = [
|
|
20
|
+
"motion",
|
|
21
|
+
"typography",
|
|
22
|
+
"hydration",
|
|
23
|
+
"perf",
|
|
24
|
+
];
|
|
25
|
+
/** Perf samples when the caller does not say. */
|
|
26
|
+
const DEFAULT_SAMPLES = 3;
|
|
27
|
+
/** The most loads one perf call will pay for. */
|
|
28
|
+
const MAX_SAMPLES = 9;
|
|
29
|
+
function isKind(kind) {
|
|
30
|
+
return WEB_CHECK_KINDS.includes(kind);
|
|
31
|
+
}
|
|
32
|
+
async function checkOn(session, kind, input) {
|
|
33
|
+
if (kind === "perf") {
|
|
34
|
+
const wanted = Math.min(Math.max(1, Math.round(input.samples ?? DEFAULT_SAMPLES)), MAX_SAMPLES);
|
|
35
|
+
const samples = [await session.vitals()];
|
|
36
|
+
while (samples.length < wanted) {
|
|
37
|
+
const { failure } = await session.reload();
|
|
38
|
+
// A reload that failed ends the sampling rather than the
|
|
39
|
+
// check: the loads that did land are still a measurement.
|
|
40
|
+
if (failure)
|
|
41
|
+
break;
|
|
42
|
+
samples.push(await session.vitals());
|
|
43
|
+
}
|
|
44
|
+
const last = samples[samples.length - 1];
|
|
45
|
+
return renderVitals(last, measureSamples(samples));
|
|
46
|
+
}
|
|
47
|
+
if (kind === "motion") {
|
|
48
|
+
const findings = analyseMotion(await session.motionUnderReduce());
|
|
49
|
+
return renderAudit(findings, tallyFindings(findings), {
|
|
50
|
+
measured: "Emulated prefers-reduced-motion: reduce, reloaded, and read " +
|
|
51
|
+
"what was still moving.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (kind === "typography") {
|
|
55
|
+
const blocks = await session.typography();
|
|
56
|
+
return renderTypography(blocks, analyseTypography(blocks));
|
|
57
|
+
}
|
|
58
|
+
const capture = await session.hydration();
|
|
59
|
+
const lines = session
|
|
60
|
+
.logs()
|
|
61
|
+
.entries.map(({ item }) => ({ level: item.level, text: item.text }));
|
|
62
|
+
return renderHydration(judgeHydration(capture, lines));
|
|
63
|
+
}
|
|
64
|
+
/** Run one web check against one URL. */
|
|
65
|
+
export async function runWebCheck(raw) {
|
|
66
|
+
let input;
|
|
67
|
+
try {
|
|
68
|
+
input = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { ok: false, error: "stdin must be JSON: { kind, url }" };
|
|
72
|
+
}
|
|
73
|
+
if (!input.url) {
|
|
74
|
+
return { ok: false, error: "url is required" };
|
|
75
|
+
}
|
|
76
|
+
if (!input.kind || !isKind(input.kind)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `kind must be one of: ${WEB_CHECK_KINDS.join(", ")}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const session = await BrowserSession.open(`web-check-${process.pid}`);
|
|
83
|
+
try {
|
|
84
|
+
const { failure, status } = await session.navigate(input.url);
|
|
85
|
+
if (failure) {
|
|
86
|
+
return { ok: false, error: `Could not load ${input.url}: ${failure}` };
|
|
87
|
+
}
|
|
88
|
+
if (status !== undefined && status >= 400) {
|
|
89
|
+
return { ok: false, error: `${input.url} answered ${status}` };
|
|
90
|
+
}
|
|
91
|
+
const report = await checkOn(session, input.kind, input);
|
|
92
|
+
return { ok: true, kind: input.kind, url: input.url, report };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await session.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -12,6 +12,7 @@ export { type AxFacts, buildStructure, selectorFor, } from "./capture.js";
|
|
|
12
12
|
export { composite, contrastRatio, deltaE, formatRgb, isOpaque, isTransparent, parseRgb, type Rgba, relativeLuminance, } from "./colour.js";
|
|
13
13
|
export { BOLD_WEIGHT, type ContrastLevel, type ContrastVerdict, isLargeText, judgeNonText, judgeText, LARGE_BOLD_PX, LARGE_TEXT_PX, NON_TEXT_MINIMUM, renderContrast, type TextSizing, textThreshold, undecidable, } from "./contrast.js";
|
|
14
14
|
export { overallOf, type Part, renderHealth } from "./health.js";
|
|
15
|
+
export { analyseMotion, BRIEF_MS, MOTION_CAPTURE, type MotionAnimation, type MotionCapture, type MotionVideo, PAUSE_STOP_HIDE_MS, } from "./motion.js";
|
|
15
16
|
export { foldPair, type PaintedSide, type PairReport, renderPair, } from "./pair.js";
|
|
16
17
|
export { TARGET_CAPTURE, visualCaptureSource } from "./probe.js";
|
|
17
18
|
export { MAX_LISTED_NODES, renderAudit, renderFinding, renderIndex, renderSummary, } from "./report.js";
|
package/dist/web/audit/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { buildStructure, selectorFor, } from "./capture.js";
|
|
|
12
12
|
export { composite, contrastRatio, deltaE, formatRgb, isOpaque, isTransparent, parseRgb, relativeLuminance, } from "./colour.js";
|
|
13
13
|
export { BOLD_WEIGHT, isLargeText, judgeNonText, judgeText, LARGE_BOLD_PX, LARGE_TEXT_PX, NON_TEXT_MINIMUM, renderContrast, textThreshold, undecidable, } from "./contrast.js";
|
|
14
14
|
export { overallOf, renderHealth } from "./health.js";
|
|
15
|
+
export { analyseMotion, BRIEF_MS, MOTION_CAPTURE, PAUSE_STOP_HIDE_MS, } from "./motion.js";
|
|
15
16
|
export { foldPair, renderPair, } from "./pair.js";
|
|
16
17
|
export { TARGET_CAPTURE, visualCaptureSource } from "./probe.js";
|
|
17
18
|
export { MAX_LISTED_NODES, renderAudit, renderFinding, renderIndex, renderSummary, } from "./report.js";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page honours a request for reduced motion.
|
|
3
|
+
*
|
|
4
|
+
* The preference is the one accessibility setting a page can
|
|
5
|
+
* ignore without failing a single axe rule: the markup is fine,
|
|
6
|
+
* the contrast is fine, and the page is still unusable for the
|
|
7
|
+
* person who asked it to hold still. The only way to know is to
|
|
8
|
+
* ask for reduce and then look at what is still moving, which is
|
|
9
|
+
* what this module judges from a capture taken under emulation.
|
|
10
|
+
*
|
|
11
|
+
* The analysis is pure and takes serialized data, like every
|
|
12
|
+
* other audit here: nothing in this file can start a browser.
|
|
13
|
+
*
|
|
14
|
+
* Adapted from the reduced-motion conventions in Carolyn
|
|
15
|
+
* McNeillie's review-page skill set, which treats a page that
|
|
16
|
+
* ignores the preference outright as one of the few
|
|
17
|
+
* symptom-only blockers: user-observable, confirmed from the
|
|
18
|
+
* capture, no mechanism claim required.
|
|
19
|
+
*/
|
|
20
|
+
import type { A11yFinding } from "./axe.js";
|
|
21
|
+
/** A video as the page reported it under reduce. */
|
|
22
|
+
export interface MotionVideo {
|
|
23
|
+
readonly selector: string;
|
|
24
|
+
readonly html: string;
|
|
25
|
+
readonly playing: boolean;
|
|
26
|
+
readonly autoplay: boolean;
|
|
27
|
+
readonly loop: boolean;
|
|
28
|
+
readonly controls: boolean;
|
|
29
|
+
readonly visible: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** An animation as the page reported it under reduce. */
|
|
32
|
+
export interface MotionAnimation {
|
|
33
|
+
readonly selector: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly kind: string;
|
|
36
|
+
readonly playState: string;
|
|
37
|
+
/** May be absent when the effect reports no timing. */
|
|
38
|
+
readonly durationMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Repeats, as text: Infinity does not survive serialization,
|
|
41
|
+
* so the page sends the word.
|
|
42
|
+
*/
|
|
43
|
+
readonly iterations: string;
|
|
44
|
+
/** Driven by scroll rather than by time. */
|
|
45
|
+
readonly scroll: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** What the page was doing while asked to hold still. */
|
|
48
|
+
export interface MotionCapture {
|
|
49
|
+
/** Whether the page actually saw the reduce preference. */
|
|
50
|
+
readonly reduced: boolean;
|
|
51
|
+
readonly videos: readonly MotionVideo[];
|
|
52
|
+
readonly animations: readonly MotionAnimation[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Animations at or under this total runtime are left alone.
|
|
56
|
+
*
|
|
57
|
+
* The preference asks for less motion, not none: a brief fade is
|
|
58
|
+
* how a page avoids a jarring pop, and flagging every 150ms
|
|
59
|
+
* transition would bury the marquee that never stops.
|
|
60
|
+
*/
|
|
61
|
+
export declare const BRIEF_MS = 250;
|
|
62
|
+
/**
|
|
63
|
+
* Motion running longer than this needs a way to stop.
|
|
64
|
+
*
|
|
65
|
+
* The five second line is WCAG 2.2.2's own: moving content that
|
|
66
|
+
* starts automatically and lasts longer than five seconds must
|
|
67
|
+
* have a mechanism to pause, stop or hide it.
|
|
68
|
+
*/
|
|
69
|
+
export declare const PAUSE_STOP_HIDE_MS = 5000;
|
|
70
|
+
/**
|
|
71
|
+
* Judge a capture taken under reduced motion.
|
|
72
|
+
*
|
|
73
|
+
* An empty answer from a capture where the page never saw the
|
|
74
|
+
* preference would be a lie, so a capture with reduced false
|
|
75
|
+
* produces a single needs-review finding naming the problem
|
|
76
|
+
* instead of a clean pass.
|
|
77
|
+
*/
|
|
78
|
+
export declare function analyseMotion(capture: MotionCapture): readonly A11yFinding[];
|
|
79
|
+
/**
|
|
80
|
+
* The expression that reads what is moving, run in the page
|
|
81
|
+
* while reduce is emulated.
|
|
82
|
+
*
|
|
83
|
+
* Selectors are built the same way the other captures build
|
|
84
|
+
* them: something a person could paste into the console, never a
|
|
85
|
+
* retained node.
|
|
86
|
+
*/
|
|
87
|
+
export declare const MOTION_CAPTURE = "(() => {\n\tconst selectorFor = (el) => {\n\t\tif (!el || !el.tagName) return \"(detached)\";\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tif (el.id) return \"#\" + el.id;\n\t\tconst hook = el.getAttribute && el.getAttribute(\"data-testid\");\n\t\tif (hook) return tag + '[data-testid=\"' + hook + '\"]';\n\t\tconst first = el.classList && el.classList[0];\n\t\tif (first) return tag + \".\" + first;\n\t\treturn tag;\n\t};\n\n\tconst videos = [...document.querySelectorAll(\"video\")].map((video) => ({\n\t\tselector: selectorFor(video),\n\t\thtml: video.outerHTML.slice(0, 160),\n\t\tplaying:\n\t\t\t!video.paused && !video.ended && video.readyState > 2,\n\t\tautoplay: video.autoplay,\n\t\tloop: video.loop,\n\t\tcontrols: video.controls,\n\t\tvisible: video.getClientRects().length > 0,\n\t}));\n\n\tconst animations = document.getAnimations\n\t\t? document.getAnimations({ subtree: true }).map((animation) => {\n\t\t\t\tconst effect = animation.effect;\n\t\t\t\tconst timing = effect && effect.getTiming ? effect.getTiming() : {};\n\t\t\t\tconst duration =\n\t\t\t\t\ttypeof timing.duration === \"number\" ? timing.duration : undefined;\n\t\t\t\tconst target = effect && effect.target ? effect.target : null;\n\t\t\t\treturn {\n\t\t\t\t\tselector: selectorFor(target),\n\t\t\t\t\tname:\n\t\t\t\t\t\tanimation.animationName ||\n\t\t\t\t\t\tanimation.transitionProperty ||\n\t\t\t\t\t\tanimation.id ||\n\t\t\t\t\t\t\"unnamed\",\n\t\t\t\t\tkind: animation.constructor.name,\n\t\t\t\t\tplayState: animation.playState,\n\t\t\t\t\t...(duration === undefined ? {} : { durationMs: duration }),\n\t\t\t\t\titerations: String(timing.iterations === undefined ? 1 : timing.iterations),\n\t\t\t\t\tscroll: !!(\n\t\t\t\t\t\tanimation.timeline &&\n\t\t\t\t\t\tanimation.timeline.constructor.name !== \"DocumentTimeline\"\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t})\n\t\t: [];\n\n\treturn {\n\t\treduced: matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n\t\tvideos,\n\t\tanimations,\n\t};\n})()";
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page honours a request for reduced motion.
|
|
3
|
+
*
|
|
4
|
+
* The preference is the one accessibility setting a page can
|
|
5
|
+
* ignore without failing a single axe rule: the markup is fine,
|
|
6
|
+
* the contrast is fine, and the page is still unusable for the
|
|
7
|
+
* person who asked it to hold still. The only way to know is to
|
|
8
|
+
* ask for reduce and then look at what is still moving, which is
|
|
9
|
+
* what this module judges from a capture taken under emulation.
|
|
10
|
+
*
|
|
11
|
+
* The analysis is pure and takes serialized data, like every
|
|
12
|
+
* other audit here: nothing in this file can start a browser.
|
|
13
|
+
*
|
|
14
|
+
* Adapted from the reduced-motion conventions in Carolyn
|
|
15
|
+
* McNeillie's review-page skill set, which treats a page that
|
|
16
|
+
* ignores the preference outright as one of the few
|
|
17
|
+
* symptom-only blockers: user-observable, confirmed from the
|
|
18
|
+
* capture, no mechanism claim required.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Animations at or under this total runtime are left alone.
|
|
22
|
+
*
|
|
23
|
+
* The preference asks for less motion, not none: a brief fade is
|
|
24
|
+
* how a page avoids a jarring pop, and flagging every 150ms
|
|
25
|
+
* transition would bury the marquee that never stops.
|
|
26
|
+
*/
|
|
27
|
+
export const BRIEF_MS = 250;
|
|
28
|
+
/**
|
|
29
|
+
* Motion running longer than this needs a way to stop.
|
|
30
|
+
*
|
|
31
|
+
* The five second line is WCAG 2.2.2's own: moving content that
|
|
32
|
+
* starts automatically and lasts longer than five seconds must
|
|
33
|
+
* have a mechanism to pause, stop or hide it.
|
|
34
|
+
*/
|
|
35
|
+
export const PAUSE_STOP_HIDE_MS = 5000;
|
|
36
|
+
/** Total runtime, or none for an animation that never ends. */
|
|
37
|
+
function totalMs(animation) {
|
|
38
|
+
if (animation.iterations === "Infinity")
|
|
39
|
+
return undefined;
|
|
40
|
+
if (animation.durationMs === undefined)
|
|
41
|
+
return undefined;
|
|
42
|
+
const repeats = Number(animation.iterations);
|
|
43
|
+
if (!Number.isFinite(repeats))
|
|
44
|
+
return undefined;
|
|
45
|
+
return animation.durationMs * repeats;
|
|
46
|
+
}
|
|
47
|
+
function videoNode(video, message) {
|
|
48
|
+
return { selector: video.selector, html: video.html, messages: [message] };
|
|
49
|
+
}
|
|
50
|
+
function animationNode(animation, message) {
|
|
51
|
+
return {
|
|
52
|
+
selector: animation.selector,
|
|
53
|
+
html: "",
|
|
54
|
+
messages: [message],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Judge a capture taken under reduced motion.
|
|
59
|
+
*
|
|
60
|
+
* An empty answer from a capture where the page never saw the
|
|
61
|
+
* preference would be a lie, so a capture with reduced false
|
|
62
|
+
* produces a single needs-review finding naming the problem
|
|
63
|
+
* instead of a clean pass.
|
|
64
|
+
*/
|
|
65
|
+
export function analyseMotion(capture) {
|
|
66
|
+
if (!capture.reduced) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
rule: "reduced-motion-not-emulated",
|
|
70
|
+
kind: "needs-review",
|
|
71
|
+
impact: "moderate",
|
|
72
|
+
authority: "best-practice",
|
|
73
|
+
criteria: [],
|
|
74
|
+
levels: [],
|
|
75
|
+
help: "The page never saw prefers-reduced-motion: reduce, so " +
|
|
76
|
+
"nothing here was judged. The emulation did not take; " +
|
|
77
|
+
"nothing can be said about how the page behaves for a " +
|
|
78
|
+
"person who asked it to hold still.",
|
|
79
|
+
nodes: [],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
const findings = [];
|
|
84
|
+
const playing = capture.videos.filter((video) => video.visible && video.playing);
|
|
85
|
+
const unstoppable = playing.filter((video) => !video.controls);
|
|
86
|
+
if (unstoppable.length > 0) {
|
|
87
|
+
findings.push({
|
|
88
|
+
rule: "video-plays-under-reduced-motion",
|
|
89
|
+
kind: "violation",
|
|
90
|
+
impact: "serious",
|
|
91
|
+
authority: "wcag",
|
|
92
|
+
criteria: ["2.2.2"],
|
|
93
|
+
levels: ["A"],
|
|
94
|
+
help: "Video still playing with no controls while the page was " +
|
|
95
|
+
"asked for reduced motion. Auto-playing movement that " +
|
|
96
|
+
"cannot be paused, stopped or hidden fails 2.2.2, and " +
|
|
97
|
+
"playing it at all ignores the stated preference.",
|
|
98
|
+
nodes: unstoppable.map((video) => videoNode(video, video.loop
|
|
99
|
+
? "playing on a loop, no controls to stop it"
|
|
100
|
+
: "playing, no controls to stop it")),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const pausable = playing.filter((video) => video.controls);
|
|
104
|
+
if (pausable.length > 0) {
|
|
105
|
+
findings.push({
|
|
106
|
+
rule: "video-ignores-reduced-motion",
|
|
107
|
+
kind: "violation",
|
|
108
|
+
impact: "moderate",
|
|
109
|
+
authority: "best-practice",
|
|
110
|
+
criteria: [],
|
|
111
|
+
levels: [],
|
|
112
|
+
help: "Video plays automatically under reduced motion. It has " +
|
|
113
|
+
"controls, so 2.2.2 is met, but the preference asked the " +
|
|
114
|
+
"page not to start the motion in the first place.",
|
|
115
|
+
nodes: pausable.map((video) => videoNode(video, "autoplays")),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const running = capture.animations.filter((animation) => animation.playState === "running");
|
|
119
|
+
const scrollDriven = running.filter((animation) => animation.scroll);
|
|
120
|
+
if (scrollDriven.length > 0) {
|
|
121
|
+
findings.push({
|
|
122
|
+
rule: "scroll-animation-under-reduced-motion",
|
|
123
|
+
kind: "violation",
|
|
124
|
+
impact: "moderate",
|
|
125
|
+
authority: "wcag",
|
|
126
|
+
criteria: ["2.3.3"],
|
|
127
|
+
levels: ["AAA"],
|
|
128
|
+
help: "Scroll-driven animation still active under reduced " +
|
|
129
|
+
"motion. Motion triggered by interaction is exactly what " +
|
|
130
|
+
"2.3.3 says the preference should disable.",
|
|
131
|
+
nodes: scrollDriven.map((animation) => animationNode(animation, `${animation.name} rides the scroll`)),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const timed = running.filter((animation) => !animation.scroll);
|
|
135
|
+
const endlessOrLong = timed.filter((animation) => {
|
|
136
|
+
const total = totalMs(animation);
|
|
137
|
+
return total === undefined || total > PAUSE_STOP_HIDE_MS;
|
|
138
|
+
});
|
|
139
|
+
if (endlessOrLong.length > 0) {
|
|
140
|
+
findings.push({
|
|
141
|
+
rule: "animation-runs-under-reduced-motion",
|
|
142
|
+
kind: "violation",
|
|
143
|
+
impact: "serious",
|
|
144
|
+
authority: "wcag",
|
|
145
|
+
criteria: ["2.2.2"],
|
|
146
|
+
levels: ["A"],
|
|
147
|
+
help: "Animation still running past the five second line while " +
|
|
148
|
+
"the page was asked for reduced motion. Moving content " +
|
|
149
|
+
"that starts on its own and runs this long needs a way to " +
|
|
150
|
+
"pause, stop or hide it, and honouring the preference is " +
|
|
151
|
+
"the way that costs nothing.",
|
|
152
|
+
nodes: endlessOrLong.map((animation) => animationNode(animation, animation.iterations === "Infinity"
|
|
153
|
+
? `${animation.name} never ends`
|
|
154
|
+
: `${animation.name} runs ${Math.round((totalMs(animation) ?? 0) / 1000)}s`)),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const brief = timed.filter((animation) => {
|
|
158
|
+
const total = totalMs(animation);
|
|
159
|
+
return (total !== undefined && total > BRIEF_MS && total <= PAUSE_STOP_HIDE_MS);
|
|
160
|
+
});
|
|
161
|
+
if (brief.length > 0) {
|
|
162
|
+
findings.push({
|
|
163
|
+
rule: "animation-under-reduced-motion",
|
|
164
|
+
kind: "violation",
|
|
165
|
+
impact: "minor",
|
|
166
|
+
authority: "best-practice",
|
|
167
|
+
criteria: [],
|
|
168
|
+
levels: [],
|
|
169
|
+
help: "Animation still running under reduced motion. Each one " +
|
|
170
|
+
"ends on its own, so no standard is failed; the preference " +
|
|
171
|
+
"still asked for less of this.",
|
|
172
|
+
nodes: brief.map((animation) => animationNode(animation, `${animation.name} runs ${totalMs(animation)}ms`)),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The expression that reads what is moving, run in the page
|
|
179
|
+
* while reduce is emulated.
|
|
180
|
+
*
|
|
181
|
+
* Selectors are built the same way the other captures build
|
|
182
|
+
* them: something a person could paste into the console, never a
|
|
183
|
+
* retained node.
|
|
184
|
+
*/
|
|
185
|
+
export const MOTION_CAPTURE = `(() => {
|
|
186
|
+
const selectorFor = (el) => {
|
|
187
|
+
if (!el || !el.tagName) return "(detached)";
|
|
188
|
+
const tag = el.tagName.toLowerCase();
|
|
189
|
+
if (el.id) return "#" + el.id;
|
|
190
|
+
const hook = el.getAttribute && el.getAttribute("data-testid");
|
|
191
|
+
if (hook) return tag + '[data-testid="' + hook + '"]';
|
|
192
|
+
const first = el.classList && el.classList[0];
|
|
193
|
+
if (first) return tag + "." + first;
|
|
194
|
+
return tag;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const videos = [...document.querySelectorAll("video")].map((video) => ({
|
|
198
|
+
selector: selectorFor(video),
|
|
199
|
+
html: video.outerHTML.slice(0, 160),
|
|
200
|
+
playing:
|
|
201
|
+
!video.paused && !video.ended && video.readyState > 2,
|
|
202
|
+
autoplay: video.autoplay,
|
|
203
|
+
loop: video.loop,
|
|
204
|
+
controls: video.controls,
|
|
205
|
+
visible: video.getClientRects().length > 0,
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
const animations = document.getAnimations
|
|
209
|
+
? document.getAnimations({ subtree: true }).map((animation) => {
|
|
210
|
+
const effect = animation.effect;
|
|
211
|
+
const timing = effect && effect.getTiming ? effect.getTiming() : {};
|
|
212
|
+
const duration =
|
|
213
|
+
typeof timing.duration === "number" ? timing.duration : undefined;
|
|
214
|
+
const target = effect && effect.target ? effect.target : null;
|
|
215
|
+
return {
|
|
216
|
+
selector: selectorFor(target),
|
|
217
|
+
name:
|
|
218
|
+
animation.animationName ||
|
|
219
|
+
animation.transitionProperty ||
|
|
220
|
+
animation.id ||
|
|
221
|
+
"unnamed",
|
|
222
|
+
kind: animation.constructor.name,
|
|
223
|
+
playState: animation.playState,
|
|
224
|
+
...(duration === undefined ? {} : { durationMs: duration }),
|
|
225
|
+
iterations: String(timing.iterations === undefined ? 1 : timing.iterations),
|
|
226
|
+
scroll: !!(
|
|
227
|
+
animation.timeline &&
|
|
228
|
+
animation.timeline.constructor.name !== "DocumentTimeline"
|
|
229
|
+
),
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
: [];
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
reduced: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
236
|
+
videos,
|
|
237
|
+
animations,
|
|
238
|
+
};
|
|
239
|
+
})()`;
|
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { type Cluster, COLOUR_SAMENESS, canonicalLengths, clusterUsage, coloursAreNear, DIMENSIONS, type Dimension, exactlyEqual, LENGTH_SAMENESS, lengthsAreNear, type Nearness, renderInventory, type StyleSample, takeInventory, tallyUsage, type Usage, } from "./inventory.js";
|
|
9
9
|
export { inventorySource, SAMPLED_PROPERTIES } from "./probe.js";
|
|
10
|
+
export { analyseTypography, BODY_FLOOR_CHARS, RUNT_MAX_WORDS, RUNT_WIDTH_SHARE, renderTypography, type TextBlock, TYPOGRAPHY_CAPTURE, type TypographyFinding, } from "./typography.js";
|
package/dist/web/design/index.js
CHANGED
|
@@ -7,3 +7,4 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { COLOUR_SAMENESS, canonicalLengths, clusterUsage, coloursAreNear, DIMENSIONS, exactlyEqual, LENGTH_SAMENESS, lengthsAreNear, renderInventory, takeInventory, tallyUsage, } from "./inventory.js";
|
|
9
9
|
export { inventorySource, SAMPLED_PROPERTIES } from "./probe.js";
|
|
10
|
+
export { analyseTypography, BODY_FLOOR_CHARS, RUNT_MAX_WORDS, RUNT_WIDTH_SHARE, renderTypography, TYPOGRAPHY_CAPTURE, } from "./typography.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How text blocks end: orphans and runts, from real layout.
|
|
3
|
+
*
|
|
4
|
+
* A single word stranded on the last line of a paragraph or
|
|
5
|
+
* heading reads as a typesetting accident, and a very short last
|
|
6
|
+
* line nearly does. Neither can be judged from markup or font
|
|
7
|
+
* metrics, because where the browser broke the lines is the
|
|
8
|
+
* fact, so the capture reads actual line boxes word by word
|
|
9
|
+
* through Range.getBoundingClientRect.
|
|
10
|
+
*
|
|
11
|
+
* This is taste, not conformance: nothing here fails a
|
|
12
|
+
* standard, and the verdict never goes past WARN. It sits in
|
|
13
|
+
* the design domain beside the inventory for the same reason
|
|
14
|
+
* the inventory does: it reports what a person would want to
|
|
15
|
+
* polish, and the judgment of whether it matters is theirs.
|
|
16
|
+
*
|
|
17
|
+
* The line-reading approach is ported from the orphans-and-runts
|
|
18
|
+
* check in Carolyn McNeillie's review-page skill set, including
|
|
19
|
+
* its exclusions: text the author already asked the browser to
|
|
20
|
+
* balance is the browser's business, and text inside a collapsed
|
|
21
|
+
* disclosure is not laid out at all.
|
|
22
|
+
*/
|
|
23
|
+
/** One text-bearing block, as the page measured it. */
|
|
24
|
+
export interface TextBlock {
|
|
25
|
+
readonly selector: string;
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
readonly textLength: number;
|
|
28
|
+
/** The width the text had to work with, in pixels. */
|
|
29
|
+
readonly containerWidth: number;
|
|
30
|
+
readonly lineCount: number;
|
|
31
|
+
readonly lastLine: {
|
|
32
|
+
readonly words: number;
|
|
33
|
+
/** How wide the last line's ink actually is. */
|
|
34
|
+
readonly width: number;
|
|
35
|
+
/** The line itself, clipped. */
|
|
36
|
+
readonly text: string;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A block that ends badly, and how. */
|
|
40
|
+
export interface TypographyFinding {
|
|
41
|
+
/** orphan: one word alone. runt: a stub of a last line. */
|
|
42
|
+
readonly kind: "orphan" | "runt";
|
|
43
|
+
/** Heading orphans read worst, so the caller may sort by this. */
|
|
44
|
+
readonly heading: boolean;
|
|
45
|
+
readonly block: TextBlock;
|
|
46
|
+
}
|
|
47
|
+
/** Body text shorter than this is skipped in the capture. */
|
|
48
|
+
export declare const BODY_FLOOR_CHARS = 80;
|
|
49
|
+
/** A last line of this many words or fewer can be a runt. */
|
|
50
|
+
export declare const RUNT_MAX_WORDS = 3;
|
|
51
|
+
/** ...when it fills less than this share of the container. */
|
|
52
|
+
export declare const RUNT_WIDTH_SHARE = 0.25;
|
|
53
|
+
/**
|
|
54
|
+
* Judge how the captured blocks end.
|
|
55
|
+
*
|
|
56
|
+
* Only blocks that actually wrapped are judged: a one-line
|
|
57
|
+
* heading has no last line to strand anything on.
|
|
58
|
+
*/
|
|
59
|
+
export declare function analyseTypography(blocks: readonly TextBlock[]): readonly TypographyFinding[];
|
|
60
|
+
/** Say how the page's text blocks end. */
|
|
61
|
+
export declare function renderTypography(blocks: readonly TextBlock[], findings: readonly TypographyFinding[]): string;
|
|
62
|
+
/**
|
|
63
|
+
* The expression that measures how text blocks wrap.
|
|
64
|
+
*
|
|
65
|
+
* The word walk reads each word's rectangle through a Range and
|
|
66
|
+
* groups words into lines by their tops, so the lines are the
|
|
67
|
+
* browser's own. Kept close to the tuned original: the fixes it
|
|
68
|
+
* carries (zero-size rects from hidden text, line grouping by
|
|
69
|
+
* half a line-height) were earned against real pages.
|
|
70
|
+
*/
|
|
71
|
+
export declare const TYPOGRAPHY_CAPTURE = "(() => {\n\tconst BODY_FLOOR = 80;\n\tconst MAX_TEXT = 80;\n\n\tconst selectorFor = (el) => {\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tif (el.id) return \"#\" + el.id;\n\t\tconst hook = el.getAttribute(\"data-testid\");\n\t\tif (hook) return tag + '[data-testid=\"' + hook + '\"]';\n\t\tconst first = el.classList[0];\n\t\tif (first) return tag + \".\" + first;\n\t\treturn tag;\n\t};\n\n\tconst visualLines = (element) => {\n\t\tconst walker = document.createTreeWalker(\n\t\t\telement,\n\t\t\tNodeFilter.SHOW_TEXT,\n\t\t\tnull,\n\t\t);\n\t\tconst nodes = [];\n\t\twhile (walker.nextNode()) {\n\t\t\tif (walker.currentNode.textContent.trim()) {\n\t\t\t\tnodes.push(walker.currentNode);\n\t\t\t}\n\t\t}\n\t\tconst range = document.createRange();\n\t\tconst lines = [];\n\t\tlet top = null;\n\t\tlet height = 0;\n\t\tlet words = 0;\n\t\tlet left = Infinity;\n\t\tlet right = -Infinity;\n\t\tlet text = \"\";\n\t\tconst flush = () => {\n\t\t\tif (words > 0) {\n\t\t\t\tlines.push({ words, width: right - left, text });\n\t\t\t}\n\t\t\twords = 0;\n\t\t\tleft = Infinity;\n\t\t\tright = -Infinity;\n\t\t\ttext = \"\";\n\t\t};\n\t\tfor (const node of nodes) {\n\t\t\tconst content = node.textContent;\n\t\t\tconst matcher = /\\S+/g;\n\t\t\tlet match = matcher.exec(content);\n\t\t\twhile (match) {\n\t\t\t\trange.setStart(node, match.index);\n\t\t\t\trange.setEnd(node, match.index + match[0].length);\n\t\t\t\tconst rect = range.getBoundingClientRect();\n\t\t\t\tif (rect.width > 0 || rect.height > 0) {\n\t\t\t\t\tconst sameLine =\n\t\t\t\t\t\ttop !== null && Math.abs(rect.top - top) <= height / 2;\n\t\t\t\t\tif (!sameLine) {\n\t\t\t\t\t\tflush();\n\t\t\t\t\t\ttop = rect.top;\n\t\t\t\t\t\theight = rect.height;\n\t\t\t\t\t}\n\t\t\t\t\twords += 1;\n\t\t\t\t\tleft = Math.min(left, rect.left);\n\t\t\t\t\tright = Math.max(right, rect.right);\n\t\t\t\t\tif (text.length < MAX_TEXT) {\n\t\t\t\t\t\ttext = (text ? text + \" \" : \"\") + match[0];\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmatch = matcher.exec(content);\n\t\t\t}\n\t\t}\n\t\tflush();\n\t\treturn lines;\n\t};\n\n\tconst wrapStyle = (el) => {\n\t\tconst style = getComputedStyle(el);\n\t\treturn style.textWrap || style.textWrapStyle || \"\";\n\t};\n\n\tconst candidates = document.querySelectorAll(\n\t\t\"p, h1, h2, h3, h4, h5, h6, figcaption\",\n\t);\n\tconst blocks = [];\n\tfor (const el of candidates) {\n\t\tif (el.getClientRects().length === 0) continue;\n\t\tconst tag = el.tagName.toLowerCase();\n\t\tconst textLength = (el.innerText || \"\").trim().length;\n\t\tif (textLength === 0) continue;\n\t\t// Headings matter most and are short; body text below the\n\t\t// floor has no room for a wrap worth judging.\n\t\tconst heading = tag[0] === \"h\";\n\t\tif (!heading && textLength < BODY_FLOOR) continue;\n\t\t// The author already asked the browser to mind the wrapping.\n\t\tconst wrap = wrapStyle(el);\n\t\tif (wrap.includes(\"balance\") || wrap.includes(\"pretty\")) continue;\n\t\t// Text inside a collapsed disclosure is not laid out.\n\t\tconst details = el.closest(\"details\");\n\t\tif (details && !details.open) continue;\n\t\tconst lines = visualLines(el);\n\t\tconst last = lines[lines.length - 1];\n\t\tif (!last) continue;\n\t\tblocks.push({\n\t\t\tselector: selectorFor(el),\n\t\t\ttag,\n\t\t\ttextLength,\n\t\t\tcontainerWidth: el.clientWidth,\n\t\t\tlineCount: lines.length,\n\t\t\tlastLine: {\n\t\t\t\twords: last.words,\n\t\t\t\twidth: last.width,\n\t\t\t\ttext: last.text.slice(0, MAX_TEXT),\n\t\t\t},\n\t\t});\n\t}\n\treturn blocks;\n})()";
|