- {copy.tokens.specimen}
- {copy.tokens.koreanSpecimen}
+ {copy.tokens.specimen}
+ {copy.tokens.koreanSpecimen}
@@ -212,7 +213,7 @@ const copy = lang === "en"
.direction-stage {
position: relative;
- min-height: 24rem;
+ aspect-ratio: 2 / 1;
overflow: clip;
background: var(--surface-soft);
border-bottom: var(--line-width) solid var(--line);
@@ -252,6 +253,16 @@ const copy = lang === "en"
vector-effect: non-scaling-stroke;
}
+ .direction-segment-1 {
+ transform-box: fill-box;
+ transform-origin: left top;
+ }
+
+ .direction-segment-2 {
+ transform-box: fill-box;
+ transform-origin: left bottom;
+ }
+
.direction-node {
fill: var(--accent);
stroke: var(--surface-raised);
@@ -355,7 +366,7 @@ const copy = lang === "en"
.token-register dd { display: flex; align-items: center; gap: var(--space-2); margin: 0; }
.token-sample {
- width: 2.75rem;
+ width: clamp(3.75rem, 6vw, 5rem);
height: 0.5rem;
border: var(--line-width) solid var(--line-inverse);
}
@@ -444,8 +455,8 @@ const copy = lang === "en"
view-timeline-axis: block;
}
- .specimen-proof {
- view-timeline-name: --proof-specimen;
+ .proof-stage {
+ view-timeline-name: --proof-stage;
view-timeline-axis: block;
}
@@ -453,17 +464,14 @@ const copy = lang === "en"
.direction-field-inner {
animation: direction-plane-in linear both;
animation-timeline: --direction-stage;
- animation-range: entry 32% cover 58%;
+ animation-range: cover 30% cover 50%;
transform-box: fill-box;
transform-origin: center;
}
- .direction-vector {
- animation: direction-vector-in linear both;
+ .direction-segment {
+ animation: direction-segment-in linear both;
animation-timeline: --direction-stage;
- animation-range: entry 64% cover 64%;
- transform-box: fill-box;
- transform-origin: left center;
}
.direction-node {
@@ -473,31 +481,40 @@ const copy = lang === "en"
transform-origin: center;
}
- .direction-node:nth-of-type(1) {
- --direction-node-x: calc(var(--space-5) * -1);
- --direction-node-y: var(--space-4);
- animation-range: entry 64% cover 58%;
- }
+ .direction-node-1 { animation-range: cover 18% cover 24%; }
+ .direction-segment-1 { animation-range: cover 20% cover 56%; }
+ .direction-node-2 { animation-range: cover 56% cover 62%; }
+ .direction-segment-2 { animation-range: cover 58% cover 80%; }
+ .direction-node-3 { animation-range: cover 80% cover 86%; }
- .direction-node:nth-of-type(2) {
- --direction-node-x: 0px;
- --direction-node-y: var(--space-5);
- animation-range: entry 88% cover 64%;
- }
-
- .direction-node:nth-of-type(3) {
- --direction-node-x: var(--space-5);
- --direction-node-y: calc(var(--space-4) * -1);
- animation-range: entry 72% cover 64%;
- }
-
- .type-glyphs {
- animation: type-register linear both;
+ .type-glyph {
animation-timeline: --type-stage;
- animation-range: entry 12% cover 58%;
- transform-origin: left bottom;
+ transform-origin: center bottom;
}
+ .type-glyph-latin {
+ animation: type-glyph-latin-in linear both;
+ animation-timeline: --type-stage;
+ animation-range: cover 20% cover 48%;
+ }
+
+ .type-glyph-korean {
+ animation: type-glyph-korean-in linear both;
+ animation-timeline: --type-stage;
+ animation-range: cover 30% cover 58%;
+ }
+
+ .type-baselines span {
+ animation: type-baseline-in linear both;
+ animation-timeline: --type-stage;
+ transform-origin: left center;
+ }
+
+ .type-baselines span:nth-child(1) { animation-range: cover 24% cover 54%; }
+ .type-baselines span:nth-child(2) { animation-range: cover 30% cover 60%; }
+ .type-baselines span:nth-child(3) { animation-range: cover 36% cover 66%; }
+ .type-baselines span:nth-child(4) { animation-range: cover 42% cover 72%; }
+
.token-sample {
animation: token-resolve linear both;
animation-timeline: --type-stage;
@@ -505,94 +522,105 @@ const copy = lang === "en"
}
.token-register > div:nth-child(1) .token-sample {
- animation-range: entry 60% cover 58%;
+ animation-range: cover 40% cover 84%;
}
.token-register > div:nth-child(2) .token-sample {
- animation-range: entry 72% cover 64%;
+ animation-range: cover 46% cover 90%;
}
.token-register > div:nth-child(3) .token-sample {
- animation-range: entry 84% cover 70%;
+ animation-range: cover 52% cover 96%;
}
- .viewport {
+ .viewport-medium,
+ .viewport-narrow {
animation: frame-resolve linear both;
- animation-timeline: --proof-specimen;
- animation-range: entry 8% cover 32%;
+ animation-timeline: --proof-stage;
+ transform-origin: center bottom;
}
.viewport-narrow {
- --frame-x: var(--space-6);
- --frame-y: var(--space-3);
+ --frame-x: 190%;
+ --frame-y: 8%;
+ --frame-scale: 0.72;
+ animation-range: entry 25% cover 60%;
}
.viewport-medium {
- --frame-x: var(--space-4);
- --frame-y: var(--space-2);
- }
-
- .viewport-wide {
- --frame-x: 0;
- --frame-y: 0;
+ --frame-x: 95%;
+ --frame-y: 4%;
+ --frame-scale: 0.86;
+ animation-range: entry 10% cover 42%;
}
.check-register svg {
animation: check-pass linear both;
- animation-timeline: --proof-specimen;
+ animation-timeline: --proof-stage;
transform-box: fill-box;
- transform-origin: center;
+ transform-origin: left center;
}
.check-register li:nth-child(1) svg {
- animation-range: entry 30% cover 42%;
+ animation-range: cover 34% cover 46%;
}
.check-register li:nth-child(2) svg {
- animation-range: entry 34% cover 46%;
+ animation-range: cover 40% cover 52%;
}
.check-register li:nth-child(3) svg {
- animation-range: entry 38% cover 50%;
+ animation-range: cover 46% cover 58%;
}
.check-register li:nth-child(4) svg {
- animation-range: entry 42% cover 54%;
+ animation-range: cover 52% cover 64%;
}
@keyframes direction-plane-in {
- from { opacity: 0.72; translate: 0 var(--space-5); scale: 0.92 0.92; }
+ from { opacity: 0.62; translate: 0 var(--space-6); scale: 0.84 0.84; }
to { opacity: 1; translate: 0 0; scale: 1 1; }
}
- @keyframes direction-vector-in {
- from { opacity: 0.08; translate: calc(var(--space-2) * -1) var(--space-1); scale: 0.12 1; }
- to { opacity: 1; translate: 0 0; scale: 1 1; }
- }
-
- @keyframes direction-node-in {
- from { opacity: 0.08; translate: var(--direction-node-x) var(--direction-node-y); scale: 0.35 0.35; }
- to { opacity: 1; translate: 0 0; scale: 1 1; }
- }
-
- @keyframes type-register {
- from { translate: 0 var(--space-5); scale: 0.92 0.92; }
- to { translate: 0 0; scale: 1 1; }
- }
-
- @keyframes token-resolve {
- from { opacity: 0.18; scale: 0.04 1; }
+ @keyframes direction-segment-in {
+ from { opacity: 0; scale: 0 0; }
+ 5% { opacity: 1; }
to { opacity: 1; scale: 1 1; }
}
+ @keyframes direction-node-in {
+ from { opacity: 0; scale: 0.45 0.45; }
+ to { opacity: 1; scale: 1 1; }
+ }
+
+ @keyframes type-glyph-latin-in {
+ from { translate: 3rem 0; scale: 0.84 0.84; }
+ to { translate: 0 0; scale: 1 1; }
+ }
+
+ @keyframes type-glyph-korean-in {
+ from { translate: -2.5rem 0; scale: 0.82 0.82; }
+ to { translate: 0 0; scale: 1 1; }
+ }
+
+ @keyframes type-baseline-in {
+ from { scale: 0.08 1; }
+ to { scale: 1 1; }
+ }
+
+ @keyframes token-resolve {
+ from { scale: 0.05 1; }
+ to { scale: 1 1; }
+ }
+
@keyframes frame-resolve {
- from { translate: var(--frame-x) var(--frame-y); }
- to { translate: 0 0; }
+ from { translate: var(--frame-x) var(--frame-y); scale: var(--frame-scale) var(--frame-scale); }
+ to { translate: 0 0; scale: 1 1; }
}
@keyframes check-pass {
- from { opacity: 0.2; translate: calc(var(--space-2) * -1) 0; }
- to { opacity: 1; translate: 0 0; }
+ from { opacity: 0.28; translate: calc(var(--space-4) * -1) 0; scale: 0.35 1; }
+ to { opacity: 1; translate: 0 0; scale: 1 1; }
}
}
}
@@ -608,58 +636,10 @@ const copy = lang === "en"
.specimen-direction,
.specimen-tokens,
.specimen-proof { grid-column: 1; margin-top: 0; }
- .direction-stage { min-height: 21rem; }
+ .direction-stage { aspect-ratio: 16 / 9; }
.type-stage { min-height: 24rem; }
.proof-stage { grid-template-columns: minmax(0, 1fr); }
.viewport-family { min-height: 18rem; border-right: 0; border-bottom: var(--line-width) solid var(--line); }
-
- @supports (animation-timeline: view()) {
- @media (prefers-reduced-motion: no-preference) {
- .direction-node:nth-of-type(1) {
- --direction-node-x: calc(var(--space-4) * -1);
- --direction-node-y: var(--space-3);
- }
-
- .direction-node:nth-of-type(2) {
- --direction-node-y: var(--space-4);
- }
-
- .direction-node:nth-of-type(3) {
- --direction-node-x: var(--space-4);
- --direction-node-y: calc(var(--space-3) * -1);
- }
-
- .viewport-narrow {
- --frame-x: var(--space-3);
- --frame-y: var(--space-2);
- }
-
- .viewport-medium {
- --frame-x: var(--space-2);
- --frame-y: var(--space-1);
- }
-
- .viewport {
- animation-range: entry 4% cover 28%;
- }
-
- .check-register li:nth-child(1) svg {
- animation-range: entry 26% cover 38%;
- }
-
- .check-register li:nth-child(2) svg {
- animation-range: entry 30% cover 42%;
- }
-
- .check-register li:nth-child(3) svg {
- animation-range: entry 34% cover 46%;
- }
-
- .check-register li:nth-child(4) svg {
- animation-range: entry 38% cover 50%;
- }
- }
- }
}
@media (max-width: 28rem) {
@@ -673,16 +653,22 @@ const copy = lang === "en"
@media (prefers-reduced-motion: reduce) {
.direction-field-inner,
- .direction-vector,
+ .direction-segment,
.direction-node,
- .type-glyphs,
+ .type-glyph,
.token-sample,
- .viewport,
+ .viewport-medium,
+ .viewport-narrow,
.check-register svg {
animation: none;
opacity: 1;
translate: 0 0;
scale: 1 1;
}
+
+ .type-baselines span {
+ animation: none;
+ scale: 1 1;
+ }
}
diff --git a/apps/site/tools/site-e2e.mjs b/apps/site/tools/site-e2e.mjs
index f09791c..672a131 100644
--- a/apps/site/tools/site-e2e.mjs
+++ b/apps/site/tools/site-e2e.mjs
@@ -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 = 85;
-const EXPECTED_ASSERTIONS = 1089;
+const EXPECTED_SCENARIOS = 86;
+const EXPECTED_ASSERTIONS = 1117;
const EXPECTED_CAPTURES = 8;
function parseArguments(argv) {
@@ -1178,19 +1178,19 @@ function uniqueLayerSignatureCount(snapshot) {
}
const LOWER_MOTION_EXPECTED_COUNTS = Object.freeze({
- direction: 5,
- type: 4,
+ direction: 6,
+ type: 9,
proof: 7,
- process: 7,
+ process: 1,
});
// The 1440 viewport is the proof specimen's fixed registration frame. The two
// smaller viewports resolve against it, so six of seven proof graphics move.
const LOWER_MOTION_EXPECTED_CHANGES = Object.freeze({
- direction: 5,
- type: 4,
+ direction: 6,
+ type: 9,
proof: 6,
- process: 7,
+ process: 1,
});
const PERCEPTUAL_MOTION_WIDTHS = [390, 1440];
@@ -1200,13 +1200,37 @@ const PERCEPTUAL_MOTION_CONTRACTS = Object.freeze({
articleSelector: ".specimen-direction",
stageSelector: ".direction-stage",
childSelector: ".direction-field-inner, .direction-vector, .direction-node",
- expectedCount: 5,
+ expectedCount: 6,
},
type: {
articleSelector: ".specimen-tokens",
stageSelector: ".type-stage",
- childSelector: ".type-glyphs, .token-sample",
- expectedCount: 4,
+ childSelector: ".type-glyph, .type-baselines span, .token-sample",
+ expectedCount: 9,
+ },
+});
+
+// This viewport matches the browser-feedback capture closely enough to keep
+// the authored graphics fully visible while a single 110px wheel step runs.
+// Element-relative screenshots prevent ordinary page translation from being
+// mistaken for motion inside the artwork.
+const VISIBLE_MOTION_VIEWPORT = Object.freeze({ width: 1316, height: 1272 });
+const VISIBLE_MOTION_CONTRACTS = Object.freeze({
+ direction: {
+ targetSelector: ".direction-stage",
+ childSelector: ".direction-field-inner, .direction-vector, .direction-node",
+ },
+ type: {
+ targetSelector: ".type-stage",
+ childSelector: ".type-glyph, .type-baselines span, .token-sample",
+ },
+ proof: {
+ targetSelector: ".proof-stage",
+ childSelector: ".viewport, .check-register svg",
+ },
+ process: {
+ targetSelector: ".stage-list",
+ pseudo: "::after",
},
});
@@ -1214,9 +1238,9 @@ async function lowerMotionSnapshot(page) {
return page.evaluate(() => {
const contracts = [
{ key: "direction", selector: ".direction-field-inner, .direction-vector, .direction-node" },
- { key: "type", selector: ".type-glyphs, .token-sample" },
+ { key: "type", selector: ".type-glyph, .type-baselines span, .token-sample" },
{ key: "proof", selector: ".viewport, .check-register svg" },
- { key: "process", selector: ".stage-list li", pseudo: "::after" },
+ { key: "process", selector: ".stage-list", pseudo: "::after" },
];
const rounded = (value) => Math.round(value * 1000) / 1000;
const numericPair = (value, fallback) => {
@@ -1237,7 +1261,10 @@ async function lowerMotionSnapshot(page) {
const translate = numericPair(style.translate, [0, 0]);
const scale = numericPair(style.scale, [1, 1]);
const opacity = rounded(Number.parseFloat(style.opacity) || 0);
- const final = opacity >= 0.99
+ const opacityFinal = element.closest(".type-baselines")
+ ? Math.abs(opacity - 0.38) <= 0.05
+ : opacity >= 0.99;
+ const final = opacityFinal
&& translate.every((value) => Math.abs(value) <= 0.05)
&& scale.every((value) => Math.abs(value - 1) <= 0.05)
&& matrix.every((value, index) => Math.abs(value - [1, 0, 0, 1, 0, 0][index]) <= 0.05);
@@ -1283,9 +1310,7 @@ async function scrollLowerMotionAndSnapshot(page, key, { phase = "past", settle
const edgeMargin = 32;
const desired = motionPhase === "before"
? absoluteTop - innerHeight - edgeMargin
- : motionKey === "process"
- ? absoluteTop + rect.height + edgeMargin
- : absoluteTop + edgeMargin;
+ : absoluteTop + rect.height + edgeMargin;
const max = Math.max(0, document.documentElement.scrollHeight - innerHeight);
const scrollTop = Math.max(0, Math.min(max, desired));
window.scrollTo({ top: scrollTop, behavior: "instant" });
@@ -1385,11 +1410,14 @@ async function perceptualMotionSnapshot(page, key) {
const rawProgress = animation?.effect?.getComputedTiming()?.progress;
return {
index,
- label: element.classList.contains("type-glyphs") ? "glyphs"
- : element.classList.contains("token-sample") ? `token-${index}`
+ label: element.classList.contains("type-glyph-latin") ? "glyph-latin"
+ : element.classList.contains("type-glyph-korean") ? "glyph-korean"
+ : element.classList.contains("token-sample") ? `token-${element.className.match(/token-sample-(\d+)/)?.[1] || index}`
+ : element.closest(".type-baselines") ? `baseline-${Math.max(1, index - 1)}`
: element.classList.contains("direction-field-inner") ? "plane"
- : element.classList.contains("direction-vector") ? "vector"
- : `node-${Math.max(1, index - 1)}`,
+ : element.classList.contains("direction-segment-1") ? "segment-1"
+ : element.classList.contains("direction-segment-2") ? "segment-2"
+ : `node-${element.className.baseVal?.match(/direction-node-(\d+)/)?.[1] || Math.max(1, index - 2)}`,
animationName: getComputedStyle(element).animationName,
progress: Number.isFinite(rawProgress) ? rounded(rawProgress) : null,
top: rounded(rect.top),
@@ -1426,7 +1454,7 @@ async function collectPerceptualMotionTrip(page, key) {
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);
+ const steps = Math.ceil((innerHeight + rect.height + 300) / step);
window.scrollTo({ top: startY, behavior: "instant" });
return { startY, steps };
}, contract.articleSelector, PERCEPTUAL_MOTION_STEP);
@@ -1516,7 +1544,7 @@ function longestMatchingRun(samples, predicate) {
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
+ sample.stage?.visibility >= 0.5 && sample.children.some(partial)
));
let maxPhaseBuckets = 0;
let phaseSample = null;
@@ -1535,20 +1563,21 @@ function directionPerceptualSummary(samples) {
}
function typePerceptualSummary(samples) {
- const tokenIndexes = [1, 2, 3];
- const visibleRuns = tokenIndexes.map((childIndex) => ({
- label: samples[0]?.children?.[childIndex]?.label,
+ const tokenLabels = ["token-1", "token-2", "token-3"];
+ const childByLabel = (sample, label) => sample.children.find((child) => child.label === label);
+ const visibleRuns = tokenLabels.map((label) => ({
+ label,
...longestMatchingRun(samples, (sample) => {
- const child = sample.children[childIndex];
+ const child = childByLabel(sample, label);
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])
+ const premature = samples.flatMap((sample, sampleIndex) => tokenLabels
+ .map((label) => childByLabel(sample, label))
.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]);
+ const tokens = tokenLabels.map((label) => childByLabel(sample, label));
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 }];
@@ -1556,6 +1585,232 @@ function typePerceptualSummary(samples) {
return { premature, staggerSamples, visibleRuns };
}
+function pngDifference(beforeBuffer, afterBuffer, channelThreshold = 10) {
+ const before = PNG.sync.read(beforeBuffer);
+ const after = PNG.sync.read(afterBuffer);
+ if (before.width !== after.width || before.height !== after.height) {
+ return {
+ changedPixels: Number.POSITIVE_INFINITY,
+ dimensionsMatch: false,
+ height: [before.height, after.height],
+ ratio: 1,
+ width: [before.width, after.width],
+ };
+ }
+ let changedPixels = 0;
+ let totalDelta = 0;
+ for (let offset = 0; offset < before.data.length; offset += 4) {
+ let pixelDelta = 0;
+ for (let channel = 0; channel < 4; channel += 1) {
+ pixelDelta = Math.max(pixelDelta, Math.abs(before.data[offset + channel] - after.data[offset + channel]));
+ }
+ totalDelta += pixelDelta;
+ if (pixelDelta >= channelThreshold) changedPixels += 1;
+ }
+ const pixels = before.width * before.height;
+ return {
+ changedPixels,
+ dimensionsMatch: true,
+ height: before.height,
+ meanDelta: Math.round(totalDelta / pixels * 1000) / 1000,
+ ratio: Math.round(changedPixels / pixels * 1e6) / 1e6,
+ width: before.width,
+ };
+}
+
+function visibleMotionStateDelta(before, after) {
+ const beforeItems = new Map((before?.items || []).map((item) => [item.label, item]));
+ const itemDeltas = (after?.items || []).map((item) => {
+ const previous = beforeItems.get(item.label);
+ if (!previous) return { label: item.label, computed: Number.POSITIVE_INFINITY, geometry: Number.POSITIVE_INFINITY };
+ const computedLength = Math.max(previous.computed.length, item.computed.length);
+ const geometryLength = Math.max(previous.geometry.length, item.geometry.length);
+ let computed = 0;
+ let geometry = 0;
+ for (let index = 0; index < computedLength; index += 1) {
+ computed = Math.max(computed, Math.abs((previous.computed[index] ?? 0) - (item.computed[index] ?? 0)));
+ }
+ for (let index = 0; index < geometryLength; index += 1) {
+ geometry = Math.max(geometry, Math.abs((previous.geometry[index] ?? 0) - (item.geometry[index] ?? 0)));
+ }
+ return { label: item.label, computed, geometry };
+ });
+ return {
+ computedChanged: itemDeltas.filter((item) => item.computed >= 0.01).length,
+ geometryChanged: itemDeltas.filter((item) => item.geometry >= 0.25).length,
+ itemDeltas,
+ maxComputedDelta: Math.max(0, ...itemDeltas.map((item) => item.computed)),
+ maxGeometryDelta: Math.max(0, ...itemDeltas.map((item) => item.geometry)),
+ };
+}
+
+async function positionVisibleMotionTarget(page, key) {
+ const contract = VISIBLE_MOTION_CONTRACTS[key];
+ const positioned = await page.evaluate((targetSelector, alignAtEntry) => {
+ const target = document.querySelector(targetSelector);
+ if (!target) return null;
+ const rect = target.getBoundingClientRect();
+ const absoluteTop = scrollY + rect.top;
+ const centeredTop = (innerHeight - rect.height) / 2;
+ const entryTop = innerHeight - rect.height - 160;
+ const targetTop = alignAtEntry ? Math.max(80, entryTop) : Math.max(80, centeredTop);
+ const maxScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight);
+ const desired = Math.max(0, Math.min(maxScroll, absoluteTop - targetTop));
+ window.scrollTo({ top: desired, left: 0, behavior: "instant" });
+ return { desired, targetTop };
+ }, contract.targetSelector, key === "direction");
+ if (!positioned) throw new Error(`Visible-motion target is missing for ${key}`);
+ await waitForScrollSettle(page, { initialDelay: 40 });
+ await settleTwoAnimationFrames(page);
+ return positioned;
+}
+
+async function visibleMotionFrame(page, key) {
+ const contract = VISIBLE_MOTION_CONTRACTS[key];
+ const state = await page.evaluate((motionKey, motionContract) => {
+ const rounded = (value) => Math.round(value * 1000) / 1000;
+ const numbers = (value, fallback = []) => {
+ if (!value || value === "none" || value === "normal") return [...fallback];
+ return (value.match(/-?(?:\d+\.?\d*|\.\d+)/g)?.map((item) => rounded(Number(item))) || [...fallback]);
+ };
+ const transformMatrix = (value) => {
+ if (!value || value === "none") return [1, 0, 0, 1, 0, 0];
+ try {
+ const matrix = new DOMMatrixReadOnly(value);
+ return [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f].map(rounded);
+ } catch {
+ return [1, 0, 0, 1, 0, 0];
+ }
+ };
+ const target = document.querySelector(motionContract.targetSelector);
+ if (!target) return null;
+ const targetRect = target.getBoundingClientRect();
+ const elements = motionContract.pseudo ? [target] : [...target.querySelectorAll(motionContract.childSelector)];
+ const items = elements.map((element, index) => {
+ const style = getComputedStyle(element, motionContract.pseudo || null);
+ const rect = element.getBoundingClientRect();
+ const matrix = transformMatrix(style.transform);
+ const scale = numbers(style.scale, [1, 1]);
+ const animation = element.getAnimations({ subtree: false })
+ .find((candidate) => candidate.effect?.getComputedTiming) || null;
+ const rawProgress = motionContract.pseudo ? null : animation?.effect?.getComputedTiming()?.progress;
+ const width = Number.parseFloat(style.width) || rect.width;
+ const pseudoRenderedWidth = motionContract.pseudo
+ ? width * Math.abs(matrix[0]) * Math.abs(scale[0] ?? 1)
+ : null;
+ const pseudoRenderedHeight = motionContract.pseudo
+ ? (Number.parseFloat(style.height) || rect.height) * Math.abs(matrix[3]) * Math.abs(scale[1] ?? scale[0] ?? 1)
+ : null;
+ return {
+ label: motionContract.pseudo ? `${motionKey}-rail` : `${motionKey}-${index}`,
+ computed: [
+ ...matrix,
+ ...numbers(style.translate, [0, 0]),
+ ...scale,
+ rounded(Number.parseFloat(style.opacity) || 0),
+ ...numbers(style.strokeDasharray),
+ ...numbers(style.strokeDashoffset),
+ Number.isFinite(rawProgress) ? rounded(rawProgress) : -1,
+ ],
+ geometry: motionContract.pseudo
+ ? [rounded(pseudoRenderedWidth), rounded(pseudoRenderedHeight)]
+ : [
+ rounded(rect.left - targetRect.left),
+ rounded(rect.top - targetRect.top),
+ rounded(rect.width),
+ rounded(rect.height),
+ ],
+ };
+ });
+
+ let directionConnections = [];
+ if (motionKey === "direction") {
+ const vectors = [...target.querySelectorAll(".direction-vector")];
+ const nodes = [...target.querySelectorAll(".direction-node")];
+ const anchors = vectors.flatMap((vector) => {
+ const matrix = vector.getScreenCTM();
+ if (!matrix) return [];
+ const length = vector.getTotalLength();
+ return [0, length].map((offset) => {
+ const point = vector.getPointAtLength(offset).matrixTransform(matrix);
+ return { x: point.x, y: point.y };
+ });
+ });
+ directionConnections = nodes.flatMap((node, index) => {
+ const style = getComputedStyle(node);
+ const rect = node.getBoundingClientRect();
+ const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ const visible = Number(style.opacity) >= 0.12
+ && rect.bottom > 0 && rect.top < innerHeight && rect.right > 0 && rect.left < innerWidth;
+ if (!visible) return [];
+ const distance = anchors.length
+ ? Math.min(...anchors.map((point) => Math.hypot(center.x - point.x, center.y - point.y)))
+ : Number.POSITIVE_INFINITY;
+ return [{
+ connected: distance <= 4,
+ distance: rounded(distance),
+ index,
+ opacity: rounded(Number(style.opacity)),
+ x: rounded(center.x - targetRect.left),
+ y: rounded(center.y - targetRect.top),
+ }];
+ });
+ }
+
+ const visibleWidth = Math.max(0, Math.min(targetRect.right, innerWidth) - Math.max(targetRect.left, 0));
+ const visibleHeight = Math.max(0, Math.min(targetRect.bottom, innerHeight) - Math.max(targetRect.top, 0));
+ const clip = {
+ x: Math.max(0, Math.floor(targetRect.left)),
+ y: Math.max(0, Math.floor(targetRect.top)),
+ width: Math.max(1, Math.min(innerWidth, Math.ceil(targetRect.right)) - Math.max(0, Math.floor(targetRect.left))),
+ height: Math.max(1, Math.min(innerHeight, Math.ceil(targetRect.bottom)) - Math.max(0, Math.floor(targetRect.top))),
+ };
+ return {
+ clip,
+ directionConnections,
+ fullyVisible: visibleWidth >= targetRect.width - 1 && visibleHeight >= targetRect.height - 1,
+ items,
+ scrollY: rounded(scrollY),
+ target: {
+ bottom: rounded(targetRect.bottom),
+ height: rounded(targetRect.height),
+ left: rounded(targetRect.left),
+ top: rounded(targetRect.top),
+ width: rounded(targetRect.width),
+ },
+ };
+ }, key, contract);
+ if (!state) throw new Error(`Visible-motion target disappeared for ${key}`);
+ const viewportPng = PNG.sync.read(await page.screenshot({
+ type: "png",
+ fullPage: false,
+ captureBeyondViewport: false,
+ }));
+ const x = Math.max(0, Math.min(viewportPng.width - 1, state.clip.x));
+ const y = Math.max(0, Math.min(viewportPng.height - 1, state.clip.y));
+ const width = Math.max(1, Math.min(state.clip.width, viewportPng.width - x));
+ const height = Math.max(1, Math.min(state.clip.height, viewportPng.height - y));
+ const cropped = new PNG({ width, height });
+ PNG.bitblt(viewportPng, cropped, x, y, width, height, 0, 0);
+ return { png: PNG.sync.write(cropped), state };
+}
+
+async function collectVisibleMotionTrip(page, key) {
+ await positionVisibleMotionTarget(page, key);
+ const viewport = page.viewport();
+ await page.mouse.move(Math.round(viewport.width / 2), Math.round(viewport.height / 2));
+ const initial = await visibleMotionFrame(page, key);
+ await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP });
+ await waitForScrollSettle(page, { initialDelay: 40 });
+ await settleTwoAnimationFrames(page);
+ const forward = await visibleMotionFrame(page, key);
+ await page.mouse.wheel({ deltaY: PERCEPTUAL_MOTION_STEP * -1 });
+ await waitForScrollSettle(page, { initialDelay: 40 });
+ await settleTwoAnimationFrames(page);
+ const reverse = await visibleMotionFrame(page, key);
+ return { forward, initial, reverse };
+}
+
async function main() {
const options = parseArguments(process.argv);
if (options.help) {
@@ -1796,12 +2051,16 @@ async function main() {
}
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
const group = initial.groups[key];
- const namesPresent = group?.items.every((item) => item.animationName && item.animationName !== "none");
- const objectsPresent = key === "process" || group.animationObjectCount >= expectedCount;
+ const namesPresent = key === "proof"
+ ? group?.items.filter((item) => item.animationName === "none").length === 1
+ && group.items.filter((item) => item.animationName !== "none").length === LOWER_MOTION_EXPECTED_CHANGES.proof
+ : group?.items.every((item) => item.animationName && item.animationName !== "none");
+ const expectedObjects = LOWER_MOTION_EXPECTED_CHANGES[key];
+ const objectsPresent = key === "process" || group.animationObjectCount >= expectedObjects;
results.check(scope, `${key} owns CSS animation names and objects`, Boolean(namesPresent && objectsPresent), {
expected: {
named: expectedCount,
- animationObjects: key === "process" ? "pseudo-element names are authoritative" : `>= ${expectedCount}`,
+ animationObjects: key === "process" ? "pseudo-element names are authoritative" : `>= ${expectedObjects}`,
},
actual: lowerMotionSummary(initial)[key],
});
@@ -1889,6 +2148,84 @@ async function main() {
}
}
+ // Reproduce the annotated desktop viewport instead of inferring motion
+ // from before-entry/past-range endpoints. Each graphic is held completely
+ // on screen while one physical-size wheel step advances and reverses it.
+ // Isolated element screenshots make this a rendered-pixel gate: the page
+ // moving beneath the viewport cannot create a false positive.
+ {
+ const scope = "visible wheel motion home-en 1316x1272";
+ await results.scenario(scope, async () => {
+ const opened = await openPage(browser, server.origin, ROUTES[1], { width: VISIBLE_MOTION_VIEWPORT.width });
+ try {
+ await opened.page.setViewport({
+ ...VISIBLE_MOTION_VIEWPORT,
+ deviceScaleFactor: 1,
+ hasTouch: false,
+ isMobile: false,
+ });
+ await settleTwoAnimationFrames(opened.page);
+ const supportsViewTimeline = await opened.page.evaluate(() => CSS.supports("animation-timeline: view()"));
+ const trips = {};
+ for (const key of Object.keys(VISIBLE_MOTION_CONTRACTS)) {
+ trips[key] = await collectVisibleMotionTrip(opened.page, key);
+ }
+
+ results.equal(scope, "HTTP status", 200, opened.status);
+ results.equal(scope, "Chromium supports native view timelines", true, supportsViewTimeline);
+
+ for (const [key, trip] of Object.entries(trips)) {
+ const forwardState = visibleMotionStateDelta(trip.initial.state, trip.forward.state);
+ const restoredState = visibleMotionStateDelta(trip.initial.state, trip.reverse.state);
+ const forwardPixels = pngDifference(trip.initial.png, trip.forward.png);
+ const restoredPixels = pngDifference(trip.initial.png, trip.reverse.png);
+ const forwardDelta = trip.forward.state.scrollY - trip.initial.state.scrollY;
+ const reverseDelta = trip.reverse.state.scrollY - trip.forward.state.scrollY;
+
+ results.check(scope, `${key} target stays completely visible for initial, forward, and reverse samples`, [trip.initial, trip.forward, trip.reverse].every((sample) => sample.state.fullyVisible), {
+ expected: "three fully-visible samples",
+ actual: [trip.initial, trip.forward, trip.reverse].map((sample) => ({ scrollY: sample.state.scrollY, target: sample.state.target })),
+ });
+ results.check(scope, `${key} advances and reverses by one 110px wheel step`, forwardDelta >= 90 && forwardDelta <= 125 && reverseDelta <= -90 && reverseDelta >= -125, {
+ expected: { forward: "90..125", reverse: "-125..-90" },
+ actual: { forwardDelta, reverseDelta },
+ });
+ results.check(scope, `${key} changes computed state and rendered geometry while visible`, forwardState.computedChanged >= 1 && forwardState.geometryChanged >= 1, {
+ expected: { computedChanged: ">= 1", geometryChanged: ">= 1" },
+ actual: forwardState,
+ });
+ results.check(scope, `${key} changes isolated artwork pixels while visible`, forwardPixels.dimensionsMatch && forwardPixels.changedPixels >= 24 && forwardPixels.ratio >= 0.00002, {
+ expected: { changedPixels: ">= 24", ratio: ">= 0.00002" },
+ actual: forwardPixels,
+ });
+ results.check(scope, `${key} reverse wheel restores computed state and rendered geometry`, restoredState.computedChanged === 0 && restoredState.geometryChanged === 0, {
+ expected: { computedChanged: 0, geometryChanged: 0 },
+ actual: restoredState,
+ });
+ results.check(scope, `${key} reverse wheel restores isolated artwork pixels`, restoredPixels.dimensionsMatch && restoredPixels.ratio <= 0.001, {
+ expected: { ratio: "<= 0.001" },
+ actual: restoredPixels,
+ });
+ }
+
+ const directionConnectionSamples = [trips.direction.initial, trips.direction.forward, trips.direction.reverse]
+ .map((sample) => ({
+ connections: sample.state.directionConnections,
+ scrollY: sample.state.scrollY,
+ }));
+ results.check(scope, "every visible direction node touches a red segment endpoint or current reveal endpoint in every sample", directionConnectionSamples.every((sample) => (
+ sample.connections.length > 0 && sample.connections.every((connection) => connection.connected)
+ )), {
+ expected: "all visible node-center distances <= 4px",
+ actual: directionConnectionSamples,
+ });
+ results.empty(scope, "visible 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) => (