Fix scroll-driven proof graphics
This commit is contained in:
parent
dcb1415aaa
commit
3c39a1731d
4 changed files with 523 additions and 182 deletions
|
|
@ -1213,3 +1213,4 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고
|
||||||
- `2026-08-30` · 국소 — 기존 히어로의 8px 일회성 조립 계약을 대체한다. [Chrome 공식 stacking-cards 예시](https://scroll-driven-animations.style/demos/stacking-cards/css/)의 transform-only scrub 원칙을 참고하되 sticky 구간이나 빈 스크롤 길이는 만들지 않고, 7개 판·등록 핀·신호선을 `scroll(root block)`에 묶는다. 데스크톱은 문서 스크롤 `0→18vh`, 68rem 이하는 `0→58vh`에서 분산 상태와 조립 완료 상태 사이를 양방향으로 연속 보간하며, 미지원 브라우저·`prefers-reduced-motion`은 정적인 조립 완료 상태를 사용한다. KO/EN 변화·역스크롤 원복·감소 모션 불변·이펙트 제거 상태를 E2E 하드 게이트로 고정한다.
|
- `2026-08-30` · 국소 — 기존 히어로의 8px 일회성 조립 계약을 대체한다. [Chrome 공식 stacking-cards 예시](https://scroll-driven-animations.style/demos/stacking-cards/css/)의 transform-only scrub 원칙을 참고하되 sticky 구간이나 빈 스크롤 길이는 만들지 않고, 7개 판·등록 핀·신호선을 `scroll(root block)`에 묶는다. 데스크톱은 문서 스크롤 `0→18vh`, 68rem 이하는 `0→58vh`에서 분산 상태와 조립 완료 상태 사이를 양방향으로 연속 보간하며, 미지원 브라우저·`prefers-reduced-motion`은 정적인 조립 완료 상태를 사용한다. KO/EN 변화·역스크롤 원복·감소 모션 불변·이펙트 제거 상태를 E2E 하드 게이트로 고정한다.
|
||||||
- `2026-08-30` · 국소 — 히어로 아래 방향 결정·토큰 조판·검증 프레임과 7단계 진행 레일도 각각의 named CSS view timeline에 연결한다. 본문과 수치는 움직이지 않고 방향선·글립·토큰 표본·검사 화살표·짧은 진행선만 transform/opacity로 보간하며, 검증 프레임의 가장 넓은 판은 두 작은 판을 맞추는 고정 기준면으로 남긴다. 모바일 검증 완료 지점은 sticky GNB 아래 제목 여유 24px 이상에서 끝나도록 앞당기고, 미지원 브라우저·감소 모션·이펙트 제거 상태는 정적 완료 화면을 사용한다. KO/EN 전진·역스크롤 geometry 8개, 320~2560px 반응형, effects-off와 감소 모션을 80/80 시나리오·1000/1000 단언으로 고정한다.
|
- `2026-08-30` · 국소 — 히어로 아래 방향 결정·토큰 조판·검증 프레임과 7단계 진행 레일도 각각의 named CSS view timeline에 연결한다. 본문과 수치는 움직이지 않고 방향선·글립·토큰 표본·검사 화살표·짧은 진행선만 transform/opacity로 보간하며, 검증 프레임의 가장 넓은 판은 두 작은 판을 맞추는 고정 기준면으로 남긴다. 모바일 검증 완료 지점은 sticky GNB 아래 제목 여유 24px 이상에서 끝나도록 앞당기고, 미지원 브라우저·감소 모션·이펙트 제거 상태는 정적 완료 화면을 사용한다. KO/EN 전진·역스크롤 geometry 8개, 320~2560px 반응형, effects-off와 감소 모션을 80/80 시나리오·1000/1000 단언으로 고정한다.
|
||||||
- `2026-08-30` · 국소 — 사용자 체감 실패를 반영해 방향 결정·토큰과 조판의 timeline subject를 article에서 실제 `.direction-stage`·`.type-stage`로 내리고, 방향 면·벡터·세 노드와 글립·세 토큰 표본을 각 자식의 첫 가시 시점 이후에 서로 다른 범위로 조립한다. 글자 대비는 opacity로 낮추지 않으며 미지원·감소 모션·이펙트 제거 상태는 정적 완료 화면이다. KO/EN × 390/1440에서 110px 실제 wheel 전진·역진, offscreen 진행 상한, 가시 구간 dwell, 독립 위상과 token stagger를 새 E2E 계약으로 추가했고 전체 85/85 시나리오·1094/1094 단언을 통과했다.
|
- `2026-08-30` · 국소 — 사용자 체감 실패를 반영해 방향 결정·토큰과 조판의 timeline subject를 article에서 실제 `.direction-stage`·`.type-stage`로 내리고, 방향 면·벡터·세 노드와 글립·세 토큰 표본을 각 자식의 첫 가시 시점 이후에 서로 다른 범위로 조립한다. 글자 대비는 opacity로 낮추지 않으며 미지원·감소 모션·이펙트 제거 상태는 정적 완료 화면이다. KO/EN × 390/1440에서 110px 실제 wheel 전진·역진, offscreen 진행 상한, 가시 구간 dwell, 독립 위상과 token stagger를 새 E2E 계약으로 추가했고 전체 85/85 시나리오·1094/1094 단언을 통과했다.
|
||||||
|
- `2026-08-30` · 사용자 실화면 정정 — Direction의 한 줄짜리 독립 변형을 폐기하고, 정확한 세 점 좌표를 공유하는 두 SVG 선분으로 나눠 시작점을 고정한 채 스크롤로 길이만 드러낸다. 노드는 이동하지 않고 앞 선분이 좌표에 닿은 뒤 같은 중심에서 나타나며, 모바일도 `xMidYMid meet`로 비율을 보존한다. 마지막 선과 점은 `cover 80% / 86%` 안에 끝내 sticky GNB 아래에서 완성 상태를 읽을 시간을 남긴다. Tokens는 글립·기준선·세 역할 표본, Verification은 두 가변 프레임·네 검사 화살표, Process는 작은 행 장식 대신 목록 전체 높이의 연속 진행선을 각 named view timeline으로 보간한다. 실제 앱 브라우저 1316×1272에서 110px 전진·역진과 Direction endpoint 연결을 확인했고, KO/EN 320~2560px 전체 게이트는 86/86 시나리오·1122/1122 단언·8/8 캡처로 통과했다.
|
||||||
|
|
|
||||||
|
|
@ -269,12 +269,30 @@ const copiedLabel = lang === "ko" ? "복사했어요" : "Copied";
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-list {
|
.stage-list {
|
||||||
|
position: relative;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 0 0 var(--space-5);
|
||||||
border-top: var(--line-width) solid var(--line-inverse);
|
border-top: var(--line-width) solid var(--line-inverse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stage-list::before,
|
||||||
|
.stage-list::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
width: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list::before { background: var(--line-inverse); }
|
||||||
|
.stage-list::after {
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: top center;
|
||||||
|
scale: 1 1;
|
||||||
|
}
|
||||||
|
|
||||||
.stage-list li {
|
.stage-list li {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -297,24 +315,21 @@ const copiedLabel = lang === "ko" ? "복사했어요" : "Copied";
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (animation-timeline: view()) {
|
@supports (animation-timeline: view()) {
|
||||||
|
.stage-list {
|
||||||
|
view-timeline-name: --process-list;
|
||||||
|
view-timeline-axis: block;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.stage-list li::after {
|
.stage-list::after {
|
||||||
content: "";
|
animation: process-rail-fill linear both;
|
||||||
position: absolute;
|
animation-timeline: --process-list;
|
||||||
inset-inline-start: 0;
|
animation-range: entry 0% cover 72%;
|
||||||
bottom: calc(var(--line-width) * -1);
|
|
||||||
width: 2.5rem;
|
|
||||||
height: var(--line-width);
|
|
||||||
background: var(--accent);
|
|
||||||
transform-origin: left center;
|
|
||||||
animation: stage-rail-register linear both;
|
|
||||||
animation-timeline: view(block);
|
|
||||||
animation-range: entry 12% cover 32%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes stage-rail-register {
|
@keyframes process-rail-fill {
|
||||||
from { opacity: 0.25; transform: scaleX(0); }
|
from { scale: 1 0; }
|
||||||
to { opacity: 1; transform: scaleX(1); }
|
to { scale: 1 1; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -458,6 +473,7 @@ const copiedLabel = lang === "ko" ? "복사했어요" : "Copied";
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.audit-details summary svg { transition: none; }
|
.audit-details summary svg { transition: none; }
|
||||||
|
.stage-list::after { animation: none; scale: 1 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 68rem) {
|
@media (max-width: 68rem) {
|
||||||
|
|
@ -489,6 +505,7 @@ const copiedLabel = lang === "ko" ? "복사했어요" : "Copied";
|
||||||
grid-template-columns: 2rem minmax(0, 1fr);
|
grid-template-columns: 2rem minmax(0, 1fr);
|
||||||
gap: var(--space-2) var(--space-4);
|
gap: var(--space-2) var(--space-4);
|
||||||
}
|
}
|
||||||
|
.stage-list { padding-inline-start: var(--space-4); }
|
||||||
.stage-list p { grid-column: 2; }
|
.stage-list p { grid-column: 2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,15 @@ const copy = lang === "en"
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="direction-stage">
|
<div class="direction-stage">
|
||||||
<svg viewBox="0 0 760 380" aria-hidden="true" preserveAspectRatio="none">
|
<svg viewBox="0 0 760 380" aria-hidden="true" preserveAspectRatio="xMidYMid meet">
|
||||||
<path class="direction-field" d="M42 320 242 56l241 86 234 162-337 44Z" />
|
<path class="direction-field" d="M42 320 242 56l241 86 234 162-337 44Z" />
|
||||||
<path class="direction-field direction-field-inner" d="m130 295 134-177 190 68 157 109-231 30Z" />
|
<path class="direction-field direction-field-inner" d="m130 295 134-177 190 68 157 109-231 30Z" />
|
||||||
<path class="direction-axis" d="M42 320 380 348 242 56m138 292 337-44M380 348 483 142" />
|
<path class="direction-axis" d="M42 320 380 348 242 56m138 292 337-44M380 348 483 142" />
|
||||||
<path class="direction-vector" d="m194 275 186 73 175-99" />
|
<path class="direction-vector direction-segment direction-segment-1" d="M194 275 380 348" />
|
||||||
<circle class="direction-node" cx="194" cy="275" r="7" />
|
<path class="direction-vector direction-segment direction-segment-2" d="M380 348 555 249" />
|
||||||
<circle class="direction-node" cx="380" cy="348" r="7" />
|
<circle class="direction-node direction-node-1" cx="194" cy="275" r="7" />
|
||||||
<circle class="direction-node" cx="555" cy="249" r="7" />
|
<circle class="direction-node direction-node-2" cx="380" cy="348" r="7" />
|
||||||
|
<circle class="direction-node direction-node-3" cx="555" cy="249" r="7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<ol class="direction-axes">
|
<ol class="direction-axes">
|
||||||
|
|
@ -94,8 +95,8 @@ const copy = lang === "en"
|
||||||
|
|
||||||
<div class="type-stage">
|
<div class="type-stage">
|
||||||
<div class="type-glyphs" role="img" aria-label={`${copy.tokens.specimen} ${copy.tokens.koreanSpecimen}`}>
|
<div class="type-glyphs" role="img" aria-label={`${copy.tokens.specimen} ${copy.tokens.koreanSpecimen}`}>
|
||||||
<span aria-hidden="true">{copy.tokens.specimen}</span>
|
<span class="type-glyph type-glyph-latin" aria-hidden="true">{copy.tokens.specimen}</span>
|
||||||
<span aria-hidden="true">{copy.tokens.koreanSpecimen}</span>
|
<span class="type-glyph type-glyph-korean" aria-hidden="true">{copy.tokens.koreanSpecimen}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="type-baselines" aria-hidden="true">
|
<div class="type-baselines" aria-hidden="true">
|
||||||
|
|
@ -212,7 +213,7 @@ const copy = lang === "en"
|
||||||
|
|
||||||
.direction-stage {
|
.direction-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 24rem;
|
aspect-ratio: 2 / 1;
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
border-bottom: var(--line-width) solid var(--line);
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
|
@ -252,6 +253,16 @@ const copy = lang === "en"
|
||||||
vector-effect: non-scaling-stroke;
|
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 {
|
.direction-node {
|
||||||
fill: var(--accent);
|
fill: var(--accent);
|
||||||
stroke: var(--surface-raised);
|
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-register dd { display: flex; align-items: center; gap: var(--space-2); margin: 0; }
|
||||||
|
|
||||||
.token-sample {
|
.token-sample {
|
||||||
width: 2.75rem;
|
width: clamp(3.75rem, 6vw, 5rem);
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
border: var(--line-width) solid var(--line-inverse);
|
border: var(--line-width) solid var(--line-inverse);
|
||||||
}
|
}
|
||||||
|
|
@ -444,8 +455,8 @@ const copy = lang === "en"
|
||||||
view-timeline-axis: block;
|
view-timeline-axis: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.specimen-proof {
|
.proof-stage {
|
||||||
view-timeline-name: --proof-specimen;
|
view-timeline-name: --proof-stage;
|
||||||
view-timeline-axis: block;
|
view-timeline-axis: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -453,17 +464,14 @@ const copy = lang === "en"
|
||||||
.direction-field-inner {
|
.direction-field-inner {
|
||||||
animation: direction-plane-in linear both;
|
animation: direction-plane-in linear both;
|
||||||
animation-timeline: --direction-stage;
|
animation-timeline: --direction-stage;
|
||||||
animation-range: entry 32% cover 58%;
|
animation-range: cover 30% cover 50%;
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.direction-vector {
|
.direction-segment {
|
||||||
animation: direction-vector-in linear both;
|
animation: direction-segment-in linear both;
|
||||||
animation-timeline: --direction-stage;
|
animation-timeline: --direction-stage;
|
||||||
animation-range: entry 64% cover 64%;
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.direction-node {
|
.direction-node {
|
||||||
|
|
@ -473,31 +481,40 @@ const copy = lang === "en"
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.direction-node:nth-of-type(1) {
|
.direction-node-1 { animation-range: cover 18% cover 24%; }
|
||||||
--direction-node-x: calc(var(--space-5) * -1);
|
.direction-segment-1 { animation-range: cover 20% cover 56%; }
|
||||||
--direction-node-y: var(--space-4);
|
.direction-node-2 { animation-range: cover 56% cover 62%; }
|
||||||
animation-range: entry 64% cover 58%;
|
.direction-segment-2 { animation-range: cover 58% cover 80%; }
|
||||||
}
|
.direction-node-3 { animation-range: cover 80% cover 86%; }
|
||||||
|
|
||||||
.direction-node:nth-of-type(2) {
|
.type-glyph {
|
||||||
--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;
|
|
||||||
animation-timeline: --type-stage;
|
animation-timeline: --type-stage;
|
||||||
animation-range: entry 12% cover 58%;
|
transform-origin: center bottom;
|
||||||
transform-origin: left 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 {
|
.token-sample {
|
||||||
animation: token-resolve linear both;
|
animation: token-resolve linear both;
|
||||||
animation-timeline: --type-stage;
|
animation-timeline: --type-stage;
|
||||||
|
|
@ -505,94 +522,105 @@ const copy = lang === "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-register > div:nth-child(1) .token-sample {
|
.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 {
|
.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 {
|
.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: frame-resolve linear both;
|
||||||
animation-timeline: --proof-specimen;
|
animation-timeline: --proof-stage;
|
||||||
animation-range: entry 8% cover 32%;
|
transform-origin: center bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport-narrow {
|
.viewport-narrow {
|
||||||
--frame-x: var(--space-6);
|
--frame-x: 190%;
|
||||||
--frame-y: var(--space-3);
|
--frame-y: 8%;
|
||||||
|
--frame-scale: 0.72;
|
||||||
|
animation-range: entry 25% cover 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport-medium {
|
.viewport-medium {
|
||||||
--frame-x: var(--space-4);
|
--frame-x: 95%;
|
||||||
--frame-y: var(--space-2);
|
--frame-y: 4%;
|
||||||
}
|
--frame-scale: 0.86;
|
||||||
|
animation-range: entry 10% cover 42%;
|
||||||
.viewport-wide {
|
|
||||||
--frame-x: 0;
|
|
||||||
--frame-y: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-register svg {
|
.check-register svg {
|
||||||
animation: check-pass linear both;
|
animation: check-pass linear both;
|
||||||
animation-timeline: --proof-specimen;
|
animation-timeline: --proof-stage;
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
transform-origin: center;
|
transform-origin: left center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-register li:nth-child(1) svg {
|
.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 {
|
.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 {
|
.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 {
|
.check-register li:nth-child(4) svg {
|
||||||
animation-range: entry 42% cover 54%;
|
animation-range: cover 52% cover 64%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes direction-plane-in {
|
@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; }
|
to { opacity: 1; translate: 0 0; scale: 1 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes direction-vector-in {
|
@keyframes direction-segment-in {
|
||||||
from { opacity: 0.08; translate: calc(var(--space-2) * -1) var(--space-1); scale: 0.12 1; }
|
from { opacity: 0; scale: 0 0; }
|
||||||
to { opacity: 1; translate: 0 0; scale: 1 1; }
|
5% { opacity: 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; }
|
|
||||||
to { opacity: 1; scale: 1 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 {
|
@keyframes frame-resolve {
|
||||||
from { translate: var(--frame-x) var(--frame-y); }
|
from { translate: var(--frame-x) var(--frame-y); scale: var(--frame-scale) var(--frame-scale); }
|
||||||
to { translate: 0 0; }
|
to { translate: 0 0; scale: 1 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes check-pass {
|
@keyframes check-pass {
|
||||||
from { opacity: 0.2; translate: calc(var(--space-2) * -1) 0; }
|
from { opacity: 0.28; translate: calc(var(--space-4) * -1) 0; scale: 0.35 1; }
|
||||||
to { opacity: 1; translate: 0 0; }
|
to { opacity: 1; translate: 0 0; scale: 1 1; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -608,58 +636,10 @@ const copy = lang === "en"
|
||||||
.specimen-direction,
|
.specimen-direction,
|
||||||
.specimen-tokens,
|
.specimen-tokens,
|
||||||
.specimen-proof { grid-column: 1; margin-top: 0; }
|
.specimen-proof { grid-column: 1; margin-top: 0; }
|
||||||
.direction-stage { min-height: 21rem; }
|
.direction-stage { aspect-ratio: 16 / 9; }
|
||||||
.type-stage { min-height: 24rem; }
|
.type-stage { min-height: 24rem; }
|
||||||
.proof-stage { grid-template-columns: minmax(0, 1fr); }
|
.proof-stage { grid-template-columns: minmax(0, 1fr); }
|
||||||
.viewport-family { min-height: 18rem; border-right: 0; border-bottom: var(--line-width) solid var(--line); }
|
.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) {
|
@media (max-width: 28rem) {
|
||||||
|
|
@ -673,16 +653,22 @@ const copy = lang === "en"
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.direction-field-inner,
|
.direction-field-inner,
|
||||||
.direction-vector,
|
.direction-segment,
|
||||||
.direction-node,
|
.direction-node,
|
||||||
.type-glyphs,
|
.type-glyph,
|
||||||
.token-sample,
|
.token-sample,
|
||||||
.viewport,
|
.viewport-medium,
|
||||||
|
.viewport-narrow,
|
||||||
.check-register svg {
|
.check-register svg {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
translate: 0 0;
|
translate: 0 0;
|
||||||
scale: 1 1;
|
scale: 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.type-baselines span {
|
||||||
|
animation: none;
|
||||||
|
scale: 1 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ const GEOMETRY_WIDTHS = [320, 390, 768, 1024, 1440, 1920, 2560];
|
||||||
const CAPTURE_WIDTHS = new Set([390, 1440]);
|
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 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 AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||||
const EXPECTED_SCENARIOS = 85;
|
const EXPECTED_SCENARIOS = 86;
|
||||||
const EXPECTED_ASSERTIONS = 1089;
|
const EXPECTED_ASSERTIONS = 1117;
|
||||||
const EXPECTED_CAPTURES = 8;
|
const EXPECTED_CAPTURES = 8;
|
||||||
|
|
||||||
function parseArguments(argv) {
|
function parseArguments(argv) {
|
||||||
|
|
@ -1178,19 +1178,19 @@ function uniqueLayerSignatureCount(snapshot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOWER_MOTION_EXPECTED_COUNTS = Object.freeze({
|
const LOWER_MOTION_EXPECTED_COUNTS = Object.freeze({
|
||||||
direction: 5,
|
direction: 6,
|
||||||
type: 4,
|
type: 9,
|
||||||
proof: 7,
|
proof: 7,
|
||||||
process: 7,
|
process: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The 1440 viewport is the proof specimen's fixed registration frame. The two
|
// 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.
|
// smaller viewports resolve against it, so six of seven proof graphics move.
|
||||||
const LOWER_MOTION_EXPECTED_CHANGES = Object.freeze({
|
const LOWER_MOTION_EXPECTED_CHANGES = Object.freeze({
|
||||||
direction: 5,
|
direction: 6,
|
||||||
type: 4,
|
type: 9,
|
||||||
proof: 6,
|
proof: 6,
|
||||||
process: 7,
|
process: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const PERCEPTUAL_MOTION_WIDTHS = [390, 1440];
|
const PERCEPTUAL_MOTION_WIDTHS = [390, 1440];
|
||||||
|
|
@ -1200,13 +1200,37 @@ const PERCEPTUAL_MOTION_CONTRACTS = Object.freeze({
|
||||||
articleSelector: ".specimen-direction",
|
articleSelector: ".specimen-direction",
|
||||||
stageSelector: ".direction-stage",
|
stageSelector: ".direction-stage",
|
||||||
childSelector: ".direction-field-inner, .direction-vector, .direction-node",
|
childSelector: ".direction-field-inner, .direction-vector, .direction-node",
|
||||||
expectedCount: 5,
|
expectedCount: 6,
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
articleSelector: ".specimen-tokens",
|
articleSelector: ".specimen-tokens",
|
||||||
stageSelector: ".type-stage",
|
stageSelector: ".type-stage",
|
||||||
childSelector: ".type-glyphs, .token-sample",
|
childSelector: ".type-glyph, .type-baselines span, .token-sample",
|
||||||
expectedCount: 4,
|
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(() => {
|
return page.evaluate(() => {
|
||||||
const contracts = [
|
const contracts = [
|
||||||
{ key: "direction", selector: ".direction-field-inner, .direction-vector, .direction-node" },
|
{ 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: "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 rounded = (value) => Math.round(value * 1000) / 1000;
|
||||||
const numericPair = (value, fallback) => {
|
const numericPair = (value, fallback) => {
|
||||||
|
|
@ -1237,7 +1261,10 @@ async function lowerMotionSnapshot(page) {
|
||||||
const translate = numericPair(style.translate, [0, 0]);
|
const translate = numericPair(style.translate, [0, 0]);
|
||||||
const scale = numericPair(style.scale, [1, 1]);
|
const scale = numericPair(style.scale, [1, 1]);
|
||||||
const opacity = rounded(Number.parseFloat(style.opacity) || 0);
|
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)
|
&& translate.every((value) => Math.abs(value) <= 0.05)
|
||||||
&& scale.every((value) => Math.abs(value - 1) <= 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);
|
&& 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 edgeMargin = 32;
|
||||||
const desired = motionPhase === "before"
|
const desired = motionPhase === "before"
|
||||||
? absoluteTop - innerHeight - edgeMargin
|
? absoluteTop - innerHeight - edgeMargin
|
||||||
: motionKey === "process"
|
: absoluteTop + rect.height + edgeMargin;
|
||||||
? absoluteTop + rect.height + edgeMargin
|
|
||||||
: absoluteTop + edgeMargin;
|
|
||||||
const max = Math.max(0, document.documentElement.scrollHeight - innerHeight);
|
const max = Math.max(0, document.documentElement.scrollHeight - innerHeight);
|
||||||
const scrollTop = Math.max(0, Math.min(max, desired));
|
const scrollTop = Math.max(0, Math.min(max, desired));
|
||||||
window.scrollTo({ top: scrollTop, behavior: "instant" });
|
window.scrollTo({ top: scrollTop, behavior: "instant" });
|
||||||
|
|
@ -1385,11 +1410,14 @@ async function perceptualMotionSnapshot(page, key) {
|
||||||
const rawProgress = animation?.effect?.getComputedTiming()?.progress;
|
const rawProgress = animation?.effect?.getComputedTiming()?.progress;
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
label: element.classList.contains("type-glyphs") ? "glyphs"
|
label: element.classList.contains("type-glyph-latin") ? "glyph-latin"
|
||||||
: element.classList.contains("token-sample") ? `token-${index}`
|
: 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-field-inner") ? "plane"
|
||||||
: element.classList.contains("direction-vector") ? "vector"
|
: element.classList.contains("direction-segment-1") ? "segment-1"
|
||||||
: `node-${Math.max(1, index - 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,
|
animationName: getComputedStyle(element).animationName,
|
||||||
progress: Number.isFinite(rawProgress) ? rounded(rawProgress) : null,
|
progress: Number.isFinite(rawProgress) ? rounded(rawProgress) : null,
|
||||||
top: rounded(rect.top),
|
top: rounded(rect.top),
|
||||||
|
|
@ -1426,7 +1454,7 @@ async function collectPerceptualMotionTrip(page, key) {
|
||||||
const absoluteTop = scrollY + rect.top;
|
const absoluteTop = scrollY + rect.top;
|
||||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight);
|
const maxScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight);
|
||||||
const startY = Math.max(0, Math.min(maxScroll, absoluteTop - innerHeight - 48));
|
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" });
|
window.scrollTo({ top: startY, behavior: "instant" });
|
||||||
return { startY, steps };
|
return { startY, steps };
|
||||||
}, contract.articleSelector, PERCEPTUAL_MOTION_STEP);
|
}, contract.articleSelector, PERCEPTUAL_MOTION_STEP);
|
||||||
|
|
@ -1516,7 +1544,7 @@ function longestMatchingRun(samples, predicate) {
|
||||||
function directionPerceptualSummary(samples) {
|
function directionPerceptualSummary(samples) {
|
||||||
const partial = (child) => child.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85;
|
const partial = (child) => child.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85;
|
||||||
const dwell = longestMatchingRun(samples, (sample) => (
|
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 maxPhaseBuckets = 0;
|
||||||
let phaseSample = null;
|
let phaseSample = null;
|
||||||
|
|
@ -1535,20 +1563,21 @@ function directionPerceptualSummary(samples) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function typePerceptualSummary(samples) {
|
function typePerceptualSummary(samples) {
|
||||||
const tokenIndexes = [1, 2, 3];
|
const tokenLabels = ["token-1", "token-2", "token-3"];
|
||||||
const visibleRuns = tokenIndexes.map((childIndex) => ({
|
const childByLabel = (sample, label) => sample.children.find((child) => child.label === label);
|
||||||
label: samples[0]?.children?.[childIndex]?.label,
|
const visibleRuns = tokenLabels.map((label) => ({
|
||||||
|
label,
|
||||||
...longestMatchingRun(samples, (sample) => {
|
...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;
|
return child?.visibility >= 0.5 && child.progress >= 0.15 && child.progress <= 0.85;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const premature = samples.flatMap((sample, sampleIndex) => tokenIndexes
|
const premature = samples.flatMap((sample, sampleIndex) => tokenLabels
|
||||||
.map((childIndex) => sample.children[childIndex])
|
.map((label) => childByLabel(sample, label))
|
||||||
.filter((child) => child && child.top >= 0 && child.visibility < 0.5 && Number.isFinite(child.progress) && child.progress > 0.25)
|
.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 })));
|
.map((child) => ({ sample: sampleIndex, scrollY: sample.scrollY, ...child })));
|
||||||
const staggerSamples = samples.flatMap((sample, sampleIndex) => {
|
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 [];
|
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];
|
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 [{ 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 };
|
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() {
|
async function main() {
|
||||||
const options = parseArguments(process.argv);
|
const options = parseArguments(process.argv);
|
||||||
if (options.help) {
|
if (options.help) {
|
||||||
|
|
@ -1796,12 +2051,16 @@ async function main() {
|
||||||
}
|
}
|
||||||
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
|
for (const [key, expectedCount] of Object.entries(LOWER_MOTION_EXPECTED_COUNTS)) {
|
||||||
const group = initial.groups[key];
|
const group = initial.groups[key];
|
||||||
const namesPresent = group?.items.every((item) => item.animationName && item.animationName !== "none");
|
const namesPresent = key === "proof"
|
||||||
const objectsPresent = key === "process" || group.animationObjectCount >= expectedCount;
|
? 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), {
|
results.check(scope, `${key} owns CSS animation names and objects`, Boolean(namesPresent && objectsPresent), {
|
||||||
expected: {
|
expected: {
|
||||||
named: expectedCount,
|
named: expectedCount,
|
||||||
animationObjects: key === "process" ? "pseudo-element names are authoritative" : `>= ${expectedCount}`,
|
animationObjects: key === "process" ? "pseudo-element names are authoritative" : `>= ${expectedObjects}`,
|
||||||
},
|
},
|
||||||
actual: lowerMotionSummary(initial)[key],
|
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)) {
|
for (const key of Object.keys(LOWER_MOTION_EXPECTED_COUNTS)) {
|
||||||
const samples = lowerMotionGeometryEvidence.filter((item) => item.key === key);
|
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) => (
|
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