This commit is contained in:
parent
7ef9106723
commit
5953c97cf6
3 changed files with 344 additions and 30 deletions
|
|
@ -32,8 +32,8 @@ const GEOMETRY_WIDTHS = [320, 390, 768, 1024, 1440, 1920, 2560];
|
|||
const CAPTURE_WIDTHS = new Set([390, 1440]);
|
||||
const HEIGHTS = new Map([[320, 844], [390, 844], [768, 1024], [1024, 900], [1440, 1000], [1920, 1080], [2560, 1440]]);
|
||||
const AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
const EXPECTED_SCENARIOS = 81;
|
||||
const EXPECTED_ASSERTIONS = 1009;
|
||||
const EXPECTED_SCENARIOS = 85;
|
||||
const EXPECTED_ASSERTIONS = 1089;
|
||||
const EXPECTED_CAPTURES = 8;
|
||||
|
||||
function parseArguments(argv) {
|
||||
|
|
@ -1193,6 +1193,23 @@ const LOWER_MOTION_EXPECTED_CHANGES = Object.freeze({
|
|||
process: 7,
|
||||
});
|
||||
|
||||
const PERCEPTUAL_MOTION_WIDTHS = [390, 1440];
|
||||
const PERCEPTUAL_MOTION_STEP = 110;
|
||||
const PERCEPTUAL_MOTION_CONTRACTS = Object.freeze({
|
||||
direction: {
|
||||
articleSelector: ".specimen-direction",
|
||||
stageSelector: ".direction-stage",
|
||||
childSelector: ".direction-field-inner, .direction-vector, .direction-node",
|
||||
expectedCount: 5,
|
||||
},
|
||||
type: {
|
||||
articleSelector: ".specimen-tokens",
|
||||
stageSelector: ".type-stage",
|
||||
childSelector: ".type-glyphs, .token-sample",
|
||||
expectedCount: 4,
|
||||
},
|
||||
});
|
||||
|
||||
async function lowerMotionSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const contracts = [
|
||||
|
|
@ -1349,6 +1366,196 @@ function lowerMotionSummary(snapshot) {
|
|||
}]));
|
||||
}
|
||||
|
||||
async function perceptualMotionSnapshot(page, key) {
|
||||
return page.evaluate((motionKey, contracts) => {
|
||||
const contract = contracts[motionKey];
|
||||
const article = document.querySelector(contract.articleSelector);
|
||||
const stage = document.querySelector(contract.stageSelector);
|
||||
const children = [...document.querySelectorAll(contract.childSelector)];
|
||||
const rounded = (value) => Math.round(value * 1000) / 1000;
|
||||
const verticalVisibility = (rect) => {
|
||||
if (!rect.height) return 0;
|
||||
const visible = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0));
|
||||
return rounded(visible / Math.min(rect.height, innerHeight));
|
||||
};
|
||||
const childState = (element, index) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const animations = element.getAnimations({ subtree: false });
|
||||
const animation = animations.find((candidate) => candidate.effect?.getComputedTiming) || null;
|
||||
const rawProgress = animation?.effect?.getComputedTiming()?.progress;
|
||||
return {
|
||||
index,
|
||||
label: element.classList.contains("type-glyphs") ? "glyphs"
|
||||
: element.classList.contains("token-sample") ? `token-${index}`
|
||||
: element.classList.contains("direction-field-inner") ? "plane"
|
||||
: element.classList.contains("direction-vector") ? "vector"
|
||||
: `node-${Math.max(1, index - 1)}`,
|
||||
animationName: getComputedStyle(element).animationName,
|
||||
progress: Number.isFinite(rawProgress) ? rounded(rawProgress) : null,
|
||||
top: rounded(rect.top),
|
||||
bottom: rounded(rect.bottom),
|
||||
visibility: verticalVisibility(rect),
|
||||
};
|
||||
};
|
||||
const articleRect = article?.getBoundingClientRect() || null;
|
||||
const stageRect = stage?.getBoundingClientRect() || null;
|
||||
return {
|
||||
article: articleRect ? {
|
||||
top: rounded(articleRect.top),
|
||||
bottom: rounded(articleRect.bottom),
|
||||
} : null,
|
||||
children: children.map(childState),
|
||||
stage: stageRect ? {
|
||||
top: rounded(stageRect.top),
|
||||
bottom: rounded(stageRect.bottom),
|
||||
visibility: verticalVisibility(stageRect),
|
||||
} : null,
|
||||
scrollY: rounded(scrollY),
|
||||
supportsViewTimeline: CSS.supports("animation-timeline: view()"),
|
||||
viewportHeight: innerHeight,
|
||||
};
|
||||
}, key, PERCEPTUAL_MOTION_CONTRACTS);
|
||||
}
|
||||
|
||||
async function collectPerceptualMotionTrip(page, key) {
|
||||
const contract = PERCEPTUAL_MOTION_CONTRACTS[key];
|
||||
const geometry = await page.evaluate((selector, step) => {
|
||||
const article = document.querySelector(selector);
|
||||
if (!article) return null;
|
||||
const rect = article.getBoundingClientRect();
|
||||
const absoluteTop = scrollY + rect.top;
|
||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight);
|
||||
const startY = Math.max(0, Math.min(maxScroll, absoluteTop - innerHeight - 48));
|
||||
const steps = Math.ceil((innerHeight + 300) / step);
|
||||
window.scrollTo({ top: startY, behavior: "instant" });
|
||||
return { startY, steps };
|
||||
}, contract.articleSelector, PERCEPTUAL_MOTION_STEP);
|
||||
if (!geometry) throw new Error(`Perceptual-motion target is missing for ${key}`);
|
||||
|
||||
await settleTwoAnimationFrames(page);
|
||||
const viewport = page.viewport();
|
||||
await page.mouse.move(Math.round(viewport.width / 2), Math.round(viewport.height / 2));
|
||||
const forward = [await perceptualMotionSnapshot(page, key)];
|
||||
for (let index = 0; index < geometry.steps; index += 1) {
|
||||
await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP });
|
||||
await settleTwoAnimationFrames(page);
|
||||
forward.push(await perceptualMotionSnapshot(page, key));
|
||||
}
|
||||
|
||||
const reverse = [];
|
||||
for (let index = 0; index < geometry.steps; index += 1) {
|
||||
await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP * -1 });
|
||||
await settleTwoAnimationFrames(page);
|
||||
reverse.push(await perceptualMotionSnapshot(page, key));
|
||||
}
|
||||
return { forward, reverse, step: PERCEPTUAL_MOTION_STEP };
|
||||
}
|
||||
|
||||
function progressIsMonotonic(samples, direction, tolerance = 0.05) {
|
||||
const count = samples[0]?.children?.length || 0;
|
||||
for (let childIndex = 0; childIndex < count; childIndex += 1) {
|
||||
const values = samples.map((sample) => sample.children[childIndex]?.progress).filter(Number.isFinite);
|
||||
for (let index = 1; index < values.length; index += 1) {
|
||||
if (direction === "forward" && values[index] + tolerance < values[index - 1]) return false;
|
||||
if (direction === "reverse" && values[index] - tolerance > values[index - 1]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function wheelDeltasStayDiscrete(samples, direction) {
|
||||
const deltas = [];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
deltas.push(samples[index].scrollY - samples[index - 1].scrollY);
|
||||
}
|
||||
return {
|
||||
deltas,
|
||||
pass: deltas.every((delta) => direction === "forward"
|
||||
? delta >= 90 && delta <= 125
|
||||
: delta <= -90 && delta >= -125),
|
||||
};
|
||||
}
|
||||
|
||||
function motionRestoreSummary(trip, tolerance = 0.05) {
|
||||
const initial = trip.forward[0]?.children || [];
|
||||
const restored = trip.reverse.at(-1)?.children || [];
|
||||
const deltas = initial.map((child, index) => ({
|
||||
label: child.label,
|
||||
delta: Number.isFinite(child.progress) && Number.isFinite(restored[index]?.progress)
|
||||
? Math.abs(child.progress - restored[index].progress)
|
||||
: Number.POSITIVE_INFINITY,
|
||||
}));
|
||||
return { deltas, pass: deltas.every((item) => item.delta <= tolerance) };
|
||||
}
|
||||
|
||||
function offscreenProgressViolations(samples, limit = 0.1) {
|
||||
return samples.flatMap((sample, sampleIndex) => sample.children
|
||||
.filter((child) => child.top >= sample.viewportHeight - 0.5 && Number.isFinite(child.progress) && child.progress > limit)
|
||||
.map((child) => ({ sample: sampleIndex, scrollY: sample.scrollY, ...child })));
|
||||
}
|
||||
|
||||
function longestMatchingRun(samples, predicate) {
|
||||
let best = { count: 0, span: 0 };
|
||||
let start = -1;
|
||||
for (let index = 0; index <= samples.length; index += 1) {
|
||||
const matches = index < samples.length && predicate(samples[index], index);
|
||||
if (matches && start < 0) start = index;
|
||||
if ((!matches || index === samples.length) && start >= 0) {
|
||||
const end = index - 1;
|
||||
const run = {
|
||||
count: end - start + 1,
|
||||
span: Math.abs(samples[end].scrollY - samples[start].scrollY),
|
||||
};
|
||||
if (run.count > best.count || (run.count === best.count && run.span > best.span)) best = run;
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function directionPerceptualSummary(samples) {
|
||||
const partial = (child) => child.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85;
|
||||
const dwell = longestMatchingRun(samples, (sample) => (
|
||||
sample.stage?.visibility >= 0.5 && sample.children.filter(partial).length >= 3
|
||||
));
|
||||
let maxPhaseBuckets = 0;
|
||||
let phaseSample = null;
|
||||
for (const sample of samples.filter((item) => item.stage?.visibility >= 0.5)) {
|
||||
const progresses = sample.children.map((child) => child.progress).filter(Number.isFinite).sort((a, b) => a - b);
|
||||
const buckets = [];
|
||||
for (const progress of progresses) {
|
||||
if (!buckets.length || progress - buckets.at(-1) >= 0.08) buckets.push(progress);
|
||||
}
|
||||
if (buckets.length > maxPhaseBuckets) {
|
||||
maxPhaseBuckets = buckets.length;
|
||||
phaseSample = { buckets, scrollY: sample.scrollY, stageVisibility: sample.stage.visibility };
|
||||
}
|
||||
}
|
||||
return { dwell, maxPhaseBuckets, phaseSample };
|
||||
}
|
||||
|
||||
function typePerceptualSummary(samples) {
|
||||
const tokenIndexes = [1, 2, 3];
|
||||
const visibleRuns = tokenIndexes.map((childIndex) => ({
|
||||
label: samples[0]?.children?.[childIndex]?.label,
|
||||
...longestMatchingRun(samples, (sample) => {
|
||||
const child = sample.children[childIndex];
|
||||
return child?.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85;
|
||||
}),
|
||||
}));
|
||||
const premature = samples.flatMap((sample, sampleIndex) => tokenIndexes
|
||||
.map((childIndex) => sample.children[childIndex])
|
||||
.filter((child) => child && child.top >= 0 && child.visibility < 0.5 && Number.isFinite(child.progress) && child.progress > 0.25)
|
||||
.map((child) => ({ sample: sampleIndex, scrollY: sample.scrollY, ...child })));
|
||||
const staggerSamples = samples.flatMap((sample, sampleIndex) => {
|
||||
const tokens = tokenIndexes.map((index) => sample.children[index]);
|
||||
if (!tokens.every((child) => child?.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85)) return [];
|
||||
const gaps = [tokens[0].progress - tokens[1].progress, tokens[1].progress - tokens[2].progress];
|
||||
return [{ gaps, pass: gaps.every((gap) => gap >= 0.08), progress: tokens.map((child) => child.progress), sample: sampleIndex, scrollY: sample.scrollY }];
|
||||
});
|
||||
return { premature, staggerSamples, visibleRuns };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArguments(process.argv);
|
||||
if (options.help) {
|
||||
|
|
@ -1614,6 +1821,74 @@ async function main() {
|
|||
});
|
||||
}
|
||||
|
||||
// Endpoint signatures prove that CSS is connected, but they can still pass
|
||||
// when the authored motion finishes before its artwork becomes visible.
|
||||
// Follow direction and type through their actual viewport interval with
|
||||
// discrete wheel input, including a reverse trip. The 390/1440 pair keeps
|
||||
// this perceptual gate focused without duplicating the 320..2560 geometry
|
||||
// matrix above.
|
||||
for (const route of ROUTES.slice(0, 2)) {
|
||||
for (const width of PERCEPTUAL_MOTION_WIDTHS) {
|
||||
const scope = `perceptual lower motion ${route.key} ${width}px`;
|
||||
await results.scenario(scope, async () => {
|
||||
const opened = await openPage(browser, server.origin, route, { width });
|
||||
try {
|
||||
const direction = await collectPerceptualMotionTrip(opened.page, "direction");
|
||||
const type = await collectPerceptualMotionTrip(opened.page, "type");
|
||||
const directionInitial = direction.forward[0];
|
||||
const typeInitial = type.forward[0];
|
||||
|
||||
results.equal(scope, "HTTP status", 200, opened.status);
|
||||
results.equal(scope, "Chromium supports native view timelines", true, Boolean(directionInitial.supportsViewTimeline && typeInitial.supportsViewTimeline));
|
||||
|
||||
for (const [key, trip] of [["direction", direction], ["type", type]]) {
|
||||
const expected = PERCEPTUAL_MOTION_CONTRACTS[key].expectedCount;
|
||||
const forwardWheel = wheelDeltasStayDiscrete(trip.forward, "forward");
|
||||
const reverseWheel = wheelDeltasStayDiscrete(trip.reverse, "reverse");
|
||||
const restored = motionRestoreSummary(trip);
|
||||
const offscreen = offscreenProgressViolations(trip.forward);
|
||||
results.equal(scope, `${key} perceptual target has every animated child`, expected, trip.forward[0]?.children?.length);
|
||||
results.check(scope, `${key} uses 110px discrete wheel steps in both directions`, forwardWheel.pass && reverseWheel.pass, {
|
||||
expected: "every forward/reverse delta has magnitude 90..125px",
|
||||
actual: { forward: forwardWheel.deltas, reverse: reverseWheel.deltas },
|
||||
});
|
||||
results.equal(scope, `${key} progress is monotonic through forward wheel input`, true, progressIsMonotonic(trip.forward, "forward"));
|
||||
results.equal(scope, `${key} progress is monotonic through reverse wheel input`, true, progressIsMonotonic(trip.reverse, "reverse"));
|
||||
results.check(scope, `${key} reverse wheel trip restores every initial progress`, restored.pass, {
|
||||
expected: "each progress delta <= 0.05",
|
||||
actual: restored.deltas,
|
||||
});
|
||||
results.empty(scope, `${key} children do not exceed 10% progress while still below the viewport`, offscreen);
|
||||
}
|
||||
|
||||
const directionPerception = directionPerceptualSummary(direction.forward);
|
||||
results.check(scope, "direction motion visibly dwells across at least three wheel samples and 220px", directionPerception.dwell.count >= 3 && directionPerception.dwell.span >= 220, {
|
||||
expected: { count: ">= 3", span: ">= 220" },
|
||||
actual: directionPerception.dwell,
|
||||
});
|
||||
results.check(scope, "direction exposes at least three independent progress phases while visible", directionPerception.maxPhaseBuckets >= 3, {
|
||||
expected: ">= 3 progress buckets separated by 0.08",
|
||||
actual: directionPerception,
|
||||
});
|
||||
|
||||
const typePerception = typePerceptualSummary(type.forward);
|
||||
results.empty(scope, "tokens do not exceed 25% progress before becoming half visible", typePerception.premature);
|
||||
results.check(scope, "every token visibly dwells in a partial state for two wheel samples and 110px", typePerception.visibleRuns.every((run) => run.count >= 2 && run.span >= 110), {
|
||||
expected: "each token: count >= 2 and span >= 110",
|
||||
actual: typePerception.visibleRuns,
|
||||
});
|
||||
results.check(scope, "visible token samples preserve the authored stagger", typePerception.staggerSamples.some((sample) => sample.pass), {
|
||||
expected: "at least one all-visible partial sample with both progress gaps >= 0.08",
|
||||
actual: typePerception.staggerSamples,
|
||||
});
|
||||
results.empty(scope, "perceptual lower-motion wheel trips have no runtime/local-resource errors", opened.runtime);
|
||||
} finally {
|
||||
await closePage(opened.page);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(LOWER_MOTION_EXPECTED_COUNTS)) {
|
||||
const samples = lowerMotionGeometryEvidence.filter((item) => item.key === key);
|
||||
results.check("lower graphic geometry hard lock", `${key} samples cross before-entry and past-range geometry in both locales`, samples.length === 2 && samples.every((item) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue