From 5953c97cf68640b546db497ed8d852a794f490f8 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 30 Aug 2026 19:08:52 +0900 Subject: [PATCH] Fix specimen scroll motion --- apps/site/design.md | 1 + .../site/src/components/OutputSpecimens.astro | 94 ++++-- apps/site/tools/site-e2e.mjs | 279 +++++++++++++++++- 3 files changed, 344 insertions(+), 30 deletions(-) diff --git a/apps/site/design.md b/apps/site/design.md index 143f667..2141047 100644 --- a/apps/site/design.md +++ b/apps/site/design.md @@ -1212,3 +1212,4 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고 - 앞선 `실제 결과 화면` 우선 판단은 폐기한다. 홈은 프로젝트 캡처를 모두 제거하고 방향 결정·실제 토큰 이름·검사 계약을 하나의 비대칭 산출물 표면으로 보여 주며, 기존 화면은 고객 성과처럼 말하지 않는 `설계 예시` 아카이브에만 남긴다. 한국어·영어 홈의 산출물 구간에 이미지가 없고 접근 가능한 표본 세 개가 있는지를 E2E 하드 게이트로 고정한다. OG/Twitter도 프로젝트 캡처를 쓰지 않고 `capture-site-og.mjs`가 한국어·영어 실제 히어로를 각각 1200×630으로 다시 만든다. - `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` · 국소 — 사용자 체감 실패를 반영해 방향 결정·토큰과 조판의 timeline subject를 article에서 실제 `.direction-stage`·`.type-stage`로 내리고, 방향 면·벡터·세 노드와 글립·세 토큰 표본을 각 자식의 첫 가시 시점 이후에 서로 다른 범위로 조립한다. 글자 대비는 opacity로 낮추지 않으며 미지원·감소 모션·이펙트 제거 상태는 정적 완료 화면이다. KO/EN × 390/1440에서 110px 실제 wheel 전진·역진, offscreen 진행 상한, 가시 구간 dwell, 독립 위상과 token stagger를 새 E2E 계약으로 추가했고 전체 85/85 시나리오·1094/1094 단언을 통과했다. diff --git a/apps/site/src/components/OutputSpecimens.astro b/apps/site/src/components/OutputSpecimens.astro index 478a8a3..3c95f7b 100644 --- a/apps/site/src/components/OutputSpecimens.astro +++ b/apps/site/src/components/OutputSpecimens.astro @@ -434,13 +434,13 @@ const copy = lang === "en" } @supports (animation-timeline: view()) { - .specimen-direction { - view-timeline-name: --direction-specimen; + .direction-stage { + view-timeline-name: --direction-stage; view-timeline-axis: block; } - .specimen-tokens { - view-timeline-name: --tokens-specimen; + .type-stage { + view-timeline-name: --type-stage; view-timeline-axis: block; } @@ -452,44 +452,68 @@ const copy = lang === "en" @media (prefers-reduced-motion: no-preference) { .direction-field-inner { animation: direction-plane-in linear both; - animation-timeline: --direction-specimen; - animation-range: entry 8% cover 30%; + animation-timeline: --direction-stage; + animation-range: entry 32% cover 58%; transform-box: fill-box; transform-origin: center; } - .direction-vector, + .direction-vector { + animation: direction-vector-in linear both; + animation-timeline: --direction-stage; + animation-range: entry 64% cover 64%; + transform-box: fill-box; + transform-origin: left center; + } + .direction-node { - --direction-shift-x: calc(var(--space-3) * -1); - animation: direction-lock linear both; - animation-timeline: --direction-specimen; - animation-range: entry 18% cover 38%; + animation: direction-node-in linear both; + animation-timeline: --direction-stage; transform-box: fill-box; 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:nth-of-type(2) { + --direction-node-x: 0px; + --direction-node-y: var(--space-5); + animation-range: entry 88% cover 66%; + } + + .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 68%; + } + .type-glyphs { animation: type-register linear both; - animation-timeline: --tokens-specimen; - animation-range: entry 8% cover 27%; + animation-timeline: --type-stage; + animation-range: entry 12% cover 58%; + transform-origin: left bottom; } .token-sample { animation: token-resolve linear both; - animation-timeline: --tokens-specimen; + animation-timeline: --type-stage; transform-origin: left center; } .token-register > div:nth-child(1) .token-sample { - animation-range: entry 22% cover 34%; + animation-range: entry 60% cover 58%; } .token-register > div:nth-child(2) .token-sample { - animation-range: entry 26% cover 38%; + animation-range: entry 72% cover 64%; } .token-register > div:nth-child(3) .token-sample { - animation-range: entry 30% cover 42%; + animation-range: entry 84% cover 70%; } .viewport { @@ -537,22 +561,27 @@ const copy = lang === "en" } @keyframes direction-plane-in { - from { opacity: 0.42; translate: 0 var(--space-3); } - to { opacity: 1; translate: 0 0; } + from { opacity: 0.72; translate: 0 var(--space-5); scale: 0.92 0.92; } + to { opacity: 1; translate: 0 0; scale: 1 1; } } - @keyframes direction-lock { - from { opacity: 0; translate: var(--direction-shift-x) var(--space-1); } - to { opacity: 1; translate: 0 0; } + @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 { opacity: 0.35; translate: 0 var(--space-3); } - to { opacity: 1; translate: 0 0; } + from { translate: 0 var(--space-5); scale: 0.92 0.92; } + to { translate: 0 0; scale: 1 1; } } @keyframes token-resolve { - from { opacity: 0.35; scale: 0.18 1; } + from { opacity: 0.18; scale: 0.04 1; } to { opacity: 1; scale: 1 1; } } @@ -586,9 +615,18 @@ const copy = lang === "en" @supports (animation-timeline: view()) { @media (prefers-reduced-motion: no-preference) { - .direction-vector, - .direction-node { - --direction-shift-x: calc(var(--space-2) * -1); + .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 { diff --git a/apps/site/tools/site-e2e.mjs b/apps/site/tools/site-e2e.mjs index 58f7563..f09791c 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 = 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) => (