summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/scroll-animations
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/scroll-animations')
-rw-r--r--testing/web-platform/tests/scroll-animations/META.yml4
-rw-r--r--testing/web-platform/tests/scroll-animations/crashtests/invalid-animation-range.html13
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-duration-auto.tentative.html58
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-events.html87
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-ref.html97
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-test.html137
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-ref.html100
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-test.html124
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-range-ignored.html235
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-range-normal-matches-cover.html92
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-range-visual-test-ref.html79
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-range-visual-test.html62
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-shorthand.html166
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-computed.html73
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-deferred.html109
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-ignored.tentative.html153
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-in-keyframe.html27
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-multiple.html91
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-named-scroll-progress-timeline.tentative.html444
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-none.html41
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-parsing.html88
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-scroll-functional-notation.tentative.html177
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-timeline-view-functional-notation.tentative.html511
-rw-r--r--testing/web-platform/tests/scroll-animations/css/animation-update-ref.html55
-rw-r--r--testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited-ref.html10
-rw-r--r--testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited.html78
-rw-r--r--testing/web-platform/tests/scroll-animations/css/get-animations-inactive-timeline.html84
-rw-r--r--testing/web-platform/tests/scroll-animations/css/merge-timeline-offset-keyframes.html135
-rw-r--r--testing/web-platform/tests/scroll-animations/css/named-range-keyframes-with-document-timeline.tentative.html54
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-print.tentative.html56
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-ref.html38
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-iframe-print.html65
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print-ref.html21
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print.tentative.html59
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-print.html58
-rw-r--r--testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-ref.html38
-rw-r--r--testing/web-platform/tests/scroll-animations/css/progress-based-animation-animation-longhand-properties.tentative.html277
-rw-r--r--testing/web-platform/tests/scroll-animations/css/progress-based-animation-timeline.html56
-rw-r--r--testing/web-platform/tests/scroll-animations/css/pseudo-on-scroller.html55
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset-ref.html44
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset.html73
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-computed.html37
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-parsing.html31
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-writing-mode.html143
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe-ref.html33
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe.html73
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-quirks-mode.html63
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-ref.html31
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl-ref.html32
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl.html65
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-default.html63
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-document-scroller-quirks.html36
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-dynamic.tentative.html267
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed-ref.html31
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed.html67
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-in-container-query.html75
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-inactive.html90
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation-ref.html32
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation.html68
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-multi-pass.tentative.html110
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-computed.html37
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-parsing.html32
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-shadow.html180
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-dirty.html42
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html79
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-paused-animations.html95
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-range-animation.html182
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-responsiveness-from-endpoint.html62
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-root-dirty.html35
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-sampling.html52
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-shorthand.html109
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-update-reversed-animation.html69
-rw-r--r--testing/web-platform/tests/scroll-animations/css/scroll-timeline-with-percent-delay.tentative.html91
-rw-r--r--testing/web-platform/tests/scroll-animations/css/support/animation-range.css82
-rw-r--r--testing/web-platform/tests/scroll-animations/css/support/testcommon.js19
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-offset-in-keyframe-change-timeline.tentative.html147
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-hidden-subject.html126
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-with-document-timeline.html80
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-range-name-offset-in-keyframes.tentative.html111
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-scope-computed.tentative.html35
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-scope-parsing.tentative.html29
-rw-r--r--testing/web-platform/tests/scroll-animations/css/timeline-scope.html322
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-animation-range-update.tentative.html78
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-animation.html223
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-axis-computed.html37
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-axis-parsing.html29
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-dynamic.html200
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-inset-animation.html743
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-inset-computed.html41
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-inset-parsing.html34
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-keyframe-boundary-interpolation.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-lookup.html253
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-name-computed.html36
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-name-parsing.html30
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-name-shadow.html181
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-range-animation.html232
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-range-update-reversed-animation.html69
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-range-update.html66
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-shorthand.html164
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-subject-bounds-update.html71
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-used-values.html104
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-with-delay-and-range.tentative.html93
-rw-r--r--testing/web-platform/tests/scroll-animations/css/view-timeline-with-transform-on-subject.html76
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html66
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html31
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html75
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html32
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html64
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html37
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html60
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html68
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html214
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html19
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html95
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html80
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html49
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html148
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html34
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html46
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html630
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html393
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js16
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html78
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html84
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html24
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html178
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html276
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html69
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html164
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html137
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html555
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html170
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html160
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html20
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html133
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html185
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html44
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html75
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html286
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html298
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html401
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html429
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html36
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js124
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html84
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html79
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html178
-rw-r--r--testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html565
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html83
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html101
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html207
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html113
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html88
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html112
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html111
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html203
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html50
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html302
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html63
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html153
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html120
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html120
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html121
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html127
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html128
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html14
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html45
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html47
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html48
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js146
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html264
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html59
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html148
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html127
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html226
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html54
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html59
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html105
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html198
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html41
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html58
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html94
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html94
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html90
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html81
-rw-r--r--testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html106
191 files changed, 22140 insertions, 0 deletions
diff --git a/testing/web-platform/tests/scroll-animations/META.yml b/testing/web-platform/tests/scroll-animations/META.yml
new file mode 100644
index 0000000000..c7f0e4903b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/META.yml
@@ -0,0 +1,4 @@
+spec: https://drafts.csswg.org/scroll-animations/
+suggested_reviewers:
+ - birtles
+ - graouts
diff --git a/testing/web-platform/tests/scroll-animations/crashtests/invalid-animation-range.html b/testing/web-platform/tests/scroll-animations/crashtests/invalid-animation-range.html
new file mode 100644
index 0000000000..43b23c93b6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/crashtests/invalid-animation-range.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<title>Invalid animation range</title>
+<body onload="runTest()">
+ <div id="target"></div>
+</body>
+<script src="../../web-animations/testcommon.js"></script>
+<script>
+ async function runTest() {
+ const anim = target.animate(undefined, {rangeStart: "initial" });
+ await waitForNextFrame();
+ await waitForNextFrame();
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-duration-auto.tentative.html b/testing/web-platform/tests/scroll-animations/css/animation-duration-auto.tentative.html
new file mode 100644
index 0000000000..0198285913
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-duration-auto.tentative.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<title>animation-duration: auto</title>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/6530">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./support/testcommon.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<style>
+ #scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ #scroller > #content {
+ height: 200px;
+ width: 200px;
+ }
+
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+
+ #scroller {
+ scroll-timeline: --timeline;
+ }
+
+ #element {
+ z-index: -1;
+ animation-name: anim;
+ animation-duration: auto;
+ animation-timeline: --timeline;
+ }
+</style>
+<main>
+ <div id=scroller>
+ <div id=content></div>
+ <div id=element></div>
+ </div>
+</main>
+<script>
+ promise_test(async (t) => {
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(element).zIndex, '0');
+ }, 'A value of auto can be specified for animation-duration');
+</script>
+
+<div id="target"></div>
+<script>
+ test_valid_value('animation-duration', 'auto');
+ test_computed_value('animation-duration', 'auto', '0s');
+ test_valid_value('animation', 'auto cubic-bezier(0, -2, 1, 3) -3s 4 reverse both paused anim');
+ test_computed_value('animation',
+ 'auto cubic-bezier(0, -2, 1, 3) -3s 4 reverse both paused anim',
+ '0s cubic-bezier(0, -2, 1, 3) -3s 4 reverse both paused anim');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-events.html b/testing/web-platform/tests/scroll-animations/css/animation-events.html
new file mode 100644
index 0000000000..be53af487a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-events.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline animation events</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#events">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: translateX(0); }
+ to { transform: translateX(100px); }
+ }
+ #target {
+ background: green;
+ height: 100px;
+ width: 100px;
+ margin-bottom: 150vh;
+ animation-timeline: view();
+ }
+ .animate {
+ animation: anim auto;
+ }
+</style>
+<body>
+ <div id="target"></div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ const target = document.getElementById('target');
+
+ // Create a timeline and advance to the next frame to ensure that the
+ // timeline has a value for currentTime.
+ await waitForNextFrame();
+ const timeline = new ViewTimeline({ subject: target });
+ await waitForNextFrame();
+
+ let animationstart_events = 0;
+ let animationend_events = 0;
+ document.addEventListener('animationstart', () => {
+ animationstart_events++;
+ });
+ document.addEventListener('animationend', () => {
+ animationend_events++;
+ });
+
+ // Start the animation and swap out its timeline while still play-pending
+ // so that it already has a value for current time.
+ target.classList.add('animate');
+ const anim = target.getAnimations();
+ anim.timeline = timeline;
+ // Introduce a style change that will make the timeline state stale when
+ // "ticked" at the start of the next animation frame.
+ target.style = 'margin-top: 150vh';
+
+ assert_false(!!anim.startTime,
+ 'Start time deferred until timeline is updated');
+
+ // Verify that we are not evaluating a start time based on a stale timeline.
+ await waitForNextFrame();
+ await waitForNextFrame();
+ assert_equals(animationstart_events, 0,
+ 'Target initially off-screen and no animationstart event');
+ assert_equals(animationend_events, 0,
+ 'Target initially off-screen and no animationend event');
+
+ const scroller = document.scrollingElement;
+ scroller.scrollTop = target.getBoundingClientRect().top;
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ assert_equals(animationstart_events, 1,
+ 'scrollstart event received after scrolling into view.');
+ assert_equals(animationend_events, 0,
+ "No scrollend event until after scrolling out of view");
+
+ scroller.scrollTop = target.getBoundingClientRect().bottom;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ assert_equals(animationstart_events, 1,
+ 'No additional scrollstart event');
+ assert_equals(animationend_events, 1,
+ 'scrollend event received after scrolling out of view');
+ }, 'View timelime generates animationstart and animationend events');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-ref.html b/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-ref.html
new file mode 100644
index 0000000000..998576b3a4
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-ref.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ .scroller {
+ height: 200px;
+ width: 500px;
+ overflow: auto;
+ position: absolute;
+ top: 100px;
+ }
+
+ .anim {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background: darkred;
+ }
+ .anim.contain {
+ background: green;
+ }
+ .spacer {
+ height: 1000px;
+ }
+
+ .before {
+ top: 450px;
+ }
+ .after {
+ top: 50px;
+ }
+ .contain {
+ top: 250px;
+ }
+ .indicator {
+ position: fixed;
+ top: 50px;
+ }
+ .contain .indicator {
+ top: 100px;
+ }
+ .contain .indicator:nth-child(2) {
+ left: 200px;
+ }
+
+ .after .indicator {
+ left: 200px;
+ }
+
+ .indicator > div {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ position: relative;
+ border-radius: 100%;
+ box-sizing: border-box;
+ border: 2px solid black;
+ padding: 3px;
+ background: lightgray;
+ background-clip: content-box;
+ }
+
+ .indicator > div > div {
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ background: green;
+ opacity: 1;
+ will-change: opacity;
+ }
+
+</style>
+</head>
+<body>
+ <p>All of the activity indicators should be active as the animations should be filling.</p>
+ <div class="scroller">
+ <div class="anim after"><div class="indicator">After cover phase: <div><div></div></div></div></div>
+ <div class="anim before"><div class="indicator">Before cover phase: <div><div></div></div></div></div>
+ <div class="anim contain">
+ <div class="indicator entry">After entry phase: <div><div></div></div></div>
+ <div class="indicator exit">Before exit phase: <div><div></div></div></div>
+ </div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script>
+ function run() {
+ let scroller = document.querySelector('.scroller');
+ scroller.scrollTo({top: 200});
+ }
+ run();
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-test.html b/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-test.html
new file mode 100644
index 0000000000..90d4f4518b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-fill-outside-range-test.html
@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<link rel="match" href="animation-fill-outside-range-ref.html">
+<script src="/common/reftest-wait.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ .scroller {
+ height: 200px;
+ width: 500px;
+ overflow: auto;
+ position: absolute;
+ top: 100px;
+ }
+
+ .anim {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background: darkred;
+ view-timeline: --view;
+ }
+ .anim.contain {
+ background: green;
+ }
+ .spacer {
+ height: 1000px;
+ }
+
+ .before {
+ top: 450px;
+ }
+ .after {
+ top: 50px;
+ }
+ .contain {
+ top: 250px;
+ }
+ @keyframes opaque-before {
+ 0% { opacity: 1; }
+ 0.01% { opacity : 0; }
+ 100% { opacity : 0; }
+ }
+ @keyframes opaque-after {
+ 0% { opacity: 0; }
+ 99.9% { opacity : 0; }
+ 100% { opacity : 1; }
+ }
+ .indicator {
+ position: fixed;
+ top: 50px;
+ }
+ .contain .indicator {
+ top: 100px;
+ }
+ .contain .indicator:nth-child(2) {
+ left: 200px;
+ }
+
+ .after .indicator {
+ left: 200px;
+ }
+
+ .indicator > div {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ position: relative;
+ border-radius: 100%;
+ box-sizing: border-box;
+ border: 2px solid black;
+ padding: 3px;
+ background: lightgray;
+ background-clip: content-box;
+ }
+
+ .indicator > div > div {
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ background: green;
+ opacity: 0;
+ animation-fill-mode: both;
+ animation-timeline: --view;
+ }
+
+ .after .indicator > div > div,
+ .contain .indicator > div > div {
+ animation-name: opaque-after;
+ }
+ .before .indicator > div > div,
+ .contain .indicator:nth-child(2) > div > div {
+ animation-name: opaque-before;
+ }
+
+ .contain .indicator > div > div {
+ animation-range: entry;
+ }
+
+ .contain .indicator:nth-child(2) > div > div {
+ animation-range: exit;
+ }
+
+</style>
+</head>
+<body>
+ <p>All of the activity indicators should be active as the animations should be filling.</p>
+ <div class="scroller">
+ <div class="anim after"><div class="indicator">After cover phase: <div><div></div></div></div></div>
+ <div class="anim before"><div class="indicator">Before cover phase: <div><div></div></div></div></div>
+ <div class="anim contain">
+ <div class="indicator entry">After entry phase: <div><div></div></div></div>
+ <div class="indicator exit">Before exit phase: <div><div></div></div></div>
+ </div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script>
+ async function run() {
+ let scroller = document.querySelector('.scroller');
+ // Scroll such that each animation becomes active.
+ scroller.scrollTo(0, 0);
+ await waitForCompositorReady();
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+
+ // Then scroll between them so that we are before one and after the other.
+ scroller.scrollTo({top: 200});
+ await waitForNextFrame();
+ takeScreenshot();
+ }
+ run();
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-ref.html b/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-ref.html
new file mode 100644
index 0000000000..e744055140
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-ref.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+<style>
+ .scroller {
+ height: 200px;
+ width: 500px;
+ overflow: auto;
+ position: absolute;
+ top: 100px;
+ }
+
+ .anim {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background: darkred;
+ view-timeline: --view;
+ }
+ .anim.contain {
+ background: green;
+ }
+ .spacer {
+ height: 1000px;
+ }
+
+ .before {
+ top: 450px;
+ }
+ .after {
+ top: 50px;
+ }
+ .contain {
+ top: 250px;
+ }
+ .indicator {
+ position: fixed;
+ top: 50px;
+ }
+ .contain .indicator {
+ top: 100px;
+ }
+ .contain .indicator:nth-child(2) {
+ left: 200px;
+ }
+
+ .after .indicator {
+ left: 200px;
+ }
+
+ .indicator > div {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ position: relative;
+ border-radius: 100%;
+ box-sizing: border-box;
+ border: 2px solid black;
+ padding: 3px;
+ background: lightgray;
+ background-clip: content-box;
+ }
+
+ .indicator > div > div {
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ background: green;
+ opacity: 0;
+ }
+</style>
+</head>
+<body onload="run()">
+ <p>None of the activity indicators should be active all of the animations are outside of their active range.</p>
+ <div class="scroller">
+ <div class="anim after"><div class="indicator">After cover phase: <div><div></div></div></div></div>
+ <div class="anim before"><div class="indicator">Before cover phase: <div><div></div></div></div></div>
+ <div class="anim contain">
+ <div class="indicator entry">After entry phase: <div><div></div></div></div>
+ <div class="indicator exit">Before exit phase: <div><div></div></div></div>
+ </div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script>
+ async function run() {
+ // Ensure we don't take the screenshot while paint-holding.
+ await waitForCompositorReady();
+ let scroller = document.querySelector('.scroller');
+ scroller.scrollTo(0, 200);
+ await waitForNextFrame();
+ takeScreenshot();
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-test.html b/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-test.html
new file mode 100644
index 0000000000..8034e451be
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-inactive-outside-range-test.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<link rel="match" href="animation-inactive-outside-range-ref.html">
+<script src="/common/reftest-wait.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ .scroller {
+ height: 200px;
+ width: 500px;
+ overflow: auto;
+ position: absolute;
+ top: 100px;
+ }
+
+ .anim {
+ position: absolute;
+ width: 100px;
+ height: 100px;
+ background: darkred;
+ view-timeline: --view;
+ }
+ .anim.contain {
+ background: green;
+ }
+ .spacer {
+ height: 1000px;
+ }
+
+ .before {
+ top: 450px;
+ }
+ .after {
+ top: 50px;
+ }
+ .contain {
+ top: 250px;
+ }
+ @keyframes active-opacity {
+ 0% { opacity: 1; }
+ 100% { opacity: 1; }
+ }
+ .indicator {
+ position: fixed;
+ top: 50px;
+ }
+ .contain .indicator {
+ top: 100px;
+ }
+ .contain .indicator:nth-child(2) {
+ left: 200px;
+ }
+
+ .after .indicator {
+ left: 200px;
+ }
+
+ .indicator > div {
+ display: inline-block;
+ width: 25px;
+ height: 25px;
+ position: relative;
+ border-radius: 100%;
+ box-sizing: border-box;
+ border: 2px solid black;
+ padding: 3px;
+ background: lightgray;
+ background-clip: content-box;
+ }
+
+ .indicator > div > div {
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ background: green;
+ opacity: 0;
+ animation: active-opacity;
+ animation-timeline: --view;
+ }
+
+ .contain .indicator > div > div {
+ animation-range: entry;
+ }
+
+ .contain .indicator:nth-child(2) > div > div {
+ animation-range: exit;
+ }
+
+</style>
+</head>
+<body onload="run()">
+ <p>None of the activity indicators should be active all of the animations are outside of their active range.</p>
+ <div class="scroller">
+ <div class="anim after"><div class="indicator">After cover phase: <div><div></div></div></div></div>
+ <div class="anim before"><div class="indicator">Before cover phase: <div><div></div></div></div></div>
+ <div class="anim contain">
+ <div class="indicator entry">After entry phase: <div><div></div></div></div>
+ <div class="indicator exit">Before exit phase: <div><div></div></div></div>
+ </div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script>
+ async function run() {
+ await waitForCompositorReady();
+ await waitForNextFrame();
+
+ let scroller = document.querySelector('.scroller');
+ // Scroll such that each animation becomes active.
+ scroller.scrollTo({top: 0});
+ await waitForNextFrame();
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+
+ // Then scroll between them so that we are before one and after the other.
+ scroller.scrollTo({top: 200});
+ await waitForNextFrame();
+ takeScreenshot();
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-range-ignored.html b/testing/web-platform/tests/scroll-animations/css/animation-range-ignored.html
new file mode 100644
index 0000000000..98a5d45c37
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-range-ignored.html
@@ -0,0 +1,235 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://www.w3.org/TR/scroll-animations-1/#named-range-animation-declaration">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Programmatic API overrides animation-range-*</title>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ @keyframes anim {
+ from { margin-left: 0px; }
+ to { margin-left: 100px; }
+ }
+ #target {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+ .animate {
+ animation: anim auto linear;
+ view-timeline: --timeline;
+ animation-timeline: --timeline;
+ animation-range-start: entry 0%;
+ animation-range-end: entry 100%;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function startAnimation(t) {
+ target.classList.add('animate');
+ t.add_cleanup(async () => {
+ target.classList.remove('animate');
+ await waitForNextFrame();
+ });
+ return target.getAnimations()[0];
+ }
+
+ promise_test(async t => {
+ // Points of interest:
+ // entry 0% @ 600
+ // entry 100% / contain 0% @ 700
+ // exit 0% / contain 100% @ 800
+ // exit 100% @ 900
+ const anim = startAnimation(t);
+ await anim.ready;
+
+ scroller.scrollTop = 650;
+ await waitForNextFrame();
+
+ // Timline time = (scroll pos - cover 0%) / (cover 100% - cover 0%) * 100%
+ // = (650 - 600)/(900 - 600) * 100% = 100/6%
+ assert_percents_equal(anim.timeline.currentTime, 100/6,
+ 'timeline\'s current time before style change');
+ assert_percents_equal(anim.startTime, 0,
+ 'animation\'s start time before style change');
+ // Range start of entry 0% aligns with timeline start. Thus, animation's
+ // and timeline's current time are equal.
+ assert_percents_equal(anim.currentTime, 100/6,
+ 'animation\'s current time before style change');
+ // Iteration duration =
+ // (range end - range start) / (cover 100% - cover 0%) * 100%
+ // = (700 - 600) / (900 - 600) = 33.3333%
+ assert_percents_equal(anim.effect.getComputedTiming().duration,
+ 100/3,
+ 'iteration duration before first style change');
+ assert_equals(getComputedStyle(target).marginLeft, '50px',
+ 'margin-left before style change');
+
+ // Step 1: Set the range end programmatically and range start via CSS.
+ // The start time will be respected since not previously set via the
+ // animation API.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeEnd = 'contain 100%';
+ target.style.animationRangeStart = 'entry 50%';
+ });
+
+ // Animation range does not affect timeline's currentTime.
+ assert_percents_equal(
+ anim.timeline.currentTime, 100/6,
+ 'timeline\'s current time after first set of range updates');
+ assert_percents_equal(
+ anim.startTime, 100/6,
+ 'animation\'s start time after first set of range updates');
+ // Scroll position aligns with range start.
+ assert_percents_equal(
+ anim.currentTime, 0,
+ 'animation\'s current time after first set of range updates');
+ // Iteration duration =
+ // (range end - range start) / (cover 100% - cover 0%) * 100%
+ // = (800 - 650) / (900 - 600) = 50%
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration, 50,
+ 'iteration duration after first style change');
+ assert_equals(getComputedStyle(target).marginLeft, '0px',
+ 'margin-left after first set of range updates');
+
+ // Step 2: Programmatically set the range start.
+ // Scroll position is current at entry 50%, thus the animation's current
+ // time is negative.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'contain 0%';
+ });
+ // animation current time =
+ // (scroll pos - range start) / (cover 100% - cover 0%) * 100%
+ // = (650 - 700) / (900 - 600) * 100% = -100/6%
+ assert_percents_equal(
+ anim.currentTime, -100/6,
+ 'animation\'s current time after second set of range updates');
+ // Iteration duration =
+ // (range end - range start) / (cover 100% - cover 0%) * 100%
+ // = (800 - 700) / (900 - 600) = 33.3333%
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration, 100/3,
+ 'iteration duration after second style change');
+ assert_equals(getComputedStyle(target).marginLeft, '0px',
+ 'margin-left after second set of range updates');
+
+ // Jump to contain / cover 50%
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+
+ // animation current time =
+ // (scroll pos - range start) / (cover 100% - cover 0%) * 100%
+ // = (750 - 700) / (900 - 600) * 100% = 100/6%
+ assert_percents_equal(
+ anim.currentTime, 100/6,
+ 'animation\'s current time after bumping scroll position');
+ assert_equals(getComputedStyle(target).marginLeft, '50px');
+
+ // Step 3: Try to update the range start via CSS. This change must be
+ // ignored since previously set programmatically.
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animationRangeStart = "entry 50%";
+ });
+ assert_percents_equal(
+ anim.currentTime, 100/6,
+ 'Current time unchanged after change to ignored CSS property');
+ assert_equals(
+ getComputedStyle(target).marginLeft, '50px',
+ 'Margin-left unaffected by change to ignored CSS property');
+
+ }, 'Animation API call rangeStart overrides animation-range-start');
+
+ promise_test(async t => {
+ const anim = startAnimation(t);
+ await anim.ready;
+
+ scroller.scrollTop = 650;
+ await waitForNextFrame();
+
+ // Step 1: Set the range start programmatically and range end via CSS.
+ // The start time will be respected since not previously set via the
+ // animation API.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "entry 50%";
+ target.style.animationRangeEnd = "contain 100%";
+ });
+
+ assert_percents_equal(
+ anim.timeline.currentTime, 100/6,
+ 'timeline\'s current time after first set of range updates');
+ assert_percents_equal(
+ anim.startTime, 100/6,
+ 'animation\'s start time after first set of range updates');
+ assert_percents_equal(
+ anim.currentTime, 0,
+ 'animation\'s current time after first set of range updates');
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration, 50,
+ 'iteration duration after first style change');
+ assert_equals(getComputedStyle(target).marginLeft, "0px",
+ 'margin-left after first set of range updates');
+
+ // Step 2: Programmatically set the range.
+ // Scroll position is current at entry 50%, thus the animation's current
+ // time is negative.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "contain 0%";
+ anim.rangeEnd = "contain 100%";
+ });
+
+ assert_percents_equal(
+ anim.currentTime, -100/6,
+ 'animation\'s current time after second set of range updates');
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration, 100/3,
+ 'iteration duration after second style change');
+ assert_equals(getComputedStyle(target).marginLeft, "0px",
+ 'margin-left after second set of range updates');
+
+ // Jump to contain / cover 50%
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+
+ assert_percents_equal(
+ anim.currentTime, 100/6,
+ 'animation\'s current time after bumping scroll position');
+ assert_equals(getComputedStyle(target).marginLeft, "50px");
+
+ // Step 3: Try to update the range end via CSS. This change must be
+ // ignored since previously set programmatically.
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animationRangeEnd = "cover 100%";
+ });
+ assert_percents_equal(
+ anim.currentTime, 100/6,
+ 'Current time unchanged after change to ignored CSS property');
+ assert_equals(
+ getComputedStyle(target).marginLeft, '50px',
+ 'Margin-left unaffected by change to ignored CSS property');
+
+ }, 'Animation API call rangeEnd overrides animation-range-end');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-range-normal-matches-cover.html b/testing/web-platform/tests/scroll-animations/css/animation-range-normal-matches-cover.html
new file mode 100644
index 0000000000..a91e3d3e29
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-range-normal-matches-cover.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Animation range 'normal' is equivalent to animation range 'cover'</title>
+</head>
+<style type="text/css">
+ @keyframes anim-1 {
+ from { background-color: blue; }
+ to { background-color: white; }
+ }
+ @keyframes anim-2 {
+ from { opacity: 0.3; }
+ to { opacity: 1; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-top: 800px;
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim-1 auto linear, anim-2 auto linear;
+ animation-range: normal, cover;
+ view-timeline: --t1;
+ animation-timeline: --t1, --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function assert_range_equals(actual, expected) {
+ if (typeof expected == 'string') {
+ assert_equals(actual, expected);
+ } else {
+ assert_equals(actual.rangeName, expected.rangeName);
+ assert_equals(actual.offset.value, expected.offset.value);
+ }
+ }
+
+ promise_test(async t => {
+ anims = target.getAnimations();
+ assert_equals(anims.length, 2, "Expecting 2 animations");
+ await anims[0].ready;
+ await anims[1].ready;
+
+ assert_range_equals(anims[0].rangeStart, "normal");
+ assert_range_equals(anims[0].rangeEnd, "normal");
+ assert_range_equals(anims[1].rangeStart,
+ { rangeName: 'cover', offset: CSS.percent(0) });
+ assert_range_equals(anims[1].rangeEnd,
+ { rangeName: 'cover', offset: CSS.percent(100) });
+
+ scroller.scrollTop = 600; // Start boundary for cover range.
+ await waitForNextFrame();
+
+ assert_percents_equal(anims[0].currentTime, 0,
+ 'currentTime at start of normal range');
+ assert_percents_equal(anims[1].currentTime, 0,
+ 'currentTime at cover 0%');
+
+ scroller.scrollTop = 900; // End boundary for cover range.
+ await waitForNextFrame();
+
+ assert_percents_equal(anims[0].currentTime, 100,
+ 'currentTime at end of normal range');
+ assert_percents_equal(anims[1].currentTime, 100,
+ 'currentTime at cover 100%');
+ }, 'Changing the animation range updates the play state');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test-ref.html b/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test-ref.html
new file mode 100644
index 0000000000..7e584400f7
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test-ref.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="stylesheet" href="support/animation-range.css">
+<script src="/common/reftest-wait.js"></script>
+<style>
+.meter {
+ animation: active-interval linear 100s paused;
+ animation-timeline: auto;
+}
+
+.bar {
+ animation: slide-in linear 100s paused;
+ animation-timeline: auto;
+}
+</style>
+</head>
+<body onload="test();">
+<h3>View timeline</h3>
+<template id="meters">
+ <div class="meters">
+ <div class="cover"><div class="meter"><div class="bar"></div></div><div>Cover</div></div>
+ <div class="contain"><div class="meter"><div class="bar"></div></div><div>Contain</div></div>
+ <div class="entry"><div class="meter"><div class="bar"></div></div><div>Entry</div></div>
+ <div class="exit"><div class="meter"><div class="bar"></div></div><div>Exit</div></div>
+ </div>
+</template>
+<div class="flex">
+ <div>
+ <div class="scroller" data-scroll-top="10">
+ <div class="subject" data-progress=".08333,-1,.5,-1"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="30">
+ <div class="subject" data-progress=".25,.125,2,-1"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="90">
+ <div class="subject" data-progress=".75,.875,2,-1"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="110">
+ <div class="subject" data-progress=".91667,2,2,.5"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</div>
+</body>
+<script>
+ function test() {
+ let template = document.querySelector('#meters');
+ let scrollers = document.querySelectorAll('.scroller');
+ for (let i = 0; i < scrollers.length; i++) {
+ let subject = scrollers[i].querySelector('.subject');
+ let clone = template.content.cloneNode(true);
+ let meters = clone.querySelectorAll('.meter');
+ let progress = subject.getAttribute('data-progress').split(',').map(s => parseFloat(s));
+ for (let meter of meters) {
+ let bar = meter.querySelector('.bar');
+ let startTime = -progress.splice(0, 1)[0] * 100;
+ meter.style.animationDelay = `${startTime}s`;
+ bar.style.animationDelay = `${startTime}s`;
+ }
+ subject.appendChild(clone);
+ scrollers[i].scrollTop = parseInt(scrollers[i].getAttribute('data-scroll-top'));
+ }
+ takeScreenshot();
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test.html b/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test.html
new file mode 100644
index 0000000000..1ff2b12d1f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-range-visual-test.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="match" href="animation-range-visual-test-ref.html">
+<meta name=fuzzy content="maxDifference=0-64;totalPixels=0-15">
+<link rel="stylesheet" href="support/animation-range.css">
+<script src="/common/reftest-wait.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<body onload="test();">
+<h3>View timeline</h3>
+<template id="meters">
+ <div class="meters">
+ <div class="cover"><div class="meter"><div class="bar"></div></div><div>Cover</div></div>
+ <div class="contain"><div class="meter"><div class="bar"></div></div><div>Contain</div></div>
+ <div class="entry"><div class="meter"><div class="bar"></div></div><div>Entry</div></div>
+ <div class="exit"><div class="meter"><div class="bar"></div></div><div>Exit</div></div>
+ </div>
+</template>
+<div class="flex">
+ <div>
+ <div class="scroller" data-scroll-top="10">
+ <div class="subject"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="30">
+ <div class="subject"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="90">
+ <div class="subject"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+ <div>
+ <div class="scroller" data-scroll-top="110">
+ <div class="subject"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</div>
+</body>
+<script>
+ function test() {
+ let template = document.querySelector('#meters');
+ let scrollers = document.querySelectorAll('.scroller');
+ for (let i = 0; i < scrollers.length; i++) {
+ let subject = scrollers[i].querySelector('.subject');
+ subject.appendChild(template.content.cloneNode(true));
+ scrollers[i].scrollTop = parseInt(scrollers[i].getAttribute('data-scroll-top'));
+ }
+ waitForCompositorReady().then(takeScreenshot);
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-shorthand.html b/testing/web-platform/tests/scroll-animations/css/animation-shorthand.html
new file mode 100644
index 0000000000..b7d5947a21
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-shorthand.html
@@ -0,0 +1,166 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-shorthand">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<script src="/css/support/shorthand-testcommon.js"></script>
+<div id="target"></div>
+<script>
+test_valid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim');
+
+test_invalid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim initial');
+test_invalid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim 2000');
+test_invalid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim scroll()');
+test_invalid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim view()');
+test_invalid_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim timeline');
+
+test_computed_value('animation',
+ '1s linear 1s 2 reverse forwards paused anim');
+
+test_shorthand_value('animation',
+ `1s linear 1s 2 reverse forwards paused anim1,
+ 1s linear 1s 2 reverse forwards paused anim2,
+ 1s linear 1s 2 reverse forwards paused anim3`,
+{
+ 'animation-duration': '1s, 1s, 1s',
+ 'animation-timing-function': 'linear, linear, linear',
+ 'animation-delay': '1s, 1s, 1s',
+ 'animation-iteration-count': '2, 2, 2',
+ 'animation-direction': 'reverse, reverse, reverse',
+ 'animation-fill-mode': 'forwards, forwards, forwards',
+ 'animation-play-state': 'paused, paused, paused',
+ 'animation-name': 'anim1, anim2, anim3',
+ 'animation-timeline': 'auto',
+ 'animation-range-start': 'normal',
+ 'animation-range-end': 'normal',
+});
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationTimeline = '--timeline';
+ assert_equals(target.style.animation, '');
+ assert_equals(target.style.animationName, 'anim');
+ assert_equals(target.style.animationDuration, '1s');
+
+ target.style.animationTimeline = 'auto, auto';
+ assert_equals(target.style.animation, '');
+}, 'Animation shorthand can not represent non-initial timelines (specified)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationTimeline = '--timeline';
+ assert_equals(getComputedStyle(target).animation, '');
+ assert_equals(getComputedStyle(target).animationName, 'anim');
+ assert_equals(getComputedStyle(target).animationDuration, '1s');
+
+ target.style.animationTimeline = 'auto, auto';
+ assert_equals(getComputedStyle(target).animation, '');
+}, 'Animation shorthand can not represent non-initial timelines (computed)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationDelayEnd = '42s';
+ assert_equals(target.style.animation, '');
+ assert_equals(target.style.animationName, 'anim');
+ assert_equals(target.style.animationDuration, '1s');
+
+ target.style.animationDelayEnd = '0s, 0s';
+ assert_equals(target.style.animation, '');
+}, 'Animation shorthand can not represent non-initial animation-delay-end (specified)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationDelayEnd = '42s';
+ assert_equals(getComputedStyle(target).animation, '');
+ assert_equals(getComputedStyle(target).animationName, 'anim');
+ assert_equals(getComputedStyle(target).animationDuration, '1s');
+
+ target.style.animationDelayEnd = '0s, 0s';
+ assert_equals(getComputedStyle(target).animation, '');
+}, 'Animation shorthand can not represent non-initial animation-delay-end (computed)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationRangeStart = 'entry';
+ assert_equals(target.style.animation, '');
+ assert_equals(target.style.animationName, 'anim');
+ assert_equals(target.style.animationDuration, '1s');
+
+ target.style.animationRangeStart = 'normal, normal';
+ assert_equals(target.style.animation, '');
+}, 'Animation shorthand can not represent non-initial animation-range-start (specified)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationRangeStart = 'entry';
+ assert_equals(getComputedStyle(target).animation, '');
+ assert_equals(getComputedStyle(target).animationName, 'anim');
+ assert_equals(getComputedStyle(target).animationDuration, '1s');
+
+ target.style.animationRangeStart = 'normal, normal';
+ assert_equals(getComputedStyle(target).animation, '');
+}, 'Animation shorthand can not represent non-initial animation-range-start (computed)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationRangeEnd = 'entry';
+ assert_equals(target.style.animation, '');
+ assert_equals(target.style.animationName, 'anim');
+ assert_equals(target.style.animationDuration, '1s');
+
+ target.style.animationRangeEnd = 'normal, normal';
+ assert_equals(target.style.animation, '');
+}, 'Animation shorthand can not represent non-initial animation-range-end (specified)');
+
+test((t) => {
+ t.add_cleanup(() => {
+ target.style = '';
+ });
+
+ target.style.animation = 'anim 1s';
+ target.style.animationRangeEnd = 'entry';
+ assert_equals(getComputedStyle(target).animation, '');
+ assert_equals(getComputedStyle(target).animationName, 'anim');
+ assert_equals(getComputedStyle(target).animationDuration, '1s');
+
+ target.style.animationRangeEnd = 'normal, normal';
+ assert_equals(getComputedStyle(target).animation, '');
+}, 'Animation shorthand can not represent non-initial animation-range-end (computed)');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-computed.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-computed.html
new file mode 100644
index 0000000000..1e621eee53
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-computed.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#animation-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+</head>
+<style>
+ #outer { animation-timeline: --foo; }
+ #target { animation-timeline: --bar; }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+test_computed_value('animation-timeline', 'initial', 'auto');
+test_computed_value('animation-timeline', 'inherit', '--foo');
+test_computed_value('animation-timeline', 'unset', 'auto');
+test_computed_value('animation-timeline', 'revert', 'auto');
+test_computed_value('animation-timeline', 'auto');
+test_computed_value('animation-timeline', 'none');
+test_computed_value('animation-timeline', 'auto, auto');
+test_computed_value('animation-timeline', 'none, none');
+test_computed_value('animation-timeline', 'auto, none');
+test_computed_value('animation-timeline', 'none, auto');
+test_computed_value('animation-timeline', '--test');
+test_computed_value('animation-timeline', '--test1, --test2');
+test_computed_value('animation-timeline', '--test1, --test2, none, --test3, auto');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('animation-timeline'), -1);
+}, 'The animation-timeline property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('animation-timeline'), -1);
+}, 'The animation-timeline property shows up in CSSStyleDeclaration.cssText');
+
+// https://drafts.csswg.org/scroll-animations-1/#scroll-notation
+//
+// animation-timeline: scroll(<axis>? <scroller>?);
+// <axis> = block | inline | x | y
+// <scroller> = root | nearest | self
+test_computed_value('animation-timeline', 'scroll()');
+test_computed_value('animation-timeline', 'scroll(block)', 'scroll()');
+test_computed_value('animation-timeline', 'scroll(inline)');
+test_computed_value('animation-timeline', 'scroll(x)');
+test_computed_value('animation-timeline', 'scroll(y)');
+test_computed_value('animation-timeline', 'scroll(root)');
+test_computed_value('animation-timeline', 'scroll(nearest)', 'scroll()');
+test_computed_value('animation-timeline', 'scroll(self)');
+test_computed_value('animation-timeline', 'scroll(self), scroll(nearest)', 'scroll(self), scroll()');
+test_computed_value('animation-timeline', 'scroll(inline nearest)', 'scroll(inline)');
+test_computed_value('animation-timeline', 'scroll(nearest inline)', 'scroll(inline)');
+test_computed_value('animation-timeline', 'scroll(block self)', 'scroll(self)');
+test_computed_value('animation-timeline', 'scroll(self block)', 'scroll(self)');
+test_computed_value('animation-timeline', 'scroll(y root)', 'scroll(root y)');
+
+// https://drafts.csswg.org/scroll-animations-1/#view-notation
+test_computed_value('animation-timeline', 'view()');
+test_computed_value('animation-timeline', 'view(block)', 'view()');
+test_computed_value('animation-timeline', 'view(inline)', 'view(inline)');
+test_computed_value('animation-timeline', 'view(x)', 'view(x)');
+test_computed_value('animation-timeline', 'view(y)', 'view(y)');
+test_computed_value('animation-timeline', 'view(y 1px)');
+test_computed_value('animation-timeline', 'view(1px auto)');
+test_computed_value('animation-timeline', 'view(auto 1px)');
+test_computed_value('animation-timeline', 'view(y 1px auto)');
+test_computed_value('animation-timeline', 'view(1px y)', 'view(y 1px)');
+test_computed_value('animation-timeline', 'view(y auto)', 'view(y)');
+test_computed_value('animation-timeline', 'view(y auto auto)', 'view(y)');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-deferred.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-deferred.html
new file mode 100644
index 0000000000..d0671e5f23
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-deferred.html
@@ -0,0 +1,109 @@
+<!DOCTYPE html>
+<title>Deferred timelines via Animation.timeline</title>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+
+<main id=main></main>
+<script>
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ main.offsetTop;
+ }
+
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+</script>
+<style>
+ @keyframes anim {
+ from { width: 0px; }
+ to { width: 200px; }
+ }
+ .scroller {
+ overflow-y: hidden;
+ width: 200px;
+ height: 200px;
+ }
+ .scroller > .content {
+ margin: 400px 0px;
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+ .animating {
+ background-color: coral;
+ width: 0px;
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ }
+ .timeline {
+ scroll-timeline-name: --t1;
+ }
+ .scope {
+ timeline-scope: --t1;
+ }
+</style>
+
+<template id=animation_timeline_attached>
+ <div class="scope">
+ <div class=animating>Test</div>
+ <div class="scroller timeline">
+ <div class="content animating"></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, animation_timeline_attached);
+ let scroller = main.querySelector('.scroller');
+ let animating = Array.from(main.querySelectorAll('.animating'));
+
+ assert_equals(animating.length, 2);
+ let animations = animating.map((e) => e.getAnimations()[0]);
+ assert_equals(animations.length, 2);
+
+ // animations[0] is attached via deferred timeline (timeline-scope),
+ // and animations[1] is attached directly.
+ assert_equals(animations[0].timeline, animations[1].timeline);
+ }, 'Animation.timeline returns attached timeline');
+</script>
+
+<template id=animation_timeline_inactive>
+ <div class="scope">
+ <div class=animating>Test</div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, animation_timeline_inactive);
+ let scroller = main.querySelector('.scroller');
+ let animating = main.querySelector('.animating');
+
+ assert_equals(animating.getAnimations()[0].timeline, null);
+ }, 'Animation.timeline returns null for inactive deferred timeline');
+</script>
+
+<template id=animation_timeline_overattached>
+ <div class="scope">
+ <div class=animating>Test</div>
+ <div class="scroller timeline">
+ <div class="content"></div>
+ </div>
+ <div class="scroller timeline">
+ <div class="content"></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, animation_timeline_overattached);
+ let scroller = main.querySelector('.scroller');
+ let animating = main.querySelector('.animating');
+
+ assert_equals(animating.getAnimations()[0].timeline, null);
+ }, 'Animation.timeline returns null for inactive (overattached) deferred timeline');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-ignored.tentative.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-ignored.tentative.html
new file mode 100644
index 0000000000..b9efbb428b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-ignored.tentative.html
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/pull/5666">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ main {
+ overflow: hidden;
+ height: 0px;
+ timeline-scope: --timeline1, --timeline2, --timeline3;
+ }
+ .scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ .scroller > div {
+ height: 200px;
+ }
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+ #scroller1 {
+ scroll-timeline-name: --timeline1;
+ }
+ #scroller2 {
+ scroll-timeline-name: --timeline2;
+ }
+ #scroller3 {
+ scroll-timeline-name: --timeline3;
+ }
+ #element {
+ width: 0px;
+ height: 20px;
+ animation-name: expand;
+ animation-duration: 1000s;
+ animation-timing-function: linear;
+ animation-timeline: --timeline1;
+ }
+ /* Ensure stable expectations if feature is not supported */
+ @supports not (animation-timeline:--foo) {
+ #element { animation-play-state: paused; }
+ }
+</style>
+<main>
+ <div class=scroller id=scroller1><div></div></div>
+ <div class=scroller id=scroller2><div></div></div>
+ <div class=scroller id=scroller3><div></div></div>
+ <div class=scroller id=scroller4><div></div></div>
+ <div id=container></div>
+</main>
+<script>
+ scroller1.scrollTop = 20;
+ scroller2.scrollTop = 40;
+ scroller3.scrollTop = 60;
+ scroller4.scrollTop = 80;
+
+ // Create #element in #container, run |func|, then clean up afterwards.
+ function test_animation_timeline(func, description) {
+ promise_test(async () => {
+ try {
+ await runAndWaitForFrameUpdate(() => {
+ let element = document.createElement('element');
+ element.setAttribute('id', 'element');
+ container.append(element);
+ });
+ await func();
+ } finally {
+ while (container.firstChild)
+ container.firstChild.remove();
+ }
+ }, description);
+ }
+
+ test_animation_timeline(async () => {
+ let animation = element.getAnimations()[0];
+ assert_equals(getComputedStyle(element).width, '120px');
+ element.style = 'animation-timeline:--timeline2';
+ await animation.ready;
+
+ assert_equals(getComputedStyle(element).width, '140px');
+ }, 'Changing animation-timeline changes the timeline (sanity check)');
+
+ test_animation_timeline(async () => {
+ let animation = element.getAnimations()[0];
+ assert_equals(getComputedStyle(element).width, '120px');
+
+ // Set a (non-CSS) ScrollTimeline on the CSSAnimation.
+ let timeline4 = new ScrollTimeline({ source: scroller4 });
+
+ animation.timeline = timeline4;
+ await animation.ready;
+ assert_equals(getComputedStyle(element).width, '180px');
+
+ // Changing the animation-timeline property should have no effect.
+ element.style = 'animation-timeline:--timeline2';
+ await animation.ready;
+
+ assert_equals(getComputedStyle(element).width, '180px');
+ }, 'animation-timeline ignored after setting timeline with JS ' +
+ '(ScrollTimeline from JS)');
+
+ test_animation_timeline(async () => {
+ let animation = element.getAnimations()[0];
+ assert_equals(getComputedStyle(element).width, '120px');
+
+ let timeline1 = animation.timeline;
+ element.style = 'animation-timeline:--timeline2';
+ await animation.ready;
+ assert_equals(getComputedStyle(element).width, '140px');
+
+ animation.timeline = timeline1;
+ await animation.ready;
+
+ assert_equals(getComputedStyle(element).width, '120px');
+
+ // Should have no effect.
+ element.style = 'animation-timeline:--timeline3';
+ await animation.ready;
+
+ assert_equals(getComputedStyle(element).width, '120px');
+ }, 'animation-timeline ignored after setting timeline with JS ' +
+ '(ScrollTimeline from CSS)');
+
+ test_animation_timeline(async () => {
+ let animation = element.getAnimations()[0];
+ assert_equals(getComputedStyle(element).width, '120px');
+ animation.timeline = document.timeline;
+ await animation.ready;
+
+ // (The animation continues from where the previous timeline left it).
+ assert_equals(getComputedStyle(element).width, '120px');
+
+ // Changing the animation-timeline property should have no effect.
+ element.style = 'animation-timeline:--timeline2';
+ await animation.ready;
+ assert_equals(getComputedStyle(element).width, '120px');
+ }, 'animation-timeline ignored after setting timeline with JS (document timeline)');
+
+ test_animation_timeline(async () => {
+ let animation = element.getAnimations()[0];
+ assert_equals(getComputedStyle(element).width, '120px');
+ animation.timeline = null;
+ assert_false(animation.pending);
+ assert_equals(getComputedStyle(element).width, '120px');
+
+ // Changing the animation-timeline property should have no effect.
+ element.style = 'animation-timeline:--timeline2';
+ assert_false(animation.pending);
+ assert_equals(getComputedStyle(element).width, '120px');
+ }, 'animation-timeline ignored after setting timeline with JS (null)');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-in-keyframe.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-in-keyframe.html
new file mode 100644
index 0000000000..04b430c324
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-in-keyframe.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#animation-timeline">
+<link rel="help" href="https://drafts.csswg.org/css-animations-1/#keyframes">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<style>
+ @keyframes test {
+ from { width: 100px; animation-timeline: --foo; }
+ to { width: 100px; animation-timeline: --foo; }
+ }
+ #target {
+ width: 50px;
+ animation-name: test;
+ animation-duration: 1s;
+ animation-play-state: paused;
+ }
+</style>
+<div id="target"></div>
+<script>
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ // Checking 'width' verifies that the animation is applied at all.
+ assert_equals(style.width, '100px');
+ assert_equals(style.animationTimeline, 'auto');
+}, 'The animation-timeline property may not be used in keyframes');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-multiple.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-multiple.html
new file mode 100644
index 0000000000..3196653656
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-multiple.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<title>animation-timeline with multiple timelines</title>
+<link rel="help" src="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ main {
+ timeline-scope: --top_timeline, --bottom_timeline, --left_timeline, --right_timeline;
+ }
+
+ .scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ .scroller > div {
+ height: 200px;
+ width: 200px;
+ }
+
+ @keyframes top {
+ from { top: 100px; }
+ to { top: 200px; }
+ }
+ @keyframes bottom {
+ from { bottom: 100px; }
+ to { bottom: 200px; }
+ }
+ @keyframes left {
+ from { left: 100px; }
+ to { left: 200px; }
+ }
+ @keyframes right {
+ from { right: 100px; }
+ to { right: 200px; }
+ }
+
+ #top_scroller {
+ scroll-timeline-name: --top_timeline;
+ scroll-timeline-axis: block;
+ }
+ #bottom_scroller {
+ scroll-timeline-name: --bottom_timeline;
+ scroll-timeline-axis: inline;
+ }
+ #left_scroller {
+ scroll-timeline-name: --left_timeline;
+ scroll-timeline-axis: block;
+ }
+ #right_scroller {
+ scroll-timeline-name: --right_timeline;
+ scroll-timeline-axis: inline;
+ }
+
+ #element {
+ animation-name: top, bottom, left, right;
+ animation-duration: 10s;
+ animation-timing-function: linear;
+ animation-timeline: --top_timeline, --bottom_timeline, --left_timeline, --right_timeline;
+ }
+ /* Ensure stable expectations if feature is not supported */
+ @supports not (animation-timeline:--foo) {
+ #element { animation-play-state: paused; }
+ }
+</style>
+<main>
+ <div class=scroller id=top_scroller><div></div></div>
+ <div class=scroller id=bottom_scroller><div></div></div>
+ <div class=scroller id=left_scroller><div></div></div>
+ <div class=scroller id=right_scroller><div></div></div>
+ <div id=element></div>
+</main>
+<script>
+ promise_test(async (t) => {
+ await runAndWaitForFrameUpdate(() => {
+ top_scroller.scrollTop = 20;
+ top_scroller.scrollLeft = 40;
+ bottom_scroller.scrollTop = 20;
+ bottom_scroller.scrollLeft = 40;
+ left_scroller.scrollTop = 60;
+ left_scroller.scrollLeft = 80;
+ right_scroller.scrollTop = 60;
+ right_scroller.scrollLeft = 80;
+ });
+ assert_equals(getComputedStyle(element).top, '120px');
+ assert_equals(getComputedStyle(element).bottom, '140px');
+ assert_equals(getComputedStyle(element).left, '160px');
+ assert_equals(getComputedStyle(element).right, '180px');
+ }, 'animation-timeline works with multiple timelines');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-named-scroll-progress-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-named-scroll-progress-timeline.tentative.html
new file mode 100644
index 0000000000..5a1f26b3f3
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-named-scroll-progress-timeline.tentative.html
@@ -0,0 +1,444 @@
+<!DOCTYPE html>
+<title>The animation-timeline: scroll-timeline-name</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/rewrite#scroll-timelines-named">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/6674">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { translate: 50px; }
+ to { translate: 150px; }
+ }
+ @keyframes anim-2 {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+
+ #target {
+ width: 100px;
+ height: 100px;
+ }
+ .square {
+ width: 100px;
+ height: 100px;
+ }
+ .square-container {
+ width: 300px;
+ height: 300px;
+ }
+ .scroller {
+ overflow: scroll;
+ }
+ .content {
+ inline-size: 100%;
+ block-size: 100%;
+ padding-inline-end: 100px;
+ padding-block-end: 100px;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+"use strict";
+
+setup(assert_implements_animation_timeline);
+
+function createScroller(t, scrollerSizeClass) {
+ let scroller = document.createElement('div');
+ let className = scrollerSizeClass || 'square';
+ scroller.className = `scroller ${className}`;
+ let content = document.createElement('div');
+ content.className = 'content';
+
+ scroller.appendChild(content);
+
+ t.add_cleanup(function() {
+ content.remove();
+ scroller.remove();
+ });
+
+ return scroller;
+}
+
+function createTarget(t) {
+ let target = document.createElement('div');
+ target.id = 'target';
+
+ t.add_cleanup(function() {
+ target.remove();
+ });
+
+ return target;
+}
+
+function createScrollerAndTarget(t, scrollerSizeClass) {
+ return [createScroller(t, scrollerSizeClass), createTarget(t)];
+}
+
+async function waitForScrollTop(scroller, percentage) {
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll * percentage / 100;
+ return waitForNextFrame();
+}
+
+async function waitForScrollLeft(scroller, percentage) {
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = maxScroll * percentage / 100;
+ return waitForNextFrame();
+}
+
+// -------------------------
+// Test scroll-timeline-name
+// -------------------------
+
+promise_test(async t => {
+ let target = document.createElement('div');
+ target.id = 'target';
+ target.className = 'scroller';
+ let content = document.createElement('div');
+ content.className = 'content';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='target' class='scroller'>
+ // <div id='content'></div>
+ // </div>
+ document.body.appendChild(target);
+ target.appendChild(content);
+
+ target.style.scrollTimelineName = '--timeline';
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline';
+ target.scrollTop = 50; // 50%
+ });
+
+ assert_equals(getComputedStyle(target).translate, '100px');
+
+ content.remove();
+ target.remove();
+}, 'scroll-timeline-name is referenceable in animation-timeline on the ' +
+ 'declaring element itself');
+
+promise_test(async t => {
+ let [parent, target] = createScrollerAndTarget(t, 'square-container');
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='parent' class='scroller'>
+ // <div id='target'></div>
+ // <div id='content'></div>
+ // </div>
+ document.body.appendChild(parent);
+ parent.insertBefore(target, parent.firstElementChild);
+
+ parent.style.scrollTimelineName = '--timeline';
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline';
+
+ parent.scrollTop = 100; // 50%
+ });
+
+ assert_equals(getComputedStyle(target).translate, '100px');
+}, "scroll-timeline-name is referenceable in animation-timeline on that " +
+ "element's descendants");
+
+// See https://github.com/w3c/csswg-drafts/issues/7047
+promise_test(async t => {
+ let [sibling, target] = createScrollerAndTarget(t);
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='sibling' class='scroller'> ... </div>
+ // <div id='target'></div>
+ document.body.appendChild(sibling);
+ document.body.appendChild(target);
+
+ // Resolvable if using a deferred timeline, but otherwise can only resolve
+ // if an ancestor container of the target element.
+ sibling.style.scrollTimelineName = '--timeline';
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline';
+
+ sibling.scrollTop = 50; // 50%
+ });
+
+ assert_equals(getComputedStyle(target).translate, '50px',
+ 'Animation with unknown timeline name holds current time at zero');
+}, "scroll-timeline-name is not referenceable in animation-timeline on that " +
+ "element's siblings");
+
+promise_test(async t => {
+ let parent = document.createElement('div');
+ parent.className = 'square';
+ parent.style.overflowX = 'clip'; // This makes overflow-y be clip as well.
+ let target = document.createElement('div');
+ target.id = 'target';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='parent' style='overflow-x: clip'>...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(parent);
+ parent.appendChild(target);
+
+ parent.style.scrollTimelineName = '--timeline';
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ assert_equals(getComputedStyle(target).translate, 'none',
+ 'Animation with an unresolved current time');
+
+ target.remove();
+ parent.remove();
+}, 'scroll-timeline-name on an element which is not a scroll-container');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimelineName = '--timeline-A';
+ scroller.scrollTop = 50; // 25%
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline-B';
+ });
+
+ const anim = target.getAnimations()[0];
+ assert_true(!!anim, 'Failed to create animation');
+ assert_equals(anim.timeline, null);
+ // Hold time of animation is zero.
+ assert_equals(getComputedStyle(target).translate, '50px');
+
+ scroller.style.scrollTimelineName = '--timeline-B';
+ await waitForNextFrame();
+
+ assert_true(!!anim.timeline, 'Failed to create timeline');
+ assert_equals(getComputedStyle(target).translate, '75px');
+}, 'Change in scroll-timeline-name to match animation timeline updates animation.');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimelineName = '--timeline-A';
+ scroller.scrollTop = 50; // 25%
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline-A';
+ });
+
+ const anim = target.getAnimations()[0];
+ assert_true(!!anim, 'Failed to create animation');
+ assert_true(!!anim.timeline, 'Failed to create timeline');
+ assert_equals(getComputedStyle(target).translate, '75px');
+ assert_percents_equal(anim.startTime, 0);
+ assert_percents_equal(anim.currentTime, 25);
+
+ scroller.style.scrollTimelineName = '--timeline-B';
+ await waitForNextFrame();
+
+ // Switching to a null timeline pauses the animation.
+ assert_equals(anim.timeline, null, 'Failed to remove timeline');
+ assert_equals(getComputedStyle(target).translate, '75px');
+ assert_equals(anim.startTime, null);
+ assert_times_equal(anim.currentTime, 2500);
+}, 'Change in scroll-timeline-name to no longer match animation timeline updates animation.');
+
+promise_test(async t => {
+ let target = createTarget(t);
+ let scroller1 = createScroller(t);
+ let scroller2 = createScroller(t);
+
+ target.style.animation = 'anim 10s linear';
+ target.style.animationTimeline = '--timeline';
+ scroller1.style.scrollTimelineName = '--timeline';
+ scroller1.id = 'A';
+ scroller2.id = 'B';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div class='scroller' id='A'> ...
+ // <div class='scroller' id='B'> ...
+ // <div id='target'></div>
+ // </div>
+ // </div>
+ document.body.appendChild(scroller1);
+ scroller1.appendChild(scroller2);
+ scroller2.appendChild(target);
+
+ scroller1.style.scrollTimelineName = '--timeline';
+ scroller1.scrollTop = 50; // 25%
+ scroller2.scrollTop = 100; // 50%
+ });
+
+ const anim = target.getAnimations()[0];
+ assert_true(!!anim.timeline, 'Failed to retrieve animation');
+ assert_equals(anim.timeline.source.id, 'A');
+ assert_equals(getComputedStyle(target).translate, '75px');
+
+ scroller2.style.scrollTimelineName = '--timeline';
+ await waitForNextFrame();
+
+ // The timeline should be updated to scroller2.
+ assert_true(!!anim.timeline, 'Animation no longer has a timeline');
+ assert_equals(anim.timeline.source.id, 'B', 'Timeline not updated');
+ assert_equals(getComputedStyle(target).translate, '100px');
+}, 'Timeline lookup updates candidate when closer match available.');
+
+promise_test(async t => {
+ let wrapper = createScroller(t);
+ wrapper.classList.remove('scroller');
+ let target = createTarget(t);
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='wrapper'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(wrapper);
+ wrapper.appendChild(target);
+ target.style.animation = "anim 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ // Timeline initially cannot be resolved, resulting in a null
+ // timeline. The animation's hold time is zero.
+ // let anim = document.getAnimations()[0];
+ assert_equals(getComputedStyle(target).translate, '50px');
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='wrapper' class="scroller"> ...
+ // <div id='target'></div>
+ // </div>
+ wrapper.classList.add('scroller');
+ wrapper.style.scrollTimelineName = '--timeline';
+ wrapper.scrollTop = 50; // 25%
+ });
+
+ // The timeline should be updated to scroller.
+ assert_equals(getComputedStyle(target).translate, '75px');
+}, 'Timeline lookup updates candidate when match becomes available.');
+
+
+// -------------------------
+// Test scroll-timeline-axis
+// -------------------------
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+ scroller.style.writingMode = 'vertical-lr';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimeline = '--timeline block';
+ target.style.animation = "anim-2 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ await waitForScrollLeft(scroller, 50);
+ assert_equals(getComputedStyle(target).zIndex, '50');
+}, 'scroll-timeline-axis is block');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+ scroller.style.writingMode = 'vertical-lr';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimeline = '--timeline inline';
+ target.style.animation = "anim-2 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ await waitForScrollTop(scroller, 50);
+ assert_equals(getComputedStyle(target).zIndex, '50');
+}, 'scroll-timeline-axis is inline');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+ scroller.style.writingMode = 'vertical-lr';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimeline = '--timeline x';
+ target.style.animation = "anim-2 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ await waitForScrollLeft(scroller, 50);
+ assert_equals(getComputedStyle(target).zIndex, '50');
+}, 'scroll-timeline-axis is x');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+ scroller.style.writingMode = 'vertical-lr';
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimeline = '--timeline y';
+ target.style.animation = "anim-2 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ await waitForScrollTop(scroller, 50);
+ assert_equals(getComputedStyle(target).zIndex, '50');
+}, 'scroll-timeline-axis is y');
+
+promise_test(async t => {
+ let [scroller, target] = createScrollerAndTarget(t);
+
+ await runAndWaitForFrameUpdate(() => {
+ // <div id='scroller' class='scroller'> ...
+ // <div id='target'></div>
+ // </div>
+ document.body.appendChild(scroller);
+ scroller.appendChild(target);
+
+ scroller.style.scrollTimeline = '--timeline block';
+ target.style.animation = "anim-2 10s linear";
+ target.style.animationTimeline = '--timeline';
+ });
+
+ await waitForScrollTop(scroller, 25);
+ await waitForScrollLeft(scroller, 75);
+ assert_equals(getComputedStyle(target).zIndex, '25');
+
+ scroller.style.scrollTimelineAxis = 'inline';
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).zIndex, '75');
+}, 'scroll-timeline-axis is mutated');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-none.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-none.html
new file mode 100644
index 0000000000..7092523c48
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-none.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<link rel="help" src="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<link rel="help" src="https://drafts.csswg.org/web-animations/#playing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+
+ .test {
+ width: 0px;
+ animation-name: expand;
+ animation-duration: 1s;
+ }
+
+ #element_timeline_none {
+ animation-timeline: none;
+ }
+ #element_unknown_timeline {
+ animation-timeline: --unknown_timeline;
+ }
+
+</style>
+<div class=test id=element_timeline_none></div>
+<div class=test id=element_unknown_timeline></div>
+<script>
+ promise_test(async (t) => {
+ assert_equals(getComputedStyle(element_timeline_none).width, '100px');
+ await waitForAnimationFrames(3);
+ assert_equals(getComputedStyle(element_timeline_none).width, '100px');
+ }, 'Animation with animation-timeline:none holds current time at zero');
+
+ promise_test(async (t) => {
+ assert_equals(getComputedStyle(element_unknown_timeline).width, '100px');
+ await waitForAnimationFrames(3);
+ assert_equals(getComputedStyle(element_unknown_timeline).width, '100px');
+ }, 'Animation with unknown timeline name holds current time at zero');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-parsing.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-parsing.html
new file mode 100644
index 0000000000..9e3c1078b5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-parsing.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#animation-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+</head>
+<div id="target"></div>
+<script>
+test_valid_value('animation-timeline', 'initial');
+test_valid_value('animation-timeline', 'inherit');
+test_valid_value('animation-timeline', 'unset');
+test_valid_value('animation-timeline', 'revert');
+test_valid_value('animation-timeline', 'auto');
+test_valid_value('animation-timeline', 'none');
+test_valid_value('animation-timeline', 'auto, auto');
+test_valid_value('animation-timeline', 'none, none');
+test_valid_value('animation-timeline', 'auto, none');
+test_valid_value('animation-timeline', 'none, auto');
+test_valid_value('animation-timeline', '--test');
+test_valid_value('animation-timeline', '--test1, --test2');
+test_valid_value('animation-timeline', '--test1, --test2, none, --test3, auto');
+
+test_invalid_value('animation-timeline', 'test1');
+test_invalid_value('animation-timeline', '10px');
+test_invalid_value('animation-timeline', 'auto auto');
+test_invalid_value('animation-timeline', 'none none');
+test_invalid_value('animation-timeline', 'foo bar');
+test_invalid_value('animation-timeline', '"foo" "bar"');
+test_invalid_value('animation-timeline', 'rgb(1, 2, 3)');
+test_invalid_value('animation-timeline', '#fefefe');
+test_invalid_value('animation-timeline', '"test"');
+
+// https://drafts.csswg.org/scroll-animations-1/#scroll-notation
+//
+// animation-timeline: scroll(<axis>? <scroller>?);
+// <axis> = block | inline | x | y
+// <scroller> = root | nearest | self
+test_valid_value('animation-timeline', 'scroll()');
+test_valid_value('animation-timeline', ' scroll() ', 'scroll()');
+test_valid_value('animation-timeline', 'scroll(block)', 'scroll()');
+test_valid_value('animation-timeline', 'scroll(inline)');
+test_valid_value('animation-timeline', 'scroll(x)');
+test_valid_value('animation-timeline', 'scroll(y)');
+test_valid_value('animation-timeline', 'scroll(root)');
+test_valid_value('animation-timeline', 'scroll(nearest)', 'scroll()');
+test_valid_value('animation-timeline', 'scroll(self)');
+test_valid_value('animation-timeline', 'scroll(inline nearest)', 'scroll(inline)');
+test_valid_value('animation-timeline', 'scroll(nearest inline)', 'scroll(inline)');
+test_valid_value('animation-timeline', 'scroll(block self)', 'scroll(self)');
+test_valid_value('animation-timeline', 'scroll(self block)', 'scroll(self)');
+test_valid_value('animation-timeline', 'scroll(y root)', 'scroll(root y)');
+
+test_invalid_value('animation-timeline', 'scroll(abc root)');
+test_invalid_value('animation-timeline', 'scroll(abc)');
+test_invalid_value('animation-timeline', 'scroll(y abc)');
+test_invalid_value('animation-timeline', 'scroll("string")');
+
+// https://drafts.csswg.org/scroll-animations-1/#view-notation
+test_valid_value('animation-timeline', 'view()');
+test_valid_value('animation-timeline', ' view() ', 'view()');
+test_valid_value('animation-timeline', 'view(block)', 'view()');
+test_valid_value('animation-timeline', 'view(inline)');
+test_valid_value('animation-timeline', 'view(x)');
+test_valid_value('animation-timeline', 'view(y)');
+test_valid_value('animation-timeline', 'view(y 1px 2px)');
+test_valid_value('animation-timeline', 'view(y 1px)');
+test_valid_value('animation-timeline', 'view(y auto)', 'view(y)');
+test_valid_value('animation-timeline', 'view(y auto auto)', 'view(y)');
+test_valid_value('animation-timeline', 'view(y auto 1px)');
+test_valid_value('animation-timeline', 'view(1px 2px y)', 'view(y 1px 2px)');
+test_valid_value('animation-timeline', 'view(1px y)', 'view(y 1px)');
+test_valid_value('animation-timeline', 'view(auto x)', 'view(x)');
+test_valid_value('animation-timeline', 'view(1px 2px)');
+test_valid_value('animation-timeline', 'view(1px)');
+test_valid_value('animation-timeline', 'view(1px 1px)', 'view(1px)');
+test_valid_value('animation-timeline', 'view(1px auto)');
+test_valid_value('animation-timeline', 'view(auto calc(1% + 1px))');
+test_valid_value('animation-timeline', 'view(auto)', 'view()');
+test_valid_value('animation-timeline', 'view(auto auto)', 'view()');
+
+test_invalid_value('animation-timeline', 'view(y 1px 2px 3px)');
+test_invalid_value('animation-timeline', 'view(1px y 3px)');
+test_invalid_value('animation-timeline', 'view(1px 2px 3px)');
+test_invalid_value('animation-timeline', 'view(abc block)');
+test_invalid_value('animation-timeline', 'view(abc)');
+test_invalid_value('animation-timeline', 'view(y abc)');
+test_invalid_value('animation-timeline', 'view("string")');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-scroll-functional-notation.tentative.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-scroll-functional-notation.tentative.html
new file mode 100644
index 0000000000..31c85810d5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-scroll-functional-notation.tentative.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<title>The animation-timeline: scroll() notation</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/rewrite#scroll-notation">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/6674">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { translate: 50px; }
+ to { translate: 150px; }
+ }
+ html {
+ min-height: 100vh;
+ /* This makes the max scrollable ragne be 100px in root element */
+ padding-bottom: 100px;
+ }
+ #container {
+ width: 300px;
+ height: 300px;
+ overflow: scroll;
+ }
+ #target {
+ width: 100px;
+ /* This makes the max scrollable ragne be 100px in the block direction */
+ height: 100px;
+ }
+ /* large block content */
+ .block-content {
+ block-size: 100%;
+ }
+ /* large inline content */
+ .inline-content {
+ inline-size: 100%;
+ block-size: 5px;
+ /* This makes the max scrollable ragne be 100px in the inline direction */
+ padding-inline-end: 100px;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+"use strict";
+
+setup(assert_implements_animation_timeline);
+
+const root = document.scrollingElement;
+const createTargetWithStuff = function(t, contentClass) {
+ let container = document.createElement('div');
+ container.id = 'container';
+ let target = document.createElement('div');
+ target.id = 'target';
+ let content = document.createElement('div');
+ content.className = contentClass;
+
+ // <div id='container'>
+ // <div id='target'></div>
+ // <div class=contentClass></div>
+ // </div>
+ document.body.appendChild(container);
+ container.appendChild(target);
+ container.appendChild(content);
+
+ if (t && typeof t.add_cleanup === 'function') {
+ t.add_cleanup(() => {
+ content.remove();
+ target.remove();
+ container.remove();
+ });
+ }
+
+ return [container, target];
+};
+
+async function scrollLeft(element, value) {
+ element.scrollLeft = value;
+ await waitForNextFrame();
+}
+
+async function scrollTop(element, value) {
+ element.scrollTop = value;
+ await waitForNextFrame();
+}
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'block-content');
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(nearest)";
+ });
+
+ await scrollTop(root, 50);
+ assert_equals(getComputedStyle(div).translate, '50px');
+
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).translate, '100px');
+
+ await scrollTop(root, 0);
+}, 'animation-timeline: scroll(nearest)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'block-content');
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(root)";
+ });
+
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).translate, '50px');
+
+ await scrollTop(root, 50);
+ assert_equals(getComputedStyle(div).translate, '100px');
+
+ await scrollTop(root, 0);
+}, 'animation-timeline: scroll(root)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'block-content');
+ await runAndWaitForFrameUpdate(() => {
+ container.style.animation = "anim 10s linear";
+ container.style.animationTimeline = "scroll(self)";
+ });
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(container).translate, '100px');
+}, 'animation-timeline: scroll(self)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'block-content');
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(self)";
+ });
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).translate, 'none');
+}, 'animation-timeline: scroll(self), on non-scroller');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'inline-content');
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(inline)";
+ });
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).translate, '100px');
+}, 'animation-timeline: scroll(inline)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'block-content');
+ await runAndWaitForFrameUpdate(() => {
+ container.style.writingMode = 'vertical-lr';
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(x)";
+ });
+
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).translate, '100px');
+}, 'animation-timeline: scroll(x)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, 'inline-content');
+ await runAndWaitForFrameUpdate(() => {
+ container.style.writingMode = 'vertical-lr';
+ div.style.animation = "anim 10s linear";
+ div.style.animationTimeline = "scroll(y)";
+ });
+
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).translate, '100px');
+}, 'animation-timeline: scroll(y)');
+
+// TODO: Add more tests which change the overflow property of the container for
+// scroll(nearest)
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-timeline-view-functional-notation.tentative.html b/testing/web-platform/tests/scroll-animations/css/animation-timeline-view-functional-notation.tentative.html
new file mode 100644
index 0000000000..d91dfe924b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-timeline-view-functional-notation.tentative.html
@@ -0,0 +1,511 @@
+<!DOCTYPE html>
+<title>The animation-timeline: view() notation</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-notation">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7587">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes fade-in-out-without-timeline-range {
+ 0% { opacity: 0; }
+ 40% { opacity: 1; }
+ 60% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+ @keyframes fade-out-without-timeline-range {
+ 0% { opacity: 1; }
+ 100% { opacity: 0; }
+ }
+ @keyframes change-font-size-without-timeline-range {
+ 0% { font-size: 10px; }
+ 100% { font-size: 30px; }
+ }
+ @keyframes fade-in-out {
+ entry 0% { opacity: 0; }
+ entry 100% { opacity: 1; }
+ exit 0% { opacity: 1; }
+ exit 100% { opacity: 0; }
+ }
+ @keyframes fade-out {
+ exit 0% { opacity: 1; }
+ exit 100% { opacity: 0; }
+ }
+ @keyframes change-font-size {
+ exit 0% { font-size: 10px; }
+ exit 100% { font-size: 20px; }
+ }
+ #container {
+ width: 200px;
+ height: 200px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ }
+ .target {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+ .content {
+ width: 400px;
+ height: 400px;
+ background-color: blue;
+ }
+</style>
+
+<body>
+<script>
+"use strict";
+
+setup(assert_implements_animation_timeline);
+
+const createTargetWithStuff = function(t, divClasses) {
+ let container = document.createElement('div');
+ container.id = 'container';
+ document.body.appendChild(container);
+
+ // *** When testing inset
+ // <div id='container'>
+ // <div class='content'></div>
+ // <div class='target'></div>
+ // <div class='content'></div>
+ // </div>
+ // *** When testing axis
+ // <div id='container'>
+ // <div class='target'></div>
+ // <div class='content'></div>
+ // </div>
+
+ let divs = [];
+ let target;
+ for(let className of divClasses) {
+ let div = document.createElement('div');
+ div.className = className;
+ container.appendChild(div);
+
+ divs.push(div);
+ if(className === 'target')
+ target = div;
+ }
+
+ if (t && typeof t.add_cleanup === 'function') {
+ t.add_cleanup(() => {
+ for(let div of divs)
+ div.remove();
+ container.remove();
+ });
+ }
+
+ return [container, target];
+};
+
+async function scrollLeft(element, value) {
+ element.scrollLeft = value;
+ await waitForNextFrame();
+}
+
+async function scrollTop(element, value) {
+ element.scrollTop = value;
+ await waitForNextFrame();
+}
+
+// ---------------------------------
+// Tests without timeline range name
+// ---------------------------------
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-in-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view()";
+
+ });
+ // So the range is [200px, 500px].
+ await scrollTop(container, 200);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 0%');
+ await scrollTop(container, 260);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 20%');
+ await scrollTop(container, 320);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 40%');
+
+ await scrollTop(container, 380);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 60%');
+ await scrollTop(container, 440);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 80%');
+ await scrollTop(container, 500);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view() without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-in-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(50px)";
+ });
+ // So the range is [250px, 450px].
+
+ await scrollTop(container, 250);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 0%');
+ await scrollTop(container, 290);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 20%');
+ await scrollTop(container, 330);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 40%');
+
+ await scrollTop(container, 370);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 60%');
+ await scrollTop(container, 410);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 80%');
+ await scrollTop(container, 450);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(50px) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-in-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(auto 50px)";
+ });
+ // So the range is [250px, 500px].
+
+ await scrollTop(container, 250);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 0%');
+ await scrollTop(container, 300);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 20%');
+ await scrollTop(container, 350);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 40%');
+
+ await scrollTop(container, 400);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At 60%');
+ await scrollTop(container, 450);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At 80%');
+ await scrollTop(container, 500);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(auto 50px) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(inline)";
+ });
+ // So the range is [-200px, 100px], but it is impossible to scroll to the
+ // negative part.
+
+ await scrollLeft(container, 0);
+ assert_approx_equals(parseFloat(getComputedStyle(div).opacity), 0.33333,
+ 0.00001, 'At 66.7%');
+ // Note: 20% for each 60px.
+ await scrollLeft(container, 40);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(inline) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(x)";
+ });
+ // So the range is [-200px, 100px], but it is impossible to scroll to the
+ // negative part.
+
+ await scrollLeft(container, 0);
+ assert_approx_equals(parseFloat(getComputedStyle(div).opacity), 0.33333,
+ 0.00001, 'At 66.7%');
+ // Note: 20% for each 60px.
+ await scrollLeft(container, 40);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(x) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "fade-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(y)";
+ });
+ // So the range is [-200px, 100px], but it is impossible to scroll to the
+ // negative part.
+
+ await scrollTop(container, 0);
+ assert_approx_equals(parseFloat(getComputedStyle(div).opacity), 0.33333,
+ 0.00001, 'At 66.7%');
+ // Note: 20% for each 60px.
+ await scrollTop(container, 40);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80%');
+ await scrollTop(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(y) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(x 50px)";
+ });
+ // So the range is [-150px, 50px], but it is impossible to scroll to the
+ // negative part.
+
+ // Note: 25% for each 50px.
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '0.25', 'At 75%');
+ await scrollLeft(container, 10);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(x 50px) without timeline range name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation
+ = "fade-out-without-timeline-range 1s linear forwards, " +
+ "change-font-size-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(50px), view(inline 50px)";
+ });
+
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).fontSize, '25px', 'At 75% inline');
+ await scrollLeft(container, 10);
+ assert_equals(getComputedStyle(div).fontSize, '26px', 'At 80% inline');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).fontSize, '30px', 'At 100% inline');
+
+ await scrollLeft(container, 0);
+
+ await scrollTop(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '0.25', 'At 75% block');
+ await scrollTop(container, 10);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80% block');
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100% block');
+
+ await scrollLeft(container, 10);
+ await scrollTop(container, 10);
+ assert_equals(getComputedStyle(div).fontSize, '26px', 'At 80% inline');
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80% block');
+}, 'animation-timeline: view(50px), view(inline 50px) without timeline range ' +
+ 'name');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'hidden';
+ div.style.animation = "fade-out-without-timeline-range 1s linear forwards";
+ div.style.animationTimeline = "view(inline)";
+ });
+ await scrollLeft(container, 0);
+ assert_approx_equals(parseFloat(getComputedStyle(div).opacity), 0.33333,
+ 0.00001, 'At 66.7%');
+ await scrollLeft(container, 40);
+ assert_equals(getComputedStyle(div).opacity, '0.2', 'At 80%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+
+ div.style.animationTimeline = "view(inline 50px)";
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '0.25', 'At 75%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At 100%');
+}, 'animation-timeline: view(inline) changes to view(inline 50px), without' +
+ 'timeline range name');
+
+
+// ---------------------------------
+// Tests with timeline range name
+// ---------------------------------
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "fade-in-out 1s linear forwards";
+ div.style.animationTimeline = "view()";
+ });
+
+ await scrollTop(container, 200);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At entry 0%');
+ await scrollTop(container, 250);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At entry 50%');
+ await scrollTop(container, 300);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At entry 100%');
+
+ await scrollTop(container, 400);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollTop(container, 450);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollTop(container, 500);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view()');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "fade-in-out 1s linear forwards";
+ div.style.animationTimeline = "view(50px)";
+ });
+
+ await scrollTop(container, 250);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At entry 0%');
+ await scrollTop(container, 300);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At entry 50%');
+
+ await scrollTop(container, 350);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At entry 100% & exit 0%');
+
+ await scrollTop(container, 400);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollTop(container, 450);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(50px)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['content', 'target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ div.style.animation = "fade-in-out 1s linear forwards";
+ div.style.animationTimeline = "view(auto 50px)";
+ });
+
+ await scrollTop(container, 250);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At entry 0%');
+ await scrollTop(container, 300);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At entry 50%');
+ await scrollTop(container, 350);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At entry 100%');
+
+ await scrollTop(container, 400);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollTop(container, 450);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollTop(container, 500);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(auto 50px)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'scroll';
+ div.style.animation = "fade-out 1s linear forwards";
+ div.style.animationTimeline = "view(inline)";
+ });
+
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(inline)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'scroll';
+ div.style.animation = "fade-out 1s linear forwards";
+ div.style.animationTimeline = "view(x)";
+ });
+
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(x)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'scroll';
+ div.style.animation = "fade-out 1s linear forwards";
+ div.style.animationTimeline = "view(y)";
+ });
+
+ await scrollTop(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollTop(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(y)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflowY = 'hidden';
+ container.style.overflowX = 'scroll';
+ div.style.animation = "fade-out 1s linear forwards";
+ div.style.animationTimeline = "view(x 50px)";
+ });
+
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(x 50px)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflow = 'scroll';
+ div.style.animation
+ = "fade-out 1s linear forwards, change-font-size 1s linear forwards";
+ div.style.animationTimeline = "view(), view(inline)";
+ });
+
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).fontSize, '10px', 'At exit 0% inline');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).fontSize, '15px', 'At exit 50% inline');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).fontSize, '20px', 'At exit 100% inline');
+
+ await scrollLeft(container, 0);
+
+ await scrollTop(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0% block');
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50% block');
+ await scrollTop(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100% block');
+
+ await scrollLeft(container, 50);
+ await scrollTop(container, 50);
+ assert_equals(getComputedStyle(div).fontSize, '15px', 'At exit 50% inline');
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50% block');
+}, 'animation-timeline: view(), view(inline)');
+
+promise_test(async t => {
+ let [container, div] = createTargetWithStuff(t, ['target', 'content']);
+ await runAndWaitForFrameUpdate(() => {
+ container.style.overflowY = 'hidden';
+ container.style.overflowX = 'scroll';
+ div.style.animation = "fade-out 1s linear forwards";
+ });
+
+ div.style.animationTimeline = "view(inline)";
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '1', 'At exit 0%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollLeft(container, 100);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+
+ div.style.animationTimeline = "view(inline 50px)";
+ await scrollLeft(container, 0);
+ assert_equals(getComputedStyle(div).opacity, '0.5', 'At exit 50%');
+ await scrollLeft(container, 50);
+ assert_equals(getComputedStyle(div).opacity, '0', 'At exit 100%');
+}, 'animation-timeline: view(inline) changes to view(inline 50px)');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/animation-update-ref.html b/testing/web-platform/tests/scroll-animations/css/animation-update-ref.html
new file mode 100644
index 0000000000..7e375a1df7
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/animation-update-ref.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Reference file for various tests that update an animation with a scroll timeline</title>
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 1px solid black;
+ overflow: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ // Defaults to exit 60% if using a view timeline with subject = target.
+ const DEFAULT_SCROLL_POS = 860;
+ await waitForCompositorReady();
+
+ const urlParams = new URLSearchParams(window.location.search);
+ target.style.transform =
+ `translateX(${urlParams.get('translate') || "0px"}`;
+
+ scroller.scrollTop = urlParams.get('scroll') || DEFAULT_SCROLL_POS;
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ // Make sure change to animation range was properly picked up.
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited-ref.html b/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited-ref.html
new file mode 100644
index 0000000000..088e93750a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited-ref.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<style>
+ #target {
+ translate: 50px;
+ width: 50px;
+ height: 50px;
+ background-color: green;
+ }
+</style>
+<div id=target></div>
diff --git a/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited.html b/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited.html
new file mode 100644
index 0000000000..e2437911b3
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/deferred-timeline-composited.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+ <link rel="match" href="deferred-timeline-composited-ref.html">
+ <script src="/web-animations/testcommon.js"></script>
+ <script src="/common/reftest-wait.js"></script>
+ <style>
+ @keyframes anim {
+ from { translate: 0px; }
+ to { translate: 100px; }
+ }
+ main {
+ timeline-scope: --t1;
+ }
+ .scroller {
+ width: 100px;
+ height: 100px;
+ will-change: translate;
+ background-color: white;
+ /* Prevent scrollers from appearing in the screenshot. */
+ opacity: 0;
+ }
+ .scroller > div {
+ height: 300px;
+ width: 300px;
+ }
+ #target {
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ width: 50px;
+ height: 50px;
+ will-change: translate;
+ background-color: green;
+ }
+ .timeline {
+ scroll-timeline-name: --t1;
+ }
+ #scroller_block {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ scroll-timeline-axis: block;
+ }
+ #scroller_inline {
+ overflow-y: hidden;
+ overflow-x: scroll;
+ scroll-timeline-axis: inline;
+ }
+ </style>
+</head>
+<body>
+ <main>
+ <div id=target></div>
+ <div id=scroller_block class="scroller timeline">
+ <div></div>
+ </div>
+ <div id=scroller_inline class=scroller>
+ <div></div>
+ </div>
+ </main>
+ <script>
+ (async () => {
+ await waitForCompositorReady();
+ // Switch out the timeline associated with timeline-scope:--t.
+ scroller_block.classList.toggle('timeline');
+ scroller_inline.classList.toggle('timeline');
+ await waitForNextFrame();
+ let scrollPromise = new Promise((resolve) => {
+ scroller_inline.addEventListener('scrollend', resolve);
+ });
+ scroller_inline.scrollTo({left: 100, behavior: "smooth"}); // 50%
+ await scrollPromise;
+ await waitForNextFrame();
+ takeScreenshot();
+ })();
+ </script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/get-animations-inactive-timeline.html b/testing/web-platform/tests/scroll-animations/css/get-animations-inactive-timeline.html
new file mode 100644
index 0000000000..83bc5b5f53
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/get-animations-inactive-timeline.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>getAnimations for scroll-linked animations</title>
+<link rel="help"
+ href="https://www.w3.org/TR/web-animations-1/#animation-effect-phases-and-states">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes slide {
+ from { transform: translateX(100px); }
+ to { transform: translateX(100px); }
+ }
+
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ scroll-timeline-name: --timeline;
+ }
+ #spacer {
+ height: 200vh;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ animation: slide 1s linear;
+ animation-timeline: --timeline;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="spacer"></div>
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ setup(assert_implements_animation_timeline);
+
+ promise_test(async t => {
+ let animations = document.getAnimations();
+ assert_equals(animations.length, 1,
+ 'Single running animation');
+ assert_true(animations[0].timeline instanceof ScrollTimeline,
+ 'Animation associated with a scroll timeline');
+
+ // Canceled animation is no longer current.
+ const anim = animations[0];
+ animations[0].cancel();
+
+ assert_equals(
+ document.getAnimations().length, 0,
+ 'A canceled animation is no longer returned by getAnimations');
+
+ // Replaying an animation makes it current.
+ anim.play();
+ assert_equals(
+ document.getAnimations().length, 1,
+ 'A play-pending animation is return by getAnimations');
+
+ // Animation effect is still current even if the timeline's source element
+ // cannot be scrolled.
+ spacer.style = 'display: none';
+ t.add_cleanup(() => {
+ spacer.style = '';
+ });
+
+ animations = document.getAnimations();
+ assert_equals(
+ animations.length, 1,
+ 'Running animation is included in getAnimations list even if ' +
+ 'currentTime is null');
+ assert_true(animations[0].timeline instanceof ScrollTimeline,
+ 'Animation has timeline associated with an element that ' +
+ 'cannot be scrolled');
+ assert_equals(animations[0].timeline.currentTime, null,
+ 'Inactive timeline when timeline\'s source element cannot ' +
+ 'be scrolled');
+ }, 'getAnimations includes inactive scroll-linked animations that have not ' +
+ 'been canceled');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/merge-timeline-offset-keyframes.html b/testing/web-platform/tests/scroll-animations/css/merge-timeline-offset-keyframes.html
new file mode 100644
index 0000000000..e5d5037d62
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/merge-timeline-offset-keyframes.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>Merge timeline offset keyframes</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+</head>
+<style>
+ @keyframes anim-1 {
+ entry 0% { opacity: 0 }
+ entry 100% { opacity: 1 }
+ contain 0% { opacity: 0.8 }
+ entry 100% { opacity: 0.5 }
+ }
+ @keyframes anim-2 {
+ entry 0% { opacity: 0 }
+ entry 100% { opacity: 1 }
+ contain 0% { opacity: 0.8 }
+ entry 100% { opacity: 0.5; animation-timing-function: ease }
+ }
+
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation-duration: auto;
+ animation-fill-mode: both;
+ animation-timing-function: linear;
+ view-timeline: --target;
+ animation-timeline: --target;
+ }
+ #target.anim-1 {
+ animation-name: anim-1;
+ }
+ #target.anim-2 {
+ animation-name: anim-2;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script>
+ async function runTests() {
+ promise_test(async t => {
+ target.classList.add('anim-1');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ t.add_cleanup(() => {
+ target.classList.remove('anim-1');
+ });
+ const keyframes = anim.effect.getKeyframes();
+ const expected = [
+ {
+ offset: 1, easing: "linear", composite: "replace", opacity: "1",
+ computedOffset: 1
+ },
+ {
+ offset: { rangeName: "entry", offset: CSS.percent(0) },
+ easing: "linear", composite: "auto", opacity: "0",
+ computedOffset: 0
+ },
+ {
+ offset: { rangeName: "contain", offset: CSS.percent(0) },
+ easing: "linear", composite: "auto", opacity: "0.8",
+ computedOffset: 1/3
+ },
+ {
+ offset: { rangeName: "entry", offset: CSS.percent(100) },
+ easing: "linear", composite: "auto", opacity: "0.5",
+ computedOffset : 1/3
+ }];
+ assert_frame_lists_equal(keyframes, expected);
+ }, 'Keyframes with same easing and timeline offset are merged.');
+
+ promise_test(async t => {
+ target.classList.add('anim-2');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+
+ t.add_cleanup(() => {
+ target.classList.remove('anim-2');
+ });
+
+ const keyframes = anim.effect.getKeyframes();
+ const expected = [
+ {
+ offset: 1, easing: "linear", composite: "replace", opacity: "1",
+ computedOffset: 1
+ },
+ {
+ offset: { rangeName: "entry", offset: CSS.percent(0) },
+ easing: "linear", composite: "auto", opacity: "0",
+ computedOffset: 0
+ },
+ {
+ offset: { rangeName: "entry", offset: CSS.percent(100) },
+ easing: "linear", composite: "auto", opacity: "1",
+ computedOffset: 1/3
+ },
+ {
+ offset: { rangeName: "contain", offset: CSS.percent(0) },
+ easing: "linear", composite: "auto", opacity: "0.8",
+ computedOffset: 1/3
+ },
+ {
+ offset: { rangeName: "entry", offset: CSS.percent(100) },
+ easing: "ease", composite: "auto", opacity: "0.5",
+ computedOffset : 1/3
+ }];
+ assert_frame_lists_equal(keyframes, expected);
+ }, 'Keyframes with same timeline offset but different easing function ' +
+ 'are not merged.');
+ }
+
+ window.onload = runTests();
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/named-range-keyframes-with-document-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/css/named-range-keyframes-with-document-timeline.tentative.html
new file mode 100644
index 0000000000..a0094d3220
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/named-range-keyframes-with-document-timeline.tentative.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Named range keyframe offset when you have a document timeline</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes fade-in-animation {
+ from { opacity: 0 }
+
+ enter 0% { opacity: 0 }
+ enter 100% { opacity: 1 }
+ exit 0% { opacity: 1 }
+ exit 100% { opacity: 0 }
+
+ to { opacity: 1 }
+ }
+
+ #subject {
+ background-color: blue;
+ height: 200px;
+ width: 200px;
+ animation: linear both fade-in-animation;
+ animation-duration: 0.1s;
+ animation-play-state: paused;
+ }
+</style>
+<body onload="runTests()">
+ <div id="subject"></div>
+</body>
+
+<script type="text/javascript">
+ setup(assert_implements_animation_timeline);
+
+ function runTests() {
+ promise_test(async t => {
+ const anim = subject.getAnimations()[0];
+ anim.currentTime = -1;
+ assert_equals(getComputedStyle(subject).opacity, "0",
+ 'unexpected value in the before phase');
+
+ anim.currentTime = 50;
+ assert_equals(getComputedStyle(subject).opacity, "0.5",
+ 'unexpected value in the middle of the animation');
+
+ anim.currentTime = 100;
+ assert_equals(getComputedStyle(subject).opacity, "1",
+ 'unexpected value in the after phase');
+ });
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-print.tentative.html b/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-print.tentative.html
new file mode 100644
index 0000000000..c37c1b95ef
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-print.tentative.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The animation-timeline:none with preserved progress for print</title>
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="print correctly for an animation with animation-timeline:none with preserved progress">
+<link rel="match" href="animation-timeline-none-with-progress-ref.html">
+
+<style>
+ @keyframes anim {
+ from { transform: translateX(0px); }
+ to { transform: translateX(100px); }
+ }
+
+ #scroller {
+ scroll-timeline: --timeline;
+ overflow: scroll;
+ width: 100px;
+ height: 100px;
+ scrollbar-width: none;
+ }
+
+ #contents {
+ height: 200px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: anim 1s linear timeline;
+ }
+</style>
+
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ const scroller = document.getElementById("scroller");
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ window.requestAnimationFrame(() => {
+ let box = document.getElementById("box");
+ box.style.animationTimeline = "none";
+ getComputedStyle(box).marginLeft;
+
+ window.requestAnimationFrame(() => {
+ document.documentElement.classList.remove("reftest-wait");
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-ref.html b/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-ref.html
new file mode 100644
index 0000000000..09bcba2fd4
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/animation-timeline-none-with-progress-ref.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>Reference for none animation-timeline</title>
+<style>
+ #scroller {
+ overflow: scroll;
+ width: 100px;
+ height: 100px;
+ scrollbar-width: none;
+ }
+
+ #contents {
+ height: 200px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateX(50px);
+ }
+</style>
+
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ const scroller = document.getElementById("scroller");
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ document.documentElement.classList.remove("reftest-wait");
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-iframe-print.html b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-iframe-print.html
new file mode 100644
index 0000000000..d732ca141a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-iframe-print.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The default scroll() timeline in the iframe for print</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the default scroll() timeline">
+<link rel="match" href="../scroll-timeline-default-iframe-ref.html">
+<meta name="fuzzy" content="25;100">
+
+<iframe id="target" width="400" height="400" srcdoc='
+ <html>
+ <style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+ </style>
+ <script>
+ window.addEventListener("load", function() {
+ const scroller = document.scrollingElement;
+
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ window.requestAnimationFrame(() => {
+ window.parent.postMessage("ready", "*");
+ });
+ });
+ </script>
+ <body>
+ <div id="box"></div>
+ <div id="covered"></div>
+ </body>
+ </html>
+'></iframe>
+
+<script>
+ window.addEventListener("message", event => {
+ if (event.data == "ready") {
+ document.documentElement.classList.remove("reftest-wait");
+ }
+ }, false);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print-ref.html b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print-ref.html
new file mode 100644
index 0000000000..6610f7a5a7
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print-ref.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<title>Reference for default scroll() timeline</title>
+<style>
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print.tentative.html b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print.tentative.html
new file mode 100644
index 0000000000..3f25cc93db
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-default-print.tentative.html
@@ -0,0 +1,59 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The default scroll() timeline for print</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the default scroll() timeline">
+<link rel="match" href="scroll-timeline-default-print-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the halfway point.
+ // When printing, a timeline associated with the document's scrolling
+ // element will become inactive. The root scroller is considered to be
+ // fully in view with a scroll range of zero.
+ // https://github.com/w3c/csswg-drafts/issues/8226
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-print.html b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-print.html
new file mode 100644
index 0000000000..dd4add49b0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-print.html
@@ -0,0 +1,58 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>A scroll timeline with a specified scroller for print</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using a specified scroller">
+<link rel="match" href="scroll-timeline-specified-scroller-ref.html">
+
+<style>
+ @keyframes anim {
+ from { transform: translateX(0px); }
+ to { transform: translateX(100px); }
+ }
+
+ #scroller {
+ scroll-timeline: --timeline;
+ overflow: scroll;
+ width: 100px;
+ height: 100px;
+ scrollbar-width: none;
+ }
+
+ #contents {
+ height: 200px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: anim 1s linear;
+ animation-timeline: --timeline;
+ }
+
+ @supports not (animation-timeline:--timeline) {
+ #box {
+ animation-play-state: paused;
+ }
+ }
+</style>
+
+<div id="scroller">
+ <div id="contents"></div>
+ <div id="box"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ const scroller = document.getElementById("scroller");
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ window.requestAnimationFrame(() => {
+ document.documentElement.classList.remove("reftest-wait");
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-ref.html b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-ref.html
new file mode 100644
index 0000000000..d2f2d8f73d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/printing/scroll-timeline-specified-scroller-ref.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>Reference for scroll timeline with a specified scroller</title>
+<style>
+ #scroller {
+ overflow: scroll;
+ width: 100px;
+ height: 100px;
+ scrollbar-width: none;
+ }
+
+ #contents {
+ height: 200px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateX(50px);
+ }
+</style>
+
+<div id="scroller">
+ <div id="contents"></div>
+ <div id="box"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ const scroller = document.getElementById("scroller");
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ document.documentElement.classList.remove("reftest-wait");
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/progress-based-animation-animation-longhand-properties.tentative.html b/testing/web-platform/tests/scroll-animations/css/progress-based-animation-animation-longhand-properties.tentative.html
new file mode 100644
index 0000000000..25ce167553
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/progress-based-animation-animation-longhand-properties.tentative.html
@@ -0,0 +1,277 @@
+<!DOCTYPE html>
+<title>The various animation longhands with progress based animations</title>
+<link rel="help" src="https://drafts.csswg.org/css-animations-2">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/4862">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/6674">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { translate: 0px; }
+ to { translate: 100px; }
+ }
+ #container {
+ width: 300px;
+ height: 300px;
+ overflow: scroll;
+ }
+ #target {
+ width: 100px;
+ height: 100px;
+ translate: none;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+"use strict";
+
+setup(assert_implements_animation_timeline);
+
+const createTargetAndScroller = function(t) {
+ let container = document.createElement('div');
+ container.id = 'container';
+ let target = document.createElement('div');
+ target.id = 'target';
+ let content = document.createElement('div');
+ content.style.blockSize = '100%';
+
+ // The height of target is 100px and the content is 100%, so the scroll range
+ // is [0, 100].
+
+ // <div id='container'>
+ // <div id='target'></div>
+ // <div style='block-size: 100%;'></div>
+ // </div>
+ document.body.appendChild(container);
+ container.appendChild(target);
+ container.appendChild(content);
+
+ if (t && typeof t.add_cleanup === 'function') {
+ t.add_cleanup(() => {
+ content.remove();
+ target.remove();
+ container.remove();
+ });
+ }
+
+ return [target, container];
+};
+
+async function scrollTop(element, value) {
+ element.scrollTop = value;
+ await waitForNextFrame();
+}
+
+// ------------------------------
+// Test animation-duration
+// ------------------------------
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ });
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '25px');
+}, 'animation-duration');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ target.style.animation = '0s linear anim forwards';
+ target.style.animationTimeline = 'scroll(nearest)';
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '100px');
+}, 'animation-duration: 0s');
+
+
+// ------------------------------
+// Test animation-iteration-count
+// ------------------------------
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ });
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '25px');
+
+ // Let animation become 50% in the 1st iteration.
+ target.style.animationIterationCount = '2';
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).translate, '50px');
+
+ // Let animation become 0% in the 2nd iteration.
+ target.style.animationIterationCount = '4';
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).translate, '0px');
+}, 'animation-iteration-count');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim forwards';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationIterationCount = '0';
+ });
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '0px');
+}, 'animation-iteration-count: 0');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim forwards';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationIterationCount = 'infinite';
+ });
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '100px');
+}, 'animation-iteration-count: infinite');
+
+
+// ------------------------------
+// Test animation-direction
+// ------------------------------
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ });
+
+ await scrollTop(scroller, 25) // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '25px');
+}, 'animation-direction: normal');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationDirection = 'reverse';
+ });
+
+ await scrollTop(scroller, 25); // 25% in the reversing direction.
+ assert_equals(getComputedStyle(target).translate, '75px');
+}, 'animation-direction: reverse');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationIterationCount = '2';
+ target.style.animationDirection = 'alternate';
+ });
+
+ await scrollTop(scroller, 10); // 20% in the 1st iteration.
+ assert_equals(getComputedStyle(target).translate, '20px');
+
+ await scrollTop(scroller, 60); // 20% in the 2nd iteration (reversing direction).
+ assert_equals(getComputedStyle(target).translate, '80px');
+}, 'animation-direction: alternate');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationIterationCount = '2';
+ target.style.animationDirection = 'alternate-reverse';
+ });
+
+ await scrollTop(scroller, 10); // 20% in the 1st iteration (reversing direction).
+ assert_equals(getComputedStyle(target).translate, '80px');
+
+ await scrollTop(scroller, 60); // 20% in the 2nd iteration.
+ assert_equals(getComputedStyle(target).translate, '20px');
+}, 'animation-direction: alternate-reverse');
+
+
+// ------------------------------
+// Test animation-delay
+// ------------------------------
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ });
+
+ await scrollTop(scroller, 25); // [0, 100].
+ assert_equals(getComputedStyle(target).translate, '25px');
+
+ // (start delay: 10s) (duration: 10s)
+ // before active
+ // |--------------------|--------------------|
+ // 0px 50px 100px (The scroller)
+ // 0% 100% (The iteration progress)
+
+ // Let animation be in before phase.
+ target.style.animationDelay = '10s';
+ target.style.animationDelayStart = '10s'; // crbug.com/1375994
+ assert_equals(getComputedStyle(target).translate, 'none');
+
+ await scrollTop(scroller, 50); // The animation enters active phase.
+ assert_equals(getComputedStyle(target).translate, '0px');
+
+ await scrollTop(scroller, 75); // The ieration progress is 50%.
+ assert_equals(getComputedStyle(target).translate, '50px');
+}, 'animation-delay with a positive value');
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ });
+
+ // active
+ // |--------------------|
+ // 0px 100px (The scroller)
+ // 50% 100% (The iteration progress)
+
+ await scrollTop(scroller, 20); // [0, 100].
+ target.style.animationDelay = '-5s';
+ target.style.animationDelayStart = '-5s'; // crbug.com/1375994
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).translate, '60px');
+}, 'animation-delay with a negative value');
+
+
+// ------------------------------
+// Test animation-fill-mode
+// ------------------------------
+
+promise_test(async t => {
+ let [target, scroller] = createTargetAndScroller(t);
+ await runAndWaitForFrameUpdate(() => {
+ target.style.animation = '10s linear anim';
+ target.style.animationTimeline = 'scroll(nearest)';
+ target.style.animationDelay = '10s';
+ target.style.animationDelayStart = '10s'; // crbug.com/1375994
+ });
+
+ await scrollTop(scroller, 25);
+ assert_equals(getComputedStyle(target).translate, 'none');
+
+ target.style.animationFillMode = 'backwards';
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).translate, '0px');
+}, 'animation-fill-mode');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/progress-based-animation-timeline.html b/testing/web-platform/tests/scroll-animations/css/progress-based-animation-timeline.html
new file mode 100644
index 0000000000..bbc60e3fbd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/progress-based-animation-timeline.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<title>CSS Animation using progress based timeline</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ main > div {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ main > div > div {
+ height: 200px;
+ }
+
+ @keyframes top {
+ from { top: 100px; }
+ to { top: 200px; }
+ }
+
+ #scroller1 {
+ scroll-timeline: --top_timeline;
+ }
+
+ #element {
+ animation-name: top;
+ animation-duration: 10s;
+ animation-timing-function: linear;
+ animation-timeline: --top_timeline;
+ position: absolute;
+ }
+ /* Ensure stable expectations if feature is not supported */
+ @supports not (animation-timeline:--foo) {
+ #element { animation-play-state: paused; }
+ }
+</style>
+<main>
+ <div id=scroller1>
+ <div></div>
+ <div id=element></div>
+ </div>
+</main>
+<script>
+ window.onload = async () => {
+ promise_test(async (t) => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ scroller1.scrollTop = 20;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(element).top, '120px');
+ }, 'progress based animation timeline works');
+ };
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/pseudo-on-scroller.html b/testing/web-platform/tests/scroll-animations/css/pseudo-on-scroller.html
new file mode 100644
index 0000000000..8dd49ce4d8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/pseudo-on-scroller.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Animating pseduo-element on scroller</title>
+</head>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style type="text/css">
+.scroller {
+ overflow: auto;
+ width: 100px;
+ height: 100px;
+ margin: 1em;
+ outline: 1px solid;
+ animation: bg linear;
+ animation-timeline: scroll(self inline);
+}
+.pseudo::before {
+ content: "";
+ display: block;
+ width: 200px;
+ height: 50px;
+ background: red;
+ animation: bg linear reverse;
+ animation-timeline: scroll(inline);
+}
+@keyframes bg {
+ from {
+ background: rgb(0, 255, 0);
+ }
+ to {
+ background: rgb(0, 0, 255);
+ }
+}
+</style>
+<body>
+ <div class="scroller pseudo"></div>
+ <div id="log"></div>
+</body>
+<script type="text/javascript">
+ 'use strict';
+
+ promise_test(async t => {
+ const scroller = document.querySelector('.scroller');
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(scroller).backgroundColor, 'rgb(0, 255, 0)');
+ assert_equals(getComputedStyle(scroller, ':before').backgroundColor,
+ 'rgb(0, 0, 255)');
+ }, `scroll nearest on pseudo-element attaches to parent scroll container`);
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset-ref.html
new file mode 100644
index 0000000000..8e6907860b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset-ref.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<style>
+
+#scroller {
+ overflow-y: auto;
+ height: 200px;
+ border: 2px solid green;
+ position: relative;
+ background: gray;
+}
+
+.spacer {
+ height: 1000px;
+}
+
+#align {
+ box-sizing: border-box;
+ width: 100%;
+ height: 50px;
+ background: rgba(0, 0, 200, 0.2);
+ color: white;
+ position: absolute;
+ border: 1px solid white;
+ transform: translateY(200px);
+ will-change: transform;
+}
+
+#marker {
+ width: 100%;
+ height: 50px;
+ background: #640;
+ position: absolute;
+ top: 350px;
+}
+
+</style>
+<div id="scroller">
+ <div id="align">TOP</div>
+ <div class="spacer"></div>
+ <div id="marker">BOTTOM</div>
+</div>
+<script>
+ scroller.scrollTo(0, 200);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset.html b/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset.html
new file mode 100644
index 0000000000..34ae52d479
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-animation-initial-offset.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>Composited scroll-linked animation with initial scroll offset</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/">
+<link rel="match" href="scroll-animation-initial-offset-ref.html">
+<style>
+
+#scroller {
+ overflow-y: auto;
+ height: 200px;
+ border: 2px solid green;
+ position: relative;
+ background: gray;
+}
+
+.spacer {
+ height: 1000px;
+}
+
+@keyframes anim {
+ 0% { transform: translateY(0); }
+ 100% { transform: translateY(800px); }
+}
+
+#align {
+ box-sizing: border-box;
+ width: 100%;
+ height: 50px;
+ background: rgba(0, 0, 200, 0.2);
+ color: white;
+ position: absolute;
+ border: 1px solid white;
+ animation: anim linear 10s;
+ animation-timeline: scroll();
+ will-change: transform;
+}
+
+#marker {
+ width: 100%;
+ height: 50px;
+ background: #640;
+ position: absolute;
+ top: 350px;
+}
+
+</style>
+<div id="scroller">
+ <div id="align">TOP</div>
+ <div class="spacer"></div>
+ <div id="marker">BOTTOM</div>
+</div>
+<script>
+
+ // Test that a scroll-linked animation of a composited property reacts
+ // correctly to a programmatic scroll early during the page load.
+ //
+ // The scroll offset will change before the animation is "started" on the
+ // compositor, so it needs to be able to handle a non-zero initial offset.
+ //
+ scroller.scrollTo(0, 200);
+
+</script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+
+ document.documentElement.addEventListener('TestRendered', async () => {
+ await waitForCompositorReady();
+ await waitForNextFrame();
+ await waitForNextFrame();
+ document.documentElement.classList.remove("reftest-wait");
+ }, { once: true });
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-computed.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-computed.html
new file mode 100644
index 0000000000..c942fb4093
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-computed.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-axis">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<style>
+ #outer { scroll-timeline-axis: inline; }
+ #target { scroll-timeline-axis: y; }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+test_computed_value('scroll-timeline-axis', 'initial', 'block');
+test_computed_value('scroll-timeline-axis', 'inherit', 'inline');
+test_computed_value('scroll-timeline-axis', 'unset', 'block');
+test_computed_value('scroll-timeline-axis', 'revert', 'block');
+test_computed_value('scroll-timeline-axis', 'block');
+test_computed_value('scroll-timeline-axis', 'inline');
+test_computed_value('scroll-timeline-axis', 'y');
+test_computed_value('scroll-timeline-axis', 'x');
+test_computed_value('scroll-timeline-axis', 'block, inline');
+test_computed_value('scroll-timeline-axis', 'inline, block');
+test_computed_value('scroll-timeline-axis', 'block, y, x, inline');
+test_computed_value('scroll-timeline-axis', 'inline, inline, inline, inline');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('scroll-timeline-axis'), -1);
+}, 'The scroll-timeline-axis property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('scroll-timeline-axis'), -1);
+}, 'The scroll-timeline-axis property shows up in CSSStyleDeclaration.cssText');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-parsing.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-parsing.html
new file mode 100644
index 0000000000..a9a760a54a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-parsing.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-axis">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<div id="target"></div>
+
+<script>
+
+test_valid_value('scroll-timeline-axis', 'initial');
+test_valid_value('scroll-timeline-axis', 'inherit');
+test_valid_value('scroll-timeline-axis', 'unset');
+test_valid_value('scroll-timeline-axis', 'revert');
+
+test_valid_value('scroll-timeline-axis', 'block');
+test_valid_value('scroll-timeline-axis', 'inline');
+test_valid_value('scroll-timeline-axis', 'y');
+test_valid_value('scroll-timeline-axis', 'x');
+test_valid_value('scroll-timeline-axis', 'block, inline');
+test_valid_value('scroll-timeline-axis', 'inline, block');
+test_valid_value('scroll-timeline-axis', 'block, y, x, inline');
+test_valid_value('scroll-timeline-axis', 'inline, inline, inline, inline');
+
+test_invalid_value('scroll-timeline-axis', 'abc');
+test_invalid_value('scroll-timeline-axis', '10px');
+test_invalid_value('scroll-timeline-axis', 'auto');
+test_invalid_value('scroll-timeline-axis', 'none');
+test_invalid_value('scroll-timeline-axis', 'block inline');
+test_invalid_value('scroll-timeline-axis', 'block / inline');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-writing-mode.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-writing-mode.html
new file mode 100644
index 0000000000..cb9a98dcd8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-axis-writing-mode.html
@@ -0,0 +1,143 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-axis">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ .contents {
+ height: 200px;
+ width: 200px;
+ }
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+ #timeline_initial_axis {
+ scroll-timeline: --timeline_initial_axis;
+ }
+ #timeline_y {
+ scroll-timeline: --timeline_y y;
+ }
+ #timeline_x {
+ scroll-timeline: --timeline_x x;
+ }
+ #timeline_block_in_horizontal {
+ scroll-timeline: --timeline_block_in_horizontal block;
+ }
+ #timeline_inline_in_horizontal {
+ scroll-timeline: --timeline_inline_in_horizontal inline;
+ }
+ #timeline_block_in_vertical {
+ scroll-timeline: --timeline_block_in_vertical block;
+ writing-mode: vertical-lr;
+ }
+ #timeline_inline_in_vertical {
+ scroll-timeline: --timeline_inline_in_vertical inline;
+ writing-mode: vertical-lr;
+ }
+ .target {
+ width: 0px;
+ animation-name: expand;
+ animation-duration: 10s;
+ animation-timing-function: linear;
+ position: absolute;
+ }
+ /* Ensure stable expectations if feature is not supported */
+ @supports not (animation-timeline:--foo) {
+ .target { animation-play-state: paused; }
+ }
+ #element_initial_axis { animation-timeline: --timeline_initial_axis; }
+ #element_y { animation-timeline: --timeline_y; }
+ #element_x { animation-timeline: --timeline_x; }
+ #element_block_in_horizontal { animation-timeline: --timeline_block_in_horizontal; }
+ #element_inline_in_horizontal { animation-timeline: --timeline_inline_in_horizontal; }
+ #element_block_in_vertical { animation-timeline: --timeline_block_in_vertical; }
+ #element_inline_in_vertical { animation-timeline: --timeline_inline_in_vertical; }
+</style>
+<div class=scroller id=timeline_initial_axis>
+ <div class=contents></div>
+ <div class=target id=element_initial_axis></div>
+</div>
+<div class=scroller id=timeline_y>
+ <div class=contents></div>
+ <div class=target id=element_y></div>
+</div>
+<div class=scroller id=timeline_x>
+ <div class=contents></div>
+ <div class=target id=element_x></div>
+</div>
+<div class=scroller id=timeline_block_in_horizontal>
+ <div class=contents></div>
+ <div class=target id=element_block_in_horizontal></div>
+</div>
+<div class=scroller id=timeline_inline_in_horizontal>
+ <div class=contents></div>
+ <div class=target id=element_inline_in_horizontal></div>
+</div>
+<div class=scroller id=timeline_block_in_vertical>
+ <div class=contents></div>
+ <div class=target id=element_block_in_vertical></div>
+</div>
+<div class=scroller id=timeline_inline_in_vertical>
+ <div class=contents></div>
+ <div class=target id=element_inline_in_vertical></div>
+</div>
+<script>
+ async function setScrollPositions() {
+ return runAndWaitForFrameUpdate(() => {
+ // Animations linked to vertical scroll-timelines are at 75% progress.
+ timeline_initial_axis.scrollTop = 75;
+ timeline_y.scrollTop = 75;
+ timeline_block_in_horizontal.scrollTop = 75;
+ timeline_inline_in_vertical.scrollTop = 75;
+ // Animations linked to horizontal scroll-timelines are at 25% progress.
+ timeline_x.scrollLeft = 25;
+ timeline_block_in_vertical.scrollLeft = 25;
+ timeline_inline_in_horizontal.scrollLeft = 25;
+ });
+ }
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(element_initial_axis).width, '175px');
+ }, 'Initial axis');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(element_y).width, '175px');
+ }, 'Vertical axis');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(element_x).width, '125px');
+ }, 'Horizontal axis');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(element_block_in_horizontal).width, '175px');
+ }, 'Block axis in horizontal writing-mode');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(element_inline_in_horizontal).width, '125px');
+ }, 'Inline axis in horizontal writing-mode');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(timeline_block_in_vertical).writingMode, 'vertical-lr');
+ assert_equals(getComputedStyle(element_block_in_vertical).width, '125px');
+ }, 'Block axis in vertical writing-mode');
+
+ promise_test(async (t) => {
+ await setScrollPositions();
+ assert_equals(getComputedStyle(timeline_inline_in_vertical).writingMode, 'vertical-lr');
+ assert_equals(getComputedStyle(element_inline_in_vertical).width, '175px');
+ }, 'Inline axis in vertical writing-mode');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe-ref.html
new file mode 100644
index 0000000000..1ab5646c8b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe-ref.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<title>Reference for default scroll() timeline</title>
+<iframe width="400" height="400" srcdoc='
+ <html>
+ <style>
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateY(100px);
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+ </style>
+ <script>
+ window.addEventListener("load", function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+ </script>
+ <div id="box"></div>
+ </html>
+'></iframe>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe.html
new file mode 100644
index 0000000000..dbcf5941a8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-iframe.html
@@ -0,0 +1,73 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The default scroll() timeline in the iframe</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the default scroll() timeline">
+<link rel="match" href="scroll-timeline-default-iframe-ref.html">
+
+<iframe id="target" width="400" height="400" srcdoc='
+ <html>
+ <style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+ </style>
+ <script src="/web-animations/testcommon.js"></script>
+ <script>
+ window.addEventListener("load", async function() {
+ const scroller = document.scrollingElement;
+
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ await waitForCompositorReady();
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ window.parent.postMessage("success", "*");
+ });
+ </script>
+ <body>
+ <div id="box"></div>
+ <div id="covered"></div>
+ </body>
+ </html>
+'></iframe>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ async function finishTest() {
+ await waitForCompositorReady();
+ await waitForNextFrame();
+ await waitForNextFrame();
+ document.documentElement.classList.remove("reftest-wait");
+ }
+ window.addEventListener("message", event => {
+ if (event.data == "success") {
+ finishTest();
+ }
+ }, false);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-quirks-mode.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-quirks-mode.html
new file mode 100644
index 0000000000..d2c28d86b6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-quirks-mode.html
@@ -0,0 +1,63 @@
+<html class="reftest-wait">
+<title>The default scroll() timeline in quirks mode</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the default scroll() timeline">
+<link rel="match" href="scroll-timeline-default-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+}, { once: true });
+
+async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the halfway point. Then advance to the next frame
+ // to pick up the new timeline time.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+}
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-ref.html
new file mode 100644
index 0000000000..cb3b60e4bd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-ref.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Reference for default scroll() timeline</title>
+<style>
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateY(100px);
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl-ref.html
new file mode 100644
index 0000000000..3c072829e6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl-ref.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Reference for default scroll() timeline with vertical-rl</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<style>
+ html {
+ min-block-size: 100%;
+ padding-block-end: 100px;
+ writing-mode: vertical-rl
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateX(-100px);
+ }
+
+ * {
+ margin-block: 0px;
+ }
+</style>
+
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = -0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl.html
new file mode 100644
index 0000000000..27e6ec196b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default-writing-mode-rl.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The default scroll() timeline with writing-mode:vertical-rl</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using
+ the default scroll() timeline with writing-mode:vertical-rl">
+<link rel="match" href="scroll-timeline-default-writing-mode-rl-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateX(0px); }
+ to { transform: translateX(-200px); }
+ }
+
+ html {
+ min-block-size: 100%;
+ padding-block-end: 100px;
+ writing-mode: vertical-rl;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-block: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = -0.5 * maxScroll;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default.html
new file mode 100644
index 0000000000..07eda33fd0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-default.html
@@ -0,0 +1,63 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>The default scroll() timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the default scroll() timeline">
+<link rel="match" href="scroll-timeline-default-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-document-scroller-quirks.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-document-scroller-quirks.html
new file mode 100644
index 0000000000..809a658a15
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-document-scroller-quirks.html
@@ -0,0 +1,36 @@
+<!-- Quirks mode -->
+<title>Tests the document scroller in quirks mode</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://bugs.chromium.org/p/chromium/issues/detail?id=1180575">
+<link rel="author" href="mailto:andruud@chromium.org">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/css/css-animations/support/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+ #element {
+ animation: anim forwards;
+ animation-timeline: scroll(root);
+ }
+ #spacer {
+ height: 200vh;
+ }
+</style>
+<div id=element></div>
+<div id=spacer></div>
+
+<script>
+'use strict';
+
+setup(assert_implements_animation_timeline);
+
+promise_test(async () => {
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(element).zIndex, "100");
+});
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-dynamic.tentative.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-dynamic.tentative.html
new file mode 100644
index 0000000000..d1f143c7c1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-dynamic.tentative.html
@@ -0,0 +1,267 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ main {
+ timeline-scope: --timeline;
+ }
+
+ main > div {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ main > div > div {
+ height: 200px;
+ }
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+ #element {
+ width: 0px;
+ height: 20px;
+ animation-name: expand;
+ /* Some of the tests in this file assume animations attached to the
+ DocumentTimeline are "stopped" without actually being paused.
+ Using 600s + steps(10, end) achieves this for one minute.*/
+ animation-duration: 600s;
+ animation-timing-function: steps(10, end);
+ }
+</style>
+<main id=main>
+ <div id=scroller1 class=scroller>
+ <div></div>
+ </div>
+ <div id=scroller2 class=scroller>
+ <div></div>
+ </div>
+ <div id=container></div>
+</main>
+<script>
+ // Force layout of scrollers.
+ scroller1.offsetTop;
+ scroller2.offsetTop;
+
+ // Note the steps(10, end) timing function and height:100px. (10px scroll
+ // resolution).
+ scroller1.scrollTop = 20;
+ scroller2.scrollTop = 40;
+
+ function insertElement() {
+ let element = document.createElement('div');
+ element.id = 'element';
+ container.append(element);
+ return element;
+ }
+
+ // Runs a test with dynamically added/removed elements or CSS rules.
+ // Each test is instantiated twice: once for the initial style resolve where
+ // the DOM change was effectuated, and once after scrolling.
+ function dynamic_rule_test(func, description) {
+ // assert_width is an async function which verifies that the computed value
+ // of 'width' is as expected.
+ const instantiate = (assert_width, description) => {
+ promise_test(async (t) => {
+ try {
+ await func(t, assert_width);
+ } finally {
+ while (container.firstChild)
+ container.firstChild.remove();
+ main.style = '';
+ scroller1.style = '';
+ scroller2.style = '';
+ }
+ }, description);
+ };
+
+ // Verify that the computed style is as expected after a full frame update
+ // following the rule change took place.
+ instantiate(async (element, expected) => {
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(element).width, expected);
+ }, description + ' [immediate]');
+
+ // Verify the computed style after scrolling a bit.
+ instantiate(async (element, expected) => {
+ await waitForNextFrame();
+ scroller1.scrollTop = scroller1.scrollTop + 10;
+ scroller2.scrollTop = scroller2.scrollTop + 10;
+ await waitForNextFrame();
+ scroller1.scrollTop = scroller1.scrollTop - 10;
+ scroller2.scrollTop = scroller2.scrollTop - 10;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(element).width, expected);
+ }, description + ' [scroll]');
+ }
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ // This element initially has a DocumentTimeline.
+ await assert_width(element, '100px');
+
+ // Switch to scroll timeline.
+ scroller1.style.scrollTimelineName = '--timeline';
+ element.style.animationTimeline = '--timeline';
+ await assert_width(element, '120px');
+
+ // Switching from ScrollTimeline -> DocumentTimeline should preserve
+ // current time.
+ scroller1.style = '';
+ element.style = '';
+ await assert_width(element, '120px');
+ }, 'Switching between document and scroll timelines');
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ // Flush style and create the animation with play pending.
+ getComputedStyle(element).animation;
+
+ let anim = element.getAnimations()[0];
+ assert_true(anim.pending, "The animation is in play pending");
+
+ // Switch to scroll timeline for a pending animation.
+ scroller1.style.scrollTimelineName = '--timeline';
+ element.style.animationTimeline = '--timeline';
+
+ await anim.ready;
+ assert_false(anim.pending, "The animation is not pending");
+
+ await assert_width(element, '120px');
+ }, 'Switching pending animation from document to scroll timelines');
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ // Note: #scroller1 is at 20%, and #scroller2 is at 40%.
+ scroller1.style.scrollTimelineName = '--timeline1';
+ scroller2.style.scrollTimelineName = '--timeline2';
+ main.style.timelineScope = "--timeline1, --timeline2";
+
+ await assert_width(element, '100px');
+
+ element.style.animationTimeline = '--timeline1';
+ await assert_width(element, '120px');
+
+ element.style.animationTimeline = '--timeline2';
+ await assert_width(element, '140px');
+
+ element.style.animationTimeline = '--timeline1';
+ await assert_width(element, '120px');
+
+ // Switching from ScrollTimeline -> DocumentTimeline should preserve
+ // current time.
+ element.style.animationTimeline = '';
+ await assert_width(element, '120px');
+
+ }, 'Changing computed value of animation-timeline changes effective timeline');
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ scroller1.style.scrollTimelineName = '--timeline';
+
+ // DocumentTimeline applies by default.
+ await assert_width(element, '100px');
+
+ // Wait for the animation to be ready so that we a start time and no hold
+ // time.
+ await element.getAnimations()[0].ready;
+
+ // DocumentTimeline -> none
+ element.style.animationTimeline = '--none';
+ await assert_width(element, '0px');
+
+ // none -> DocumentTimeline
+ element.style.animationTimeline = '';
+ await assert_width(element, '100px');
+
+ // DocumentTimeline -> ScrollTimeline
+ element.style.animationTimeline = '--timeline';
+ await assert_width(element, '120px');
+
+ // ScrollTimeline -> none
+ element.style.animationTimeline = '--none';
+ await assert_width(element, '120px');
+
+ // none -> ScrollTimeline
+ element.style.animationTimeline = '--timeline';
+ await assert_width(element, '120px');
+ }, 'Changing to/from animation-timeline:none');
+
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ element.style.animationDirection = 'reverse';
+ element.style.animationTimeline = '--timeline';
+
+ // Inactive animation-timeline. Animation is inactive.
+ await assert_width(element, '0px');
+
+ // Note: #scroller1 is at 20%.
+ scroller1.style.scrollTimelineName = '--timeline';
+ await assert_width(element, '180px');
+
+ // Note: #scroller2 is at 40%.
+ scroller1.style.scrollTimelineName = '';
+ scroller2.style.scrollTimelineName = '--timeline';
+ await assert_width(element, '160px');
+
+ element.style.animationDirection = '';
+ await assert_width(element, '140px');
+ }, 'Reverse animation direction');
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+ element.style.animationTimeline = '--timeline';
+
+ // Inactive animation-timeline. Animation effect is inactive.
+ await assert_width(element, '0px');
+
+ // Note: #scroller1 is at 20%.
+ scroller1.style.scrollTimelineName = '--timeline';
+ await assert_width(element, '120px');
+
+ element.style.animationPlayState = 'paused';
+
+ // We should still be at the same position after pausing.
+ await assert_width(element, '120px');
+
+ // Note: #scroller2 is at 40%.
+ scroller1.style.scrollTimelineName = '';
+ scroller2.style.scrollTimelineName = '--timeline';
+
+ // Should be at the same position until we unpause.
+ await assert_width(element, '120px');
+
+ // Unpausing should synchronize to the scroll position.
+ element.style.animationPlayState = '';
+ await assert_width(element, '140px');
+ }, 'Change to timeline attachment while paused');
+
+ dynamic_rule_test(async (t, assert_width) => {
+ let element = insertElement();
+
+ // Note: #scroller1 is at 20%.
+ scroller1.style.scrollTimelineName = '--timeline';
+
+ await assert_width(element, '100px');
+
+ element.style.animationTimeline = '--timeline';
+ element.style.animationPlayState = 'paused';
+
+ // Pausing should happen before the timeline is modified. (Tentative).
+ // https://github.com/w3c/csswg-drafts/issues/5653
+ await assert_width(element, '100px');
+
+ element.style.animationPlayState = 'running';
+ await assert_width(element, '120px');
+ }, 'Switching timelines and pausing at the same time');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed-ref.html
new file mode 100644
index 0000000000..ea7628ac72
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed-ref.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>Reference for the default scroll() timeline</title>
+<style>
+ html {
+ min-height: 100%;
+ padding-bottom: 50px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateY(100px);
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed.html
new file mode 100644
index 0000000000..fb0eb8aa17
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-frame-size-changed.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>The default scroll() timeline when the frame size changed</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using
+ the default scroll() timeline and update the
+ frame size">
+<link rel="match" href="scroll-timeline-frame-size-changed-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateY(0px); }
+ to { transform: translateY(200px); }
+ }
+
+ html {
+ min-height: 100%;
+ padding-bottom: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll();
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ * {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the 25% point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ await waitForNextFrame();
+
+ // Update scroll range to make the current position become 50% point.
+ scroller.style.paddingBottom = "50px";
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-in-container-query.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-in-container-query.html
new file mode 100644
index 0000000000..742c35b258
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-in-container-query.html
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<title>scroll-timeline and container queries</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-shorthand">
+<link rel="help" src="https://drafts.csswg.org/css-contain-3/#container-queries">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ #outer {
+ height: 100px;
+ width: 150px;
+ }
+
+ #container {
+ container-type: size;
+ }
+
+ #scroller {
+ overflow: auto;
+ width: auto;
+ height: 100px;
+ }
+
+ #scroller > div {
+ height: 200px;
+ }
+
+ /* This does not apply initially. */
+ @container (width > 200px) {
+ #scroller {
+ scroll-timeline: --timeline;
+ }
+ }
+
+ @keyframes recolor {
+ from { background-color: rgb(100, 100, 100); }
+ to { background-color: rgb(200, 200, 200); }
+ }
+
+ #element {
+ height: 10px;
+ width: 10px;
+ animation: recolor 10s linear;
+ animation-timeline: --timeline;
+ background-color: rgb(0, 0, 0);
+ }
+</style>
+<div id=outer>
+ <div id=container>
+ <div id=scroller>
+ <div></div>
+ <div id=element></div>
+ </div>
+ </div>
+</div>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ promise_test(async (t) => {
+ element.offsetTop;
+ scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
+ await waitForNextFrame();
+ // Unknown timeline, time held at zero.
+ assert_equals(getComputedStyle(element).backgroundColor, 'rgb(100, 100, 100)');
+ // This causes the timeline to be created.
+ outer.style.width = '250px';
+ // Check value with getComputedStyle immediately, which is the unanimated
+ // value since the scroll timeline is inactive before the next frame.
+ assert_equals(getComputedStyle(element).backgroundColor, 'rgb(0, 0, 0)');
+ // Also check value after one frame.
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(element).backgroundColor, 'rgb(150, 150, 150)');
+ }, 'Timeline appearing via container queries');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inactive.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inactive.html
new file mode 100644
index 0000000000..eedc8e3958
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inactive.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+ .scroller {
+ overflow: scroll;
+ width: 100px;
+ height: 100px;
+ }
+</style>
+<main id=main></main>
+<script>
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ main.offsetTop;
+ }
+</script>
+
+<template id=basic>
+ <style>
+ #timeline {
+ scroll-timeline: --timeline;
+ }
+ #element {
+ width: 0px;
+ animation: expand 10s linear paused;
+ animation-timeline: --timeline;
+ }
+ </style>
+ <div id="container">
+ <div id=timeline class=scroller><div>
+ <div id=element></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, basic);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(element).width, '0px');
+ }, 'Animation does not apply when the timeline is inactive because there is ' +
+ 'not scroll range');
+</script>
+
+<template id=dynamically_change_range>
+ <style>
+ #contents {
+ height: 200px;
+ }
+ #element {
+ width: 0px;
+ animation: expand 10s linear paused;
+ animation-timeline: --timeline;
+ }
+ </style>
+ <div id="container">
+ <div id=element></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, dynamically_change_range);
+ await waitForNextFrame();
+
+ let div = document.createElement('div');
+ div.setAttribute('class', 'scroller');
+ div.style.scrollTimeline = 'timeline';
+ div.innerHTML = '<div id=contents></div>';
+ try {
+ container.insertBefore(div, element);
+
+ // The source has no layout box at the time the scroll timeline is created.
+ assert_equals(getComputedStyle(element).width, '0px');
+ scroller.offsetTop; // Ensure a layout box for the scroller.
+ // Wait for an update to the timeline state:
+ await waitForNextFrame();
+ // The timeline should now be active, and the animation should apply:
+ assert_equals(getComputedStyle(element).width, '100px');
+ } finally {
+ div.remove();
+ }
+ }, 'Animation does not apply when timeline is initially inactive');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation-ref.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation-ref.html
new file mode 100644
index 0000000000..7b87b1db39
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation-ref.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<title>Reference for scroll timeline with inline orientation and root scroller</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<style>
+ html {
+ min-width: 100%;
+ padding-right: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translateX(100px);
+ }
+
+ * {
+ margin-left: 0px;
+ margin-right: 0px;
+ }
+</style>
+
+<div id="box"></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation.html
new file mode 100644
index 0000000000..52b7427f2d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-inline-orientation.html
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML>
+<html class="reftest-wait">
+<title>Scroll timeline with inline orientation and root scroller</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#descdef-scroll-timeline-orientation">
+<link rel="help" href="https://drafts.csswg.org/css-animations-2/#animation-timeline">
+<meta name="assert" content="CSS animation correctly updates values when using the inline orientation">
+<link rel="match" href="scroll-timeline-inline-orientation-ref.html">
+
+<style>
+ @keyframes update {
+ from { transform: translateX(0px); }
+ to { transform: translateX(200px); }
+ }
+
+ html {
+ min-width: 100%;
+ padding-right: 100px;
+ font-size: 0;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ animation: update 1s linear;
+ animation-timeline: scroll(inline root);
+ display: inline-block;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ display: inline-block;
+ }
+
+ * {
+ margin-left: 0px;
+ margin-right: 0px;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ const scroller = document.scrollingElement;
+
+ await waitForCompositorReady();
+
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = 0.5 * maxScroll;
+
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-multi-pass.tentative.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-multi-pass.tentative.html
new file mode 100644
index 0000000000..403316ead0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-multi-pass.tentative.html
@@ -0,0 +1,110 @@
+<!DOCTYPE html>
+<title>ScrollTimelines may trigger multiple style/layout passes</title>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/5261">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes expand_width {
+ from { width: 100px; }
+ to { width: 100px; }
+ }
+ @keyframes expand_height {
+ from { height: 100px; }
+ to { height: 100px; }
+ }
+ main {
+ height: 0px;
+ overflow: hidden;
+ timeline-scope: --timeline1, --timeline2;
+ }
+ .scroller {
+ height: 100px;
+ overflow: scroll;
+ }
+ .scroller > div {
+ height: 200px;
+ }
+ #element1 {
+ width: 1px;
+ animation: expand_width 10s;
+ animation-timeline: --timeline1;
+ }
+ #element2 {
+ height: 1px;
+ animation: expand_height 10s;
+ animation-timeline: --timeline2;
+ }
+</style>
+<main id=main>
+ <div id=element1></div>
+ <div>
+ <div id=element2></div>
+ </div>
+</main>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function insertScroller(timeline_name) {
+ let scroller = document.createElement('div');
+ scroller.classList.add('scroller');
+ scroller.style.scrollTimeline = timeline_name;
+ scroller.append(document.createElement('div'));
+ main.insertBefore(scroller, element1);
+ }
+
+ promise_test(async () => {
+ await waitForNextFrame();
+
+ let events1 = [];
+ let events2 = [];
+
+ insertScroller('--timeline1');
+ // Even though the scroller was just inserted into the DOM, |timeline1|
+ // remains inactive until the next frame.
+ //
+ // https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles
+ assert_equals(getComputedStyle(element1).width, '1px');
+ (new ResizeObserver(entries => {
+ events1.push(entries);
+ insertScroller('--timeline2');
+ assert_equals(getComputedStyle(element2).height, '1px');
+ })).observe(element1);
+
+ (new ResizeObserver(entries => {
+ events2.push(entries);
+ })).observe(element2);
+
+ await waitForNextFrame();
+
+ // According to the basic rules of the spec [1], the timeline is
+ // inactive at the time the resize observer event was delivered, because
+ // #scroller1 did not have a layout box at the time style recalc for
+ // #element1 happened.
+ //
+ // However, an additional style/layout pass should take place
+ // (before resize observer deliveries) if we detect new ScrollTimelines
+ // in this situation, hence we ultimately do expect the animation to
+ // apply [2].
+ //
+ // [1] https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles
+ // [2] https://github.com/w3c/csswg-drafts/issues/5261
+ assert_equals(events1.length, 1);
+ assert_equals(events1[0].length, 1);
+ assert_equals(events1[0][0].contentBoxSize.length, 1);
+ assert_equals(events1[0][0].contentBoxSize[0].inlineSize, 100);
+
+ // ScrollTimelines created during the ResizeObserver should remain
+ // inactive during the frame they're created, so the ResizeObserver
+ // event should not reflect the animated value.
+ assert_equals(events2.length, 1);
+ assert_equals(events2[0].length, 1);
+ assert_equals(events2[0][0].contentBoxSize.length, 1);
+ assert_equals(events2[0][0].contentBoxSize[0].blockSize, 1);
+
+ assert_equals(getComputedStyle(element1).width, '100px');
+ assert_equals(getComputedStyle(element2).height, '100px');
+ }, 'Multiple style/layout passes occur when necessary');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-computed.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-computed.html
new file mode 100644
index 0000000000..b803ee8212
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-computed.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-name">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+</head>
+<style>
+ #outer { scroll-timeline-name: --foo; }
+ #target { scroll-timeline-name: --bar; }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+test_computed_value('scroll-timeline-name', 'initial', 'none');
+test_computed_value('scroll-timeline-name', 'inherit', '--foo');
+test_computed_value('scroll-timeline-name', 'unset', 'none');
+test_computed_value('scroll-timeline-name', 'revert', 'none');
+test_computed_value('scroll-timeline-name', 'none');
+test_computed_value('scroll-timeline-name', '--foo');
+test_computed_value('scroll-timeline-name', '--foo, --bar');
+test_computed_value('scroll-timeline-name', '--bar, --foo');
+test_computed_value('scroll-timeline-name', '--a, --b, --c, --D, --e');
+test_computed_value('scroll-timeline-name', 'none, none');
+test_computed_value('scroll-timeline-name', '--a, --b, --c, none, --d, --e');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('scroll-timeline-name'), -1);
+}, 'The scroll-timeline-name property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('scroll-timeline-name'), -1);
+}, 'The scroll-timeline-name property shows up in CSSStyleDeclaration.cssText');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-parsing.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-parsing.html
new file mode 100644
index 0000000000..d38b9640af
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-parsing.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-name">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<div id="target"></div>
+<script>
+
+test_valid_value('scroll-timeline-name', 'initial');
+test_valid_value('scroll-timeline-name', 'inherit');
+test_valid_value('scroll-timeline-name', 'unset');
+test_valid_value('scroll-timeline-name', 'revert');
+
+test_valid_value('scroll-timeline-name', 'none');
+test_valid_value('scroll-timeline-name', '--abc');
+test_valid_value('scroll-timeline-name', ' --abc', '--abc');
+test_valid_value('scroll-timeline-name', '--aBc');
+test_valid_value('scroll-timeline-name', '--foo, --bar');
+test_valid_value('scroll-timeline-name', '--bar, --foo');
+test_valid_value('scroll-timeline-name', 'none, none');
+test_valid_value('scroll-timeline-name', '--a, none, --b');
+
+test_invalid_value('scroll-timeline-name', 'auto');
+test_invalid_value('scroll-timeline-name', 'abc');
+test_invalid_value('scroll-timeline-name', 'default');
+test_invalid_value('scroll-timeline-name', '10px');
+test_invalid_value('scroll-timeline-name', 'foo bar');
+test_invalid_value('scroll-timeline-name', '"foo" "bar"');
+test_invalid_value('scroll-timeline-name', 'rgb(1, 2, 3)');
+test_invalid_value('scroll-timeline-name', '#fefefe');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-shadow.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-shadow.html
new file mode 100644
index 0000000000..a535b2a44f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-name-shadow.html
@@ -0,0 +1,180 @@
+<!DOCTYPE html>
+<title>scroll-timeline-name and tree-scoped references</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines-named">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/8135">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/8192">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+
+<main id=main></main>
+<script>
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ main.offsetTop;
+ }
+</script>
+<style>
+ @keyframes anim {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+</style>
+
+<template id=scroll_timeline_host>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ main > .scroller {
+ scroll-timeline: --timeline x;
+ }
+ </style>
+ <div class=scroller>
+ <div class=scroller>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ :host {
+ scroll-timeline: --timeline y;
+ }
+ </style>
+ <slot></slot>
+ </template>
+ <div class=target></div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, scroll_timeline_host);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Outer animation can see scroll timeline defined by :host');
+</script>
+
+
+<template id=scroll_timeline_slotted>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ .host {
+ scroll-timeline: --timeline x;
+ }
+ </style>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ ::slotted(.scroller) {
+ scroll-timeline: --timeline y;
+ }
+ </style>
+ <slot></slot>
+ </template>
+ <div class=scroller>
+ <div class=target></div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, scroll_timeline_slotted);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Outer animation can see scroll timeline defined by ::slotted');
+</script>
+
+
+<template id=scroll_timeline_part>
+ <style>
+ .host {
+ scroll-timeline: --timeline y;
+ }
+ .host::part(foo) {
+ scroll-timeline: --timeline x;
+ }
+ </style>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ /* Not using 'anim' at document scope, due to https://crbug.com/1334534 */
+ @keyframes anim2 {
+ from { z-index: 100; background-color: green; }
+ to { z-index: 100; background-color: green; }
+ }
+ .target {
+ animation: anim2 10s linear;
+ animation-timeline: --timeline;
+ }
+ </style>
+ <div part=foo>
+ <div class=target></div>
+ </div>
+ </template>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, scroll_timeline_part);
+ let target = main.querySelector('.host').shadowRoot.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'x');
+ }, 'Inner animation can see scroll timeline defined by ::part');
+</script>
+
+
+<template id=scroll_timeline_shadow>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ .host {
+ scroll-timeline: --timeline x;
+ }
+ </style>
+ <div class=scroller>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ div {
+ scroll-timeline: --timeline y;
+ }
+ </style>
+ <div>
+ <slot></slot>
+ </div>
+ </template>
+ <div class=target></div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, scroll_timeline_shadow);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Slotted element can see scroll timeline within the shadow');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-dirty.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-dirty.html
new file mode 100644
index 0000000000..1a79c9bb22
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-dirty.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<title>Unrelated style mutation does not affect anonymous timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/css/css-animations/support/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+ #scroller {
+ overflow: auto;
+ width: 100px;
+ height: 100px;
+ }
+ #element {
+ animation: anim forwards;
+ animation-timeline: scroll();
+ }
+ #spacer {
+ height: 200px;
+ }
+</style>
+<div id=scroller>
+ <div id=element></div>
+ <div id=spacer></div>
+</div>
+
+<script>
+setup(assert_implements_animation_timeline);
+
+promise_test(async () => {
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(element).zIndex, '100');
+ // Unrelated style mutation does not change the effect value:
+ element.style.color = 'green';
+ assert_equals(getComputedStyle(element).zIndex, '100');
+});
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html
new file mode 100644
index 0000000000..57a1a94712
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<title>The animation-timeline: scroll-timeline-name</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/rewrite#scroll-timelines-named">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/6674">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes grow-progress {
+ to { width: 300px; }
+ }
+
+ .scrollcontainer {
+ overflow-x: scroll;
+ display: flex;
+ flex-direction: row;
+ scroll-timeline: --timeline inline;
+ }
+
+ .progress {
+ position: absolute;
+ z-index: 10;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 1em;
+ background: red;
+ animation: auto grow-progress linear forwards;
+ animation-timeline: scroll(inline nearest);
+ }
+
+ .entry {
+ min-height: 90vh;
+ min-width: 100vw;
+ }
+
+ .entry:nth-child(even) {
+ background-color: #eee;
+ }
+
+ .entry:nth-child(odd) {
+ background-color: #ddd;
+ }
+</style>
+<body>
+ <div class = "scrollcontainer" id = "scroller">
+ <div class = "progress" id = "target"></div>
+ <div class = "entry"></div>
+ <div class = "entry"></div>
+ <div class = "entry"></div>
+ </div>
+</body>
+<script>
+"use strict";
+
+setup(assert_implements_animation_timeline);
+
+promise_test(async t => {
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth;
+ scroller.scrollLeft = maxScroll;
+
+ // Advance to next frame so that scroll-timeline has a valid time.
+ await waitForNextFrame();
+
+ // Flex container is not position relative and therefore not the container for
+ // the progress element.
+ assert_equals(getComputedStyle(target).width, "100px");
+
+ // Once the scroller is position relative, it becomes the container block for
+ // the progress element.
+ scroller.style.position = 'relative';
+ await waitForNextFrame();
+
+ assert_equals(getComputedStyle(target).width, "300px");
+}, 'Resolving scroll(nearest) for an absolutely positioned element');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-paused-animations.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-paused-animations.html
new file mode 100644
index 0000000000..54518a5e87
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-paused-animations.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Scroll timeline with paused animations</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/css-animations/#animation-play-state">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/css/css-animations/support/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+
+ .fill-vh {
+ width: 100px;
+ height: 100vh;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+setup(assert_implements_animation_timeline);
+
+async function resetScrollPosition() {
+ // Reset to 0 so we don't affect following tests.
+ document.scrollingElement.scrollTop = 0;
+ return waitForNextFrame();
+}
+
+promise_test(async t => {
+ const div = addDiv(t, { style: 'width: 50px; height: 100px;' });
+ const filling = addDiv(t, { class: 'fill-vh' });
+ const scroller = document.scrollingElement;
+ t.add_cleanup(resetScrollPosition);
+
+ div.style.animation = 'anim 100s linear paused';
+ div.style.animationTimeline = 'scroll(root)';
+ await waitForCSSScrollTimelineStyle();
+
+ const anim = div.getAnimations()[0];
+ await anim.ready;
+ assert_percents_equal(anim.currentTime, 0, 'timeline time reset');
+ assert_equals(getComputedStyle(div).width, '100px');
+
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(div).width, '100px');
+
+}, 'Test that the scroll animation is paused');
+
+promise_test(async t => {
+ const div = addDiv(t, { style: 'width: 50px; height: 100px;' });
+ const filling = addDiv(t, { class: 'fill-vh' });
+ const scroller = document.scrollingElement;
+ await waitForNextFrame();
+
+ div.style.animation = 'anim 100s linear forwards';
+ div.style.animationTimeline = 'scroll(root)';
+ await waitForCSSScrollTimelineStyle();
+
+ const anim = div.getAnimations()[0];
+ await anim.ready;
+ assert_percents_equal(anim.currentTime, 0, 'timeline time reset');
+ assert_equals(getComputedStyle(div).width, '100px');
+
+ await waitForNextFrame();
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(div).width, '200px');
+
+ div.style.animationPlayState = 'paused';
+ assert_equals(anim.playState, 'paused');
+ assert_equals(getComputedStyle(div).width, '200px',
+ 'Current time preserved when pause-pending.');
+ assert_true(anim.pending,
+ 'Pending state after changing animationPlayState');
+ await anim.ready;
+ assert_equals(getComputedStyle(div).width, '200px',
+ 'Current time preserved when paused.');
+ assert_percents_equal(anim.timeline.currentTime, 100);
+ document.scrollingElement.scrollTop = 0;
+ await waitForNextFrame();
+ assert_percents_equal(anim.timeline.currentTime, 0);
+ assert_equals(getComputedStyle(div).width, '200px');
+}, 'Test that the scroll animation is paused by updating animation-play-state');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-range-animation.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-range-animation.html
new file mode 100644
index 0000000000..df087da6e2
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-range-animation.html
@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<title>Scroll timelines and animation attachment ranges</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#animation-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; background-color: skyblue;}
+ to { z-index: 100; background-color: coral; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ width: 200px;
+ height: 200px;
+ }
+ #scroller > div {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ }
+ #target {
+ font-size: 10px;
+ background-color: green;
+ z-index: -1;
+ }
+</style>
+<main id=main>
+</main>
+
+<template id=template_without_scope>
+ <div id=scroller class=timeline>
+ <div id=target></div>
+ </div>
+</template>
+
+<template id=template_with_scope>
+ <div id=scope>
+ <div id=target></div>
+ <div id=scroller class=timeline>
+ <div></div>
+ </div>
+ </div>
+</template>
+
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function waitForAnimationReady(target) {
+ await waitForNextFrame();
+ await Promise.all(target.getAnimations().map(x => x.ready));
+ }
+ async function assertValueAt(scroller, target, args) {
+ await waitForAnimationReady(target);
+ await scrollTop(scroller, args.scrollTop);
+ assert_equals(getComputedStyle(target).zIndex, args.expected.toString());
+ }
+ function test_animation_range(options, template, desc_suffix) {
+ if (template === undefined)
+ template = template_without_scope;
+ if (desc_suffix === undefined)
+ desc_suffix = '';
+
+ promise_test(async (t) => {
+ inflate(t, template);
+ let scroller = main.querySelector('#scroller');
+ let target = main.querySelector('#target');
+ let timeline = main.querySelector('.timeline');
+ let scope = main.querySelector('#scope');
+ let maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ if (scope != null) {
+ scope.style.timelineScope = '--t1';
+ }
+
+ timeline.style.scrollTimeline = '--t1';
+ target.style.animation = 'anim auto linear';
+ target.style.animationTimeline = '--t1';
+ target.style.animationRangeStart = options.rangeStart;
+ target.style.animationRangeEnd = options.rangeEnd;
+
+ // Accommodates floating point precision errors at the endpoints.
+ target.style.animationFillMode = 'both';
+
+ // 0%
+ await assertValueAt(scroller, target,
+ { scrollTop: options.startOffset, expected: 0 });
+ // 50%
+ await assertValueAt(scroller, target,
+ { scrollTop: (options.startOffset + options.endOffset) / 2, expected: 50 });
+ // 100%
+ await assertValueAt(scroller, target,
+ { scrollTop: options.endOffset, expected: 100 });
+
+ // Test before/after phases (need to clear the fill mode for that).
+ target.style.animationFillMode = 'initial';
+ let before_scroll = options.startOffset - 10;
+ if (before_scroll >= 0) {
+ await assertValueAt(scroller, target,
+ { scrollTop: options.startOffset - 10, expected: -1 });
+ }
+ let after_scroll = options.startOffset + 10;
+ if (after_scroll <= scroller.maxmum) {
+ await assertValueAt(scroller, target,
+ { scrollTop: options.endOffset + 10, expected: -1 });
+ }
+ // Check 50% again without fill mode.
+ await assertValueAt(scroller, target,
+ { scrollTop: (options.startOffset + options.endOffset) / 2, expected: 50 });
+
+ }, `Animation with ranges [${options.rangeStart}, ${options.rangeEnd}] ${desc_suffix}`.trim());
+ }
+
+ test_animation_range({
+ rangeStart: 'initial',
+ rangeEnd: 'initial',
+ startOffset: 0,
+ endOffset: 1500
+ });
+
+ test_animation_range({
+ rangeStart: '0%',
+ rangeEnd: '100%',
+ startOffset: 0,
+ endOffset: 1500
+ });
+
+ test_animation_range({
+ rangeStart: '10%',
+ rangeEnd: '100%',
+ startOffset: 150,
+ endOffset: 1500
+ });
+
+ test_animation_range({
+ rangeStart: '0%',
+ rangeEnd: '50%',
+ startOffset: 0,
+ endOffset: 750
+ });
+
+ test_animation_range({
+ rangeStart: '10%',
+ rangeEnd: '50%',
+ startOffset: 150,
+ endOffset: 750
+ });
+
+ test_animation_range({
+ rangeStart: '150px',
+ rangeEnd: '75em',
+ startOffset: 150,
+ endOffset: 750
+ });
+
+ test_animation_range({
+ rangeStart: 'calc(1% + 135px)',
+ rangeEnd: 'calc(70em + 50px)',
+ startOffset: 150,
+ endOffset: 750
+ });
+
+ // Test animation-range via timeline-scope.
+ test_animation_range({
+ rangeStart: 'calc(1% + 135px)',
+ rangeEnd: 'calc(70em + 50px)',
+ startOffset: 150,
+ endOffset: 750
+ }, template_with_scope, '(scoped)');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-responsiveness-from-endpoint.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-responsiveness-from-endpoint.html
new file mode 100644
index 0000000000..71d3699077
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-responsiveness-from-endpoint.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Root-scrolling timeline with animation moving from end point</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<link rel="help" href="https://drafts.csswg.org/web-animations/#update-an-animations-finished-state">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/css-animations/support/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+
+<style>
+ @keyframes anim {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+
+ .fill-vh {
+ width: 100px;
+ height: 100vh;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+setup(assert_implements_animation_timeline);
+
+promise_test(async t => {
+ const div = addDiv(t, { style: 'width: 50px; height: 100px;' });
+ const filling = addDiv(t, { class: 'fill-vh' });
+ const scroller = document.scrollingElement;
+ scroller.scrollTop = 0;
+ await waitForNextFrame();
+
+ div.style.animation = 'anim 100s linear';
+ div.style.animationTimeline = 'scroll(root)';
+ await waitForCSSScrollTimelineStyle();
+
+ const anim = div.getAnimations()[0];
+ await anim.ready;
+ assert_percents_equal(anim.timeline.currentTime, 0,
+ 'Timeline time when animation is ready');
+ assert_equals(getComputedStyle(div).width, '100px',
+ 'Width at animation start');
+
+ await waitForNextFrame();
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(div).width, '200px',
+ 'Width at scroll limit');
+
+ document.scrollingElement.scrollTop = 0;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(div).width, '100px',
+ 'Width after reset to scroll top');
+}, 'Test that the scroll animation is still responsive after moving from 100%');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-root-dirty.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-root-dirty.html
new file mode 100644
index 0000000000..1c0b73ab45
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-root-dirty.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<title>Unrelated style mutation does not affect anonymous timeline (root)</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-notation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/css/css-animations/support/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+ #element {
+ animation: anim forwards;
+ animation-timeline: scroll(root);
+ }
+ #spacer {
+ height: 200vh;
+ }
+</style>
+<div id=element></div>
+<div id=spacer></div>
+
+<script>
+setup(assert_implements_animation_timeline);
+
+promise_test(async () => {
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(element).zIndex, '100');
+ // Unrelated style mutation does not change the effect value:
+ element.style.color = 'green';
+ assert_equals(getComputedStyle(element).zIndex, '100');
+});
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-sampling.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-sampling.html
new file mode 100644
index 0000000000..a67f3b94e4
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-sampling.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#avoiding-cycles">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ #scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ scroll-timeline: --timeline;
+ }
+ #contents {
+ height: 200px;
+ }
+ @keyframes expand {
+ from { width: 100px; }
+ to { width: 200px; }
+ }
+ #element {
+ width: 0px;
+ }
+ #element.animate {
+ animation: expand 10s linear;
+ animation-timeline: --timeline;
+ }
+ /* Ensure stable expectations if feature is not supported */
+ @supports not (animation-timeline:--foo) {
+ #element { animation-play-state: paused; }
+ }
+</style>
+<div id=scroller>
+ <div id=contents></div>
+ <div id=element></div>
+</div>
+<script>
+ promise_test(async (t) => {
+ assert_equals(getComputedStyle(element).width, '0px');
+ await runAndWaitForFrameUpdate(() => {
+ element.classList.add('animate');
+ });
+ assert_equals(getComputedStyle(element).width, '100px');
+
+ scroller.scrollTop = 50;
+ // Scrolling position should not yet be reflected in the animation,
+ // since the new scroll position has not yet been sampled.
+ assert_equals(getComputedStyle(element).width, '100px');
+
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(element).width, '150px');
+ }, 'Scroll position is sampled once per frame');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-shorthand.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-shorthand.html
new file mode 100644
index 0000000000..722a8a1f4d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-shorthand.html
@@ -0,0 +1,109 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-shorthand">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<script src="/css/support/shorthand-testcommon.js"></script>
+<div id="target"></div>
+<script>
+test_valid_value('scroll-timeline', 'none block', 'none');
+test_valid_value('scroll-timeline', 'none inline');
+test_valid_value('scroll-timeline', '--abc x');
+test_valid_value('scroll-timeline', '--abc inline');
+test_valid_value('scroll-timeline', '--aBc inline');
+test_valid_value('scroll-timeline', '--inline inline');
+test_valid_value('scroll-timeline', '--abc');
+
+test_valid_value('scroll-timeline', '--inline block', '--inline');
+test_valid_value('scroll-timeline', '--block block', '--block');
+test_valid_value('scroll-timeline', '--y block', '--y');
+test_valid_value('scroll-timeline', '--x block', '--x');
+
+test_valid_value('scroll-timeline', '--a, --b, --c');
+test_valid_value('scroll-timeline', '--a inline, --b block, --c y', '--a inline, --b, --c y');
+test_valid_value('scroll-timeline', '--auto');
+
+test_invalid_value('scroll-timeline', '');
+test_invalid_value('scroll-timeline', '--abc --abc');
+test_invalid_value('scroll-timeline', 'block none');
+test_invalid_value('scroll-timeline', 'inline --abc');
+test_invalid_value('scroll-timeline', 'default');
+test_invalid_value('scroll-timeline', ',');
+test_invalid_value('scroll-timeline', ',,block,,');
+
+test_computed_value('scroll-timeline', 'none block', 'none');
+test_computed_value('scroll-timeline', '--abc inline');
+test_computed_value('scroll-timeline', 'none y');
+test_computed_value('scroll-timeline', '--abc x');
+test_computed_value('scroll-timeline', '--y y');
+test_computed_value('scroll-timeline', '--abc');
+test_computed_value('scroll-timeline', '--inline block', '--inline');
+test_computed_value('scroll-timeline', '--block block', '--block');
+test_computed_value('scroll-timeline', '--y block', '--y');
+test_computed_value('scroll-timeline', '--x block', '--x');
+test_computed_value('scroll-timeline', '--a, --b, --c');
+test_computed_value('scroll-timeline', '--a inline, --b block, --c y', '--a inline, --b, --c y');
+
+test_shorthand_value('scroll-timeline', '--abc y',
+{
+ 'scroll-timeline-name': '--abc',
+ 'scroll-timeline-axis': 'y',
+});
+test_shorthand_value('scroll-timeline', '--inline x',
+{
+ 'scroll-timeline-name': '--inline',
+ 'scroll-timeline-axis': 'x',
+});
+test_shorthand_value('scroll-timeline', '--abc y, --def',
+{
+ 'scroll-timeline-name': '--abc, --def',
+ 'scroll-timeline-axis': 'y, block',
+});
+test_shorthand_value('scroll-timeline', '--abc, --def',
+{
+ 'scroll-timeline-name': '--abc, --def',
+ 'scroll-timeline-axis': 'block, block',
+});
+
+function test_shorthand_contraction(shorthand, longhands, expected) {
+ let longhands_fmt = Object.entries(longhands).map((e) => `${e[0]}:${e[1]}:${e[2]}`).join(';');
+ test((t) => {
+ t.add_cleanup(() => {
+ for (let shorthand of Object.keys(longhands))
+ target.style.removeProperty(shorthand);
+ });
+ for (let [shorthand, value] of Object.entries(longhands))
+ target.style.setProperty(shorthand, value);
+ assert_equals(target.style.getPropertyValue(shorthand), expected, 'Declared value');
+ assert_equals(getComputedStyle(target).getPropertyValue(shorthand), expected, 'Computed value');
+ }, `Shorthand contraction of ${longhands_fmt}`);
+}
+
+test_shorthand_contraction('scroll-timeline', {
+ 'scroll-timeline-name': '--abc',
+ 'scroll-timeline-axis': 'inline',
+}, '--abc inline');
+
+test_shorthand_contraction('scroll-timeline', {
+ 'scroll-timeline-name': '--a, --b',
+ 'scroll-timeline-axis': 'inline, block',
+}, '--a inline, --b');
+
+test_shorthand_contraction('scroll-timeline', {
+ 'scroll-timeline-name': 'none, none',
+ 'scroll-timeline-axis': 'block, block',
+}, 'none, none');
+
+// Longhands with different lengths:
+
+test_shorthand_contraction('scroll-timeline', {
+ 'scroll-timeline-name': '--a, --b, --c',
+ 'scroll-timeline-axis': 'inline, inline',
+}, '--a inline, --b inline, --c inline');
+
+test_shorthand_contraction('scroll-timeline', {
+ 'scroll-timeline-name': '--a, --b',
+ 'scroll-timeline-axis': 'inline, inline, inline',
+}, '--a inline, --b inline');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-update-reversed-animation.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-update-reversed-animation.html
new file mode 100644
index 0000000000..6bc18544f4
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-update-reversed-animation.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Attach a scroll timeline to a reversed animation refTest</title>
+<link rel="help" src="https://www.w3.org/TR/scroll-animations-1/#scroll-timeline-name">
+<link rel="match" href="./animation-update-ref.html?translate=55px&scroll=825">
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: translateX(100px) }
+ to { transform: translateX(0px) }
+ }
+ #scroller {
+ border: 1px solid black;
+ overflow: hidden;
+ width: 300px;
+ height: 200px;
+ scroll-timeline: --timeline;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim 10s linear paused;
+ }
+ #target.update {
+ animation-play-state: running;
+ animation-timeline: --timeline;
+ animation-duration: auto;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ await waitForCompositorReady();
+
+ const anim = target.getAnimations()[0];
+ anim.playbackRate = -1;
+ await anim.ready;
+
+ // Scroll to 55% of maximum scroll while paused.
+ scroller.scrollTop = 825;
+ await waitForNextFrame();
+
+ target.classList.add('update');
+ await waitForNextFrame();
+
+ // Make sure change to animation range was properly picked up.
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/scroll-timeline-with-percent-delay.tentative.html b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-with-percent-delay.tentative.html
new file mode 100644
index 0000000000..4f2e1761de
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/scroll-timeline-with-percent-delay.tentative.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { opacity: 0 }
+ to { opacity: 1 }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: scroll();
+ /* Sentinel value when in before or after phase of the animation. */
+ opacity: 0.96875;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+
+ function assert_opacity_equals(expected, errorMessage) {
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected, 1e-6,
+ errorMessage);
+ }
+
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+
+ await waitForNextFrame();
+ scroller.scrollTop =
+ (scroller.scrollHeight - scroller.clientHeight) / 2;
+ await waitForNextFrame();
+
+ const baseOpacity = 0.96875;
+ // Delays are percentages.
+ const testData = [
+ { delay: 0, endDelay: 0, opacity: 0.5 },
+ { delay: 20, endDelay: 0, opacity: 0.375 },
+ { delay: 0, endDelay: 20, opacity: 0.625 },
+ { delay: 20, endDelay: 20, opacity: 0.5 },
+ // // Negative delays.
+ { delay: -25, endDelay: 0, opacity: 0.6 },
+ { delay: 0, endDelay: -25, opacity: 0.4 },
+ { delay: -25, endDelay: -25, opacity: 0.5 },
+ // Stress tests with >= 100% total delay. Verify effect is inactive.
+ { delay: 100, endDelay: 0, opacity: baseOpacity },
+ { delay: 0, endDelay: 100, opacity: baseOpacity },
+ { delay: 100, endDelay: 100, opacity: baseOpacity }
+ ];
+
+ testData.forEach(test => {
+ anim.effect.updateTiming({
+ delay: CSS.percent(test.delay),
+ endDelay: CSS.percent(test.endDelay)
+ });
+ assert_opacity_equals(
+ test.opacity,
+ `Opacity when delay=${test.delay} and endDelay=${test.endDelay}`);
+ });
+ }, 'ScrollTimeline with animation delays as percentages');
+ }
+
+ window.onload = runTest;
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/support/animation-range.css b/testing/web-platform/tests/scroll-animations/css/support/animation-range.css
new file mode 100644
index 0000000000..1ebd0b429b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/support/animation-range.css
@@ -0,0 +1,82 @@
+.flex {
+ display: flex;
+}
+
+.flex > div {
+ position: relative;
+ height: 160px;
+ margin: 0 10px;
+}
+
+.scroller {
+ width: 100px;
+ height: 100px;
+ overflow: auto;
+ border: 1px solid black;
+}
+
+.subject {
+ view-timeline-name: --view;
+ width: 20px;
+ height: 20px;
+ margin: 100px auto;
+ background: green;
+}
+
+.meters {
+ position: absolute;
+ left: 0;
+ top: 110px;
+ height: 50px;
+}
+
+.meters > div {
+ display: flex;
+ align-items: center;
+}
+
+@keyframes active-interval {
+ 0% { opacity: 1; }
+ 100% { opacity: 1; }
+}
+
+.meter {
+ width: 50px;
+ position: relative;
+ border: 2px solid black;
+ height: 5px;
+ overflow: clip;
+ opacity: 0.4;
+ animation: active-interval linear;
+ animation-timeline: --view;
+}
+
+@keyframes slide-in {
+ 0% { transform: translateX(-100%)}
+ 100% { transform: translateX(0%)}
+}
+
+.bar {
+ width: 100%;
+ height: 100%;
+ background: blue;
+ transform: translateX(-100%);
+ animation: slide-in linear;
+ animation-timeline: --view;
+}
+
+.spacer {
+ height: 400px;
+}
+
+.contain .bar, .contain .meter {
+ animation-range: contain;
+}
+
+.entry .bar, .entry .meter {
+ animation-range: entry;
+}
+
+.exit .bar, .exit .meter {
+ animation-range: exit;
+}
diff --git a/testing/web-platform/tests/scroll-animations/css/support/testcommon.js b/testing/web-platform/tests/scroll-animations/css/support/testcommon.js
new file mode 100644
index 0000000000..91540774d0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/support/testcommon.js
@@ -0,0 +1,19 @@
+'use strict';
+
+/**
+ * Returns a Promise that is resolved after a CSS scroll timeline is created (as
+ * the result of a style change) and a snapshot has been taken, so that the
+ * animation style is correctly reflected by getComputedStyle().
+ * Technically, this only takes a full frame update. We implement this as two
+ * requestAnimationFrame callbacks because the result will be available at the
+ * beginning of the second frame.
+ */
+async function waitForCSSScrollTimelineStyle() {
+ await waitForNextFrame();
+ await waitForNextFrame();
+}
+
+function assert_implements_animation_timeline() {
+ assert_implements(CSS.supports('animation-timeline:--foo'),
+ 'animation-timeline not supported');
+}
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-offset-in-keyframe-change-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/css/timeline-offset-in-keyframe-change-timeline.tentative.html
new file mode 100644
index 0000000000..5a70820b88
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-offset-in-keyframe-change-timeline.tentative.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ cover 0% {
+ opacity: 0;
+ margin-left: 0px;
+ }
+ cover 100% {
+ opacity: 1;
+ margin-right: 0px;
+ }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ timeline-scope: --sibling;
+ }
+ #sibling {
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 50px;
+ background-color: blue;
+ view-timeline: --sibling block;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ /* using document timeline by default */
+ animation-range-start: contain 0%;
+ animation-range-end: contain 100%;
+ view-timeline: --target block;
+ }
+
+ #target.with-view-timeline {
+ animation-timeline: --target;
+ }
+ #target.with-view-timeline.retarget {
+ animation-timeline: --sibling;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="sibling"></div>
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ await waitForNextFrame();
+
+ // Initially using a document timeline, so the keyframes should be
+ // ignored.
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ // Once a view-timeline is added, the kefyrames must update to reflect
+ // the new keyframe offsets.
+ target.classList.add('with-view-timeline');
+ assert_equals(getComputedStyle(target).animationTimeline, '--target',
+ 'Switch to view timeline');
+ await waitForNextFrame();
+
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: 0, computedOffset: 0, easing: "linear", composite: "replace",
+ marginRight: "10px" },
+ { offset: 1, computedOffset: 1, easing: "linear", composite: "replace",
+ marginLeft: "10px" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ target.classList.add('retarget');
+ assert_equals(getComputedStyle(target).animationTimeline, '--sibling',
+ 'Switch to another view timeline');
+ await waitForNextFrame();
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: 0, computedOffset: 0, easing: "linear", composite: "replace",
+ marginRight: "10px" },
+ { offset: 1, computedOffset: 1, easing: "linear", composite: "replace",
+ marginLeft: "10px" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1/3, easing: "linear",
+ composite: "auto", marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 4/3, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ target.classList.toggle('with-view-timeline');
+ assert_equals(getComputedStyle(target).animationTimeline, 'auto',
+ 'Switch back to document timeline');
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'getKeyframes with timeline-offsets');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-hidden-subject.html b/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-hidden-subject.html
new file mode 100644
index 0000000000..011b8d4319
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-hidden-subject.html
@@ -0,0 +1,126 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ cover 0% {
+ margin-left: 0px;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ cover 100% {
+ margin-right: 0px;
+ }
+ }
+
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ timeline-scope: --t1;
+ }
+ #block {
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 50px;
+ background-color: blue;
+ view-timeline: --t1;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-range-start: contain 0%;
+ animation-range-end: contain 100%;
+ animation-timeline: --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="block"></div>
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anims = document.getAnimations();
+ assert_equals(anims.length, 1,
+ "Should have one animation attached to the view-timeline");
+ const anim = anims[0];
+ await anim.ready;
+ await waitForNextFrame();
+
+ let frames = anim.effect.getKeyframes();
+ let expected_resolved_offsets = [
+ { offset: 0, computedOffset: 0, easing: "linear", composite: "replace",
+ marginRight: "10px", opacity: "1" },
+ { offset: 1/2, computedOffset: 1/2, easing: "linear",
+ composite: "auto", opacity: "0.5" },
+ { offset: 1, computedOffset: 1, easing: "linear", composite: "replace",
+ marginLeft: "10px", opacity: "1" },
+ { offset: { rangeName: "cover", offset: CSS.percent(0) },
+ computedOffset: -1/3, easing: "linear",
+ composite: "auto", marginLeft: "0px" },
+ { offset: { rangeName: "cover", offset: CSS.percent(100) },
+ computedOffset: 4/3, easing: "linear", composite: "auto",
+ marginRight: "0px" },
+ ];
+ assert_frame_lists_equal(frames, expected_resolved_offsets,
+ 'Initial keyframes with active view-timeline');
+
+ block.style.display = 'none';
+ // View-timeline becomes inactive. Keyframes with timeline offsets must be
+ // ignored.
+ frames = anim.effect.getKeyframes();
+ let expected_unresolved_offsets = [
+ { offset: 0, computedOffset: 0, opacity: "1", easing: "linear",
+ composite: "replace" },
+ { offset: 0.5, computedOffset: 0.5, opacity: "0.5", easing: "linear",
+ composite: "auto", },
+ { offset: 1, computedOffset: 1, opacity: "1", easing: "linear",
+ composite: "replace" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: null, easing: "linear",
+ composite: "auto", marginLeft: "0px" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginRight: "0px" }
+ ];
+ assert_frame_lists_equal(frames, expected_unresolved_offsets,
+ 'Keyframes with invalid view timeline');
+
+ block.style.display = 'block';
+ // Timeline remains inactive until next frame.
+ await waitForNextFrame();
+
+ // Ensure that keyframes with timeline-offsets are restored.
+ frames = anim.effect.getKeyframes();
+
+ assert_frame_lists_equal(frames, expected_resolved_offsets,
+ 'Keyframes with restored view timeline');
+ }, 'Keyframes with timeline-offsets ignored when timeline is inactive');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-with-document-timeline.html b/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-with-document-timeline.html
new file mode 100644
index 0000000000..03ee381fd9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-offset-keyframes-with-document-timeline.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ cover 100% {
+ margin-right: 0px;
+ }
+ cover 0% {
+ margin-left: 0px;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ /* using document timeline by default */
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ await waitForNextFrame();
+
+ // Using a document timeline, so only the 50% keyframe is used.
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: 0, computedOffset: 0, opacity: "1", easing: "linear",
+ composite: "replace" },
+ { offset: 0.5, computedOffset: 0.5, opacity: "0.5", easing: "linear",
+ composite: "auto" },
+ { offset: 1, computedOffset: 1, opacity: "1", easing: "linear",
+ composite: "replace" },
+ { offset: { rangeName: "cover", offset: CSS.percent(100) },
+ computedOffset: null, marginRight: "0px", composite: "auto",
+ easing: "linear" },
+ { offset: { rangeName: "cover", offset: CSS.percent(0) },
+ computedOffset: null, marginLeft: "0px", composite: "auto",
+ easing: "linear" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Keyframes with timeline-offsets reported but not reachable when ' +
+ 'using a document timeline');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-range-name-offset-in-keyframes.tentative.html b/testing/web-platform/tests/scroll-animations/css/timeline-range-name-offset-in-keyframes.tentative.html
new file mode 100644
index 0000000000..6fab0025da
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-range-name-offset-in-keyframes.tentative.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>Timeline offset in Animation Keyframes</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#named-range-keyframes">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes fade-in-out-animation {
+ entry 0%, exit 100% { opacity: 0 }
+ entry 100%, exit 0% { opacity: 1 }
+ }
+
+ #subject {
+ background-color: rgba(0, 0, 255);
+ height: 200px;
+ width: 200px;
+ view-timeline-name: --foo;
+ animation: linear 1s both fade-in-out-animation;
+ animation-timeline: --foo;
+ }
+
+ #container {
+ border: 5px solid black;
+ height: 400px;
+ width: 400px;
+ overflow-y: scroll;
+ resize: both;
+ }
+
+ .spacer {
+ height: 600px;
+ width: 100%;
+ }
+</style>
+<body onload="runTests()">
+ <div id="container">
+ <div class="spacer"></div>
+ <div id="subject"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+
+<script type="text/javascript">
+ setup(assert_implements_animation_timeline);
+
+ function runTests() {
+ promise_test(async t => {
+ await waitForNextFrame();
+
+ // scrollTop=200 to 400 is the entry range
+ container.scrollTop = 200;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0',
+ 'Effect at entry 0%');
+
+ container.scrollTop = 300;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0.5',
+ 'Effect at entry 50%');
+
+ container.scrollTop = 400;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '1',
+ 'Effect at entry 100%');
+
+ // scrollTop=600-800 is the exit range
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '1',
+ 'Effect at exit 0%');
+
+ container.scrollTop = 700;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0.5',
+ 'Effect at exit 50%');
+
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0',
+ 'Effect at exit 100%');
+
+ // First change scrollTop so that you are at entry 100%, then resize the
+ // container in a way that scrollTop is the same, but now the animation is
+ // at entry 50% and check opacity. After changing the height of container,
+ // scrollTop=300-500 is the entry range
+ container.scrollTop = 400;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '1',
+ 'Effect at entry 100%');
+
+ // Reducing the viewport by 100px, shifts the keyframe offsets.
+ // The entry range shifts from [200px, 400px] to [300px, 500px].
+ container.style.height = '300px';
+
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0.5',
+ 'Effect at entry 50% (post resize)');
+
+ // After changing the height of container, scrollTop=600-800 is still the
+ // exit range
+ container.scrollTop = 700;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(subject).opacity, '0.5',
+ 'Effect at exit 50% (post resize)');
+ });
+ }
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-scope-computed.tentative.html b/testing/web-platform/tests/scroll-animations/css/timeline-scope-computed.tentative.html
new file mode 100644
index 0000000000..814933f726
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-scope-computed.tentative.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/7759">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+</head>
+<style>
+ #outer { timeline-scope: foo; }
+ #target { timeline-scope: bar; }
+</style>
+<div id="outer">
+ <div id="target"></div>
+</div>
+<script>
+test_computed_value('timeline-scope', 'initial', 'none');
+test_computed_value('timeline-scope', 'inherit', 'foo');
+test_computed_value('timeline-scope', 'unset', 'none');
+test_computed_value('timeline-scope', 'revert', 'none');
+test_computed_value('timeline-scope', 'none');
+test_computed_value('timeline-scope', 'test');
+test_computed_value('timeline-scope', 'foo, bar');
+test_computed_value('timeline-scope', 'bar, foo');
+test_computed_value('timeline-scope', 'a, b, c, D, e');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('timeline-scope'), -1);
+}, 'The timeline-scope property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('timeline-scope'), -1);
+}, 'The timeline-scope property shows up in CSSStyleDeclaration.cssText');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-scope-parsing.tentative.html b/testing/web-platform/tests/scroll-animations/css/timeline-scope-parsing.tentative.html
new file mode 100644
index 0000000000..2885cb758d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-scope-parsing.tentative.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/7759">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<div id="target"></div>
+<script>
+
+test_valid_value('timeline-scope', 'initial');
+test_valid_value('timeline-scope', 'inherit');
+test_valid_value('timeline-scope', 'unset');
+test_valid_value('timeline-scope', 'revert');
+
+test_valid_value('timeline-scope', 'none');
+test_valid_value('timeline-scope', 'abc');
+test_valid_value('timeline-scope', ' abc', 'abc');
+test_valid_value('timeline-scope', 'aBc');
+test_valid_value('timeline-scope', 'foo, bar');
+test_valid_value('timeline-scope', 'bar, foo');
+test_valid_value('timeline-scope', 'auto');
+
+test_invalid_value('timeline-scope', 'none, abc');
+test_invalid_value('timeline-scope', '10px');
+test_invalid_value('timeline-scope', 'foo bar');
+test_invalid_value('timeline-scope', '"foo" "bar"');
+test_invalid_value('timeline-scope', 'rgb(1, 2, 3)');
+test_invalid_value('timeline-scope', '#fefefe');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/timeline-scope.html b/testing/web-platform/tests/scroll-animations/css/timeline-scope.html
new file mode 100644
index 0000000000..e4e90bc03a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/timeline-scope.html
@@ -0,0 +1,322 @@
+<!DOCTYPE html>
+<title>Behavior of the timeline-scope property</title>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+
+<main id=main></main>
+<script>
+ async function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ return runAndWaitForFrameUpdate(() => {
+ main.append(template.content.cloneNode(true));
+ });
+ }
+
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+</script>
+<style>
+ @keyframes anim {
+ from { width: 0px; }
+ to { width: 200px; }
+ }
+
+ .scroller {
+ overflow-y: hidden;
+ width: 200px;
+ height: 200px;
+ }
+ .scroller > .content {
+ margin: 400px 0px;
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+ .target {
+ background-color: coral;
+ width: 0px;
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ }
+ .timeline {
+ scroll-timeline-name: --t1;
+ }
+ .scope {
+ timeline-scope: --t1;
+ }
+
+</style>
+
+<!-- Basic Behavior -->
+
+<template id=deferred_timeline>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+ }, 'Descendant can attach to deferred timeline');
+</script>
+
+<template id=deferred_timeline_no_attachments>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_no_attachments);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '0px');
+ }, 'Deferred timeline with no attachments');
+</script>
+
+<template id=scroll_timeline_inner_interference>
+ <div class="scroller timeline">
+ <div class=content>
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, scroll_timeline_inner_interference);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+ }, 'Inner timeline does not interfere with outer timeline');
+</script>
+
+<template id=deferred_timeline_two_attachments>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ <!-- Extra attachment -->
+ <div class="timeline"></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_two_attachments);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '0px');
+ }, 'Deferred timeline with two attachments');
+</script>
+
+<!-- Dynamic Reattachment -->
+
+<template id=deferred_timeline_reattach>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ <div class="scroller">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_reattach);
+ let scrollers = main.querySelectorAll('.scroller');
+ assert_equals(scrollers.length, 2);
+ let target = main.querySelector('.target');
+ await scrollTop(scrollers[0], 350); // 50%
+ await scrollTop(scrollers[1], 175); // 25%
+
+ // Attached to scrollers[0].
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+
+ // Reattach to scrollers[1].
+ await runAndWaitForFrameUpdate(() => {
+ scrollers[0].classList.remove('timeline');
+ scrollers[1].classList.add('timeline');
+ });
+
+ assert_equals(getComputedStyle(target).width, '50px'); // 0px => 200px, 25%
+ }, 'Dynamically re-attaching');
+</script>
+
+<template id=deferred_timeline_dynamic_detach>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_dynamic_detach);
+ let scrollers = main.querySelectorAll('.scroller');
+ assert_equals(scrollers.length, 2);
+ let target = main.querySelector('.target');
+ await scrollTop(scrollers[0], 350); // 50%
+ await scrollTop(scrollers[1], 175); // 25%
+
+ // Attached to two timelines initially:
+ assert_equals(getComputedStyle(target).width, '0px');
+
+ // Detach scrollers[1].
+ await runAndWaitForFrameUpdate(() => {
+ scrollers[1].classList.remove('timeline');
+ });
+
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+
+ // Also detach scrollers[0].
+ scrollers[0].classList.remove('timeline');
+
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).width, '0px');
+ }, 'Dynamically detaching');
+</script>
+
+<template id=deferred_timeline_attached_removed>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_attached_removed);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+
+ let scroller_parent = scroller.parentElement;
+ scroller.remove();
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).width, '0px');
+
+ scroller_parent.append(scroller);
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+ }, 'Removing/inserting element with attaching timeline');
+</script>
+
+<template id=deferred_timeline_attached_display_none>
+ <div class="scope">
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_attached_display_none);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+
+ scroller.style.display = 'none';
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).width, '0px');
+
+ scroller.style.display = 'block';
+ await scrollTop(scroller, 350); // 50%
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+ }, 'Ancestor attached element becoming display:none/block');
+</script>
+
+<template id=deferred_timeline_appearing>
+ <div class=container>
+ <div class=target>Test</div>
+ <div class="scroller timeline">
+ <div class=content></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_appearing);
+ let container = main.querySelector('.container');
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+
+ await scrollTop(scroller, 350); // 50%
+
+ // Not attached to any timeline initially.
+ assert_equals(getComputedStyle(target).width, '0px');
+
+ // Add the deferred timeline.
+ container.classList.add('scope');
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).width, '100px'); // 0px => 200px, 50%
+
+ // Remove the deferred timeline.
+ container.classList.remove('scope');
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).width, '0px');
+ }, 'A deferred timeline appearing dynamically in the ancestor chain');
+</script>
+
+<template id=deferred_timeline_on_self>
+ <div class="scroller timeline scope">
+ <div class=content>
+ <div class=target></div>
+ </div>
+ <div class=scroller2></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, deferred_timeline_on_self);
+ let scroller = main.querySelector('.scroller');
+ let target = main.querySelector('.target');
+ await scrollTop(scroller, 525); // 75%
+
+ assert_equals(getComputedStyle(target).width, '150px'); // 0px => 200px, 75%
+
+ // A second scroll-timeline now attaches to the same root.
+ let scroller2 = main.querySelector('.scroller2');
+ scroller2.classList.add('timeline');
+ await waitForNextFrame();
+
+ // The deferred timeline produced by timeline-scope is now inactive,
+ // but it doesn't matter, because we preferred to attach
+ // to the non-deferred timeline.
+ assert_equals(getComputedStyle(target).width, '150px'); // 0px => 200px, 75%
+ }, 'Animations prefer non-deferred timelines');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-animation-range-update.tentative.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-animation-range-update.tentative.html
new file mode 100644
index 0000000000..552461c9b6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-animation-range-update.tentative.html
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Change animation-range after creation</title>
+<link rel="help" src="https://www.w3.org/TR/scroll-animations-1/#named-range-animation-declaration">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; background-color: skyblue;}
+ to { z-index: 100; background-color: coral; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ width: 200px;
+ height: 200px;
+ }
+ /* Reset specificity to allow animation-range-* from .restrict-range to win. */
+ :where(#target) {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: --t1;
+ view-timeline: --t1 block;
+ }
+ .restrict-range {
+ animation-range-start: contain 0%;
+ animation-range-end: contain 100%;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ setup(assert_implements_animation_timeline);
+
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function waitForAnimationReady(target) {
+ await waitForNextFrame();
+ await Promise.all(target.getAnimations().map(x => x.promise));
+ }
+ async function assertValueAt(scroller, target, position, expected) {
+ await waitForAnimationReady(target);
+ await scrollTop(scroller, position);
+ assert_equals(getComputedStyle(target).zIndex, expected.toString());
+ }
+
+ promise_test(async t => {
+ const scroller = document.getElementById('scroller');
+ const target = document.getElementById('target');
+ waitForAnimationReady(target);
+
+ await assertValueAt(scroller, target, 600, 0);
+ await assertValueAt(scroller, target, 700, 33);
+ await assertValueAt(scroller, target, 750, 50);
+ await assertValueAt(scroller, target, 800, 67);
+
+ target.classList.toggle('restrict-range');
+ await waitForNextFrame();
+
+ await assertValueAt(scroller, target, 700, 0);
+ await assertValueAt(scroller, target, 750, 50);
+ await assertValueAt(scroller, target, 800, 100);
+ }, 'Ensure that animation is updated on a style change');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-animation.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-animation.html
new file mode 100644
index 0000000000..1bf034a742
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-animation.html
@@ -0,0 +1,223 @@
+<!DOCTYPE html>
+<title>Animations using view-timeline</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timelines-named">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+ .vertical-scroller {
+ overflow: auto;
+ width: 100px;
+ height: 100px;
+ }
+ .vertical-scroller > div {
+ height: 50px;
+ z-index: -1;
+ }
+ .horizontal-scroller {
+ overflow: auto;
+ width: 100px;
+ height: 100px;
+ writing-mode: vertical-lr;
+ }
+ .horizontal-scroller > div {
+ width: 50px;
+ z-index: -1;
+ }
+</style>
+<main id=main></main>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function scrollLeft(e, value) {
+ e.scrollLeft = value;
+ await waitForNextFrame();
+ }
+</script>
+
+<template id=default_view_timeline>
+ <style>
+ #target {
+ view-timeline: --t1;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical-scroller>
+ <div></div> <!-- [0px, 50px] -->
+ <div></div> <!-- [50px, 100px] -->
+ <div></div> <!-- [100px, 150px] -->
+ <div id=target></div> <!-- [150px, 200px] -->
+ <div></div> <!-- [200px, 250px] -->
+ <div></div> <!-- [250px, 300px] -->
+ <div></div> <!-- [300px, 350px] -->
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, default_view_timeline);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ await scrollTop(scroller, 25);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ await scrollTop(scroller, 50); // 0% (enter 0%)
+ assert_equals(getComputedStyle(target).zIndex, '0');
+ await scrollTop(scroller, 125); // 50%
+ assert_equals(getComputedStyle(target).zIndex, '50');
+ await scrollTop(scroller, 200); // 100% (exit 100%)
+ assert_equals(getComputedStyle(target).zIndex, '100');
+ document.getAnimations()[0].effect.updateTiming( { fill: 'none' });
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ }, 'Default view-timeline');
+</script>
+
+<template id=horizontal_timeline>
+ <style>
+ #target {
+ view-timeline: --t1 x;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=horizontal-scroller>
+ <div></div> <!-- [0px, 50px] -->
+ <div></div> <!-- [50px, 100px] -->
+ <div></div> <!-- [100px, 150px] -->
+ <div id=target></div> <!-- [150px, 200px] -->
+ <div></div> <!-- [200px, 250px] -->
+ <div></div> <!-- [250px, 300px] -->
+ <div></div> <!-- [300px, 350px] -->
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, horizontal_timeline);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ await scrollLeft(scroller, 25);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ await scrollLeft(scroller, 50); // 0% (enter 0%)
+ assert_equals(getComputedStyle(target).zIndex, '0');
+ await scrollLeft(scroller, 125); // 50%
+ assert_equals(getComputedStyle(target).zIndex, '50');
+ await scrollLeft(scroller, 200); // 100% (exit 100%)
+ assert_equals(getComputedStyle(target).zIndex, '100');
+ document.getAnimations()[0].effect.updateTiming( { fill: 'none' });
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+ }, 'Horizontal view-timeline');
+</script>
+
+<template id=multiple_timelines>
+ <style>
+ #timelines {
+ view-timeline: --tv y, --th x;
+ background-color: red;
+ }
+ #scroller {
+ width: 100px;
+ height: 100px;
+ overflow: hidden;
+ display: grid;
+ grid-template-columns: 50px 50px 50px 50px 50px 50px 50px;
+ grid-template-row: 50px 50px 50px 50px 50px 50px 50px;
+ timeline-scope: --tv, --th;
+ }
+ #scroller > div {
+ z-index: -1;
+ width: 50px;
+ height: 50px;
+ }
+ #target_v {
+ animation: anim 1s linear forwards;
+ animation-timeline: --tv;
+ }
+ #target_h {
+ animation: anim 1s linear forwards;
+ animation-timeline: --th;
+ }
+ </style>
+ <div id=scroller>
+ <!-- Created dynamically -->
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, multiple_timelines);
+
+ // Create a 350px x 350px grid (7x7 items of 50x50px each), with the
+ // timelines at item [3,3], an element attached to the horizontal timeline
+ // at [4,3], and an element attached to the vertical timeline at [3,4].
+
+ // x x x x x x x
+ // x x x x x x x
+ // x x x x x x x
+ // x x x T H x x
+ // x x x V x x x
+ // x x x x x x x
+ // x x x x x x x
+ // x x x x x x x
+
+ let grid_size = 7;
+ for (let i = 0; i < (grid_size*grid_size); ++i) {
+ let div = document.createElement('div');
+ if (i == (3 * grid_size + 3))
+ div.id = 'timelines';
+ if (i == (3 * grid_size + 4))
+ div.id = 'target_h';
+ if (i == (4 * grid_size + 3))
+ div.id = 'target_v';
+ scroller.append(div);
+ }
+
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+
+ // First scroll vertically.
+ await scrollTop(scroller, 25);
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ await scrollTop(scroller, 50); // 0% (enter 0%)
+ assert_equals(getComputedStyle(target_v).zIndex, '0');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ await scrollTop(scroller, 125); // 50%
+ assert_equals(getComputedStyle(target_v).zIndex, '50');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ await scrollTop(scroller, 200); // 100% (exit 100%)
+ assert_equals(getComputedStyle(target_v).zIndex, '100');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ document.getElementById('target_v').getAnimations()[0].
+ effect.updateTiming({ fill: 'none' });
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+
+ // Then horizontally.
+ await scrollLeft(scroller, 25);
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ await scrollLeft(scroller, 50); // 0% (enter 0%)
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '0');
+ await scrollLeft(scroller, 125); // 50%
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '50');
+ await scrollLeft(scroller, 200); // 100% (exit 100%)
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '100');
+ document.getElementById('target_h').getAnimations()[0].
+ effect.updateTiming({ fill: 'none' });
+ assert_equals(getComputedStyle(target_v).zIndex, '-1');
+ assert_equals(getComputedStyle(target_h).zIndex, '-1');
+ }, 'Multiple view-timelines on the same element');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-computed.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-computed.html
new file mode 100644
index 0000000000..30b2a1ae05
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-computed.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-axis">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+</head>
+<style>
+ #outer { view-timeline-axis: block, inline; }
+ #target { view-timeline-axis: y; }
+</style>
+<div id=outer>
+ <div id=target></div>
+</div>
+<script>
+test_computed_value('view-timeline-axis', 'initial', 'block');
+test_computed_value('view-timeline-axis', 'inherit', 'block, inline');
+test_computed_value('view-timeline-axis', 'unset', 'block');
+test_computed_value('view-timeline-axis', 'revert', 'block');
+test_computed_value('view-timeline-axis', 'block');
+test_computed_value('view-timeline-axis', 'inline');
+test_computed_value('view-timeline-axis', 'y');
+test_computed_value('view-timeline-axis', 'x');
+test_computed_value('view-timeline-axis', 'block, inline');
+test_computed_value('view-timeline-axis', 'inline, block');
+test_computed_value('view-timeline-axis', 'block, y, x, inline');
+test_computed_value('view-timeline-axis', 'inline, inline, inline, inline');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('view-timeline-axis'), -1);
+}, 'The view-timeline-axis property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('view-timeline-axis'), -1);
+}, 'The view-timeline-axis property shows up in CSSStyleDeclaration.cssText');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-parsing.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-parsing.html
new file mode 100644
index 0000000000..1ebe4410a9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-axis-parsing.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-axis">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+</head>
+<div id="target"></div>
+<script>
+test_valid_value('view-timeline-axis', 'initial');
+test_valid_value('view-timeline-axis', 'inherit');
+test_valid_value('view-timeline-axis', 'unset');
+test_valid_value('view-timeline-axis', 'revert');
+
+test_valid_value('view-timeline-axis', 'block');
+test_valid_value('view-timeline-axis', 'inline');
+test_valid_value('view-timeline-axis', 'y');
+test_valid_value('view-timeline-axis', 'x');
+test_valid_value('view-timeline-axis', 'block, inline');
+test_valid_value('view-timeline-axis', 'inline, block');
+test_valid_value('view-timeline-axis', 'block, y, x, inline');
+test_valid_value('view-timeline-axis', 'inline, inline, inline, inline');
+
+test_invalid_value('view-timeline-axis', 'abc');
+test_invalid_value('view-timeline-axis', '10px');
+test_invalid_value('view-timeline-axis', 'auto');
+test_invalid_value('view-timeline-axis', 'none');
+test_invalid_value('view-timeline-axis', 'block inline');
+test_invalid_value('view-timeline-axis', 'block / inline');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-dynamic.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-dynamic.html
new file mode 100644
index 0000000000..81dc8353c2
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-dynamic.html
@@ -0,0 +1,200 @@
+<!DOCTYPE html>
+<title>Changes to view-timeline are reflected in dependent elements</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timeline-shorthand">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+ .scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ .scroller > div {
+ height: 100px;
+ }
+ #target {
+ height: 0px;
+ z-index: -1;
+ }
+</style>
+<main id=main></main>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ main.offsetTop;
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function scrollLeft(e, value) {
+ e.scrollLeft = value;
+ await waitForNextFrame();
+ }
+</script>
+
+<template id=dynamic_view_timeline_attachment>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ .timeline {
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=div75></div>
+ <div id=div25></div>
+ <div id=div_before></div>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, dynamic_view_timeline_attachment);
+
+ await scrollTop(scroller, 50);
+
+ // scrollTop=50 is 75% for div75.
+ div75.classList.add('timeline');
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '75', 'div75');
+
+ // Identical timelines in div75 and div25 creates an ambiguity.
+ div25.classList.add('timeline');
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '-1', 'ambiguous');
+ // Removing the timeline from div75 unambiguously links div25 to the
+ // timeline, making scrollTop=50 at 25% for div25.
+ div75.classList.remove('timeline');
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '25', 'div25');
+
+ // scrollTop=50 is before the timeline start for div_before.
+ div25.classList.remove('timeline');
+ div_before.classList.add('timeline');
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '-1', 'ahead of div_before');
+ // Scroll to 25% (for div_before) to verify that we're linked to that
+ // timeline.
+ await scrollTop(scroller, 150);
+ assert_equals(getComputedStyle(target).zIndex, '25', 'div_before');
+
+ // Linking the timeline back to div25 verifies that the new scrollTop=150 is
+ // actually at 75%.
+ div_before.classList.remove('timeline');
+ div25.classList.add('timeline');
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '75', 'div25 again');
+ }, 'Dynamically changing view-timeline attachment');
+</script>
+
+<template id=dynamic_view_timeline_axis>
+ <style>
+ #timeline {
+ view-timeline: --t1;
+ width: 100px;
+ height: 100px;
+ margin: 100px;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=timeline style="background: red;">
+ <div id=target></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, dynamic_view_timeline_axis);
+
+ await scrollTop(scroller, 50); // 25% (vertical)
+ await scrollLeft(scroller, 20); // 10% (horizontal)
+
+ assert_equals(getComputedStyle(target).zIndex, '25', 'vertical');
+ timeline.style.viewTimelineAxis = 'x';
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '10', 'horizontal');
+ }, 'Dynamically changing view-timeline-axis');
+</script>
+
+<template id=dynamic_view_timeline_inset>
+ <style>
+ #timeline {
+ width: 100px;
+ height: 100px;
+ margin: 100px;
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=timeline style="background: red;">
+ <div id=target></div>
+ </div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, dynamic_view_timeline_inset);
+
+ await scrollTop(scroller, 50); // 25% (without inset).
+
+ assert_equals(getComputedStyle(target).zIndex, '25', 'without inset');
+ timeline.style.viewTimelineInset = '0px 50px';
+ await waitForCSSScrollTimelineStyle();
+ assert_equals(getComputedStyle(target).zIndex, '0', 'with inset');
+ }, 'Dynamically changing view-timeline-inset');
+</script>
+
+<template id=timeline_display_none>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ #timeline {
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div id=timeline></div>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, timeline_display_none);
+
+ await scrollTop(scroller, 50);
+ assert_equals(getComputedStyle(target).zIndex, '25', 'display:block');
+ timeline.style.display = 'none';
+ await waitForNextFrame();
+ // The timeline became inactive.
+ assert_equals(getComputedStyle(target).zIndex, '-1', 'display:none');
+ }, 'Element with scoped view-timeline becoming display:none');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-animation.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-animation.html
new file mode 100644
index 0000000000..f9aa0f2918
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-animation.html
@@ -0,0 +1,743 @@
+<!DOCTYPE html>
+<title>Animations using view-timeline-inset</title>
+<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#propdef-view-timeline-inset">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+ #scroller {
+ overflow: hidden;
+ width: 80px;
+ height: 100px;
+ }
+ #target {
+ margin: 150px;
+ width: 50px;
+ height: 50px;
+ z-index: -1;
+ }
+</style>
+<main id=main></main>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function scrollLeft(e, value) {
+ e.scrollLeft = value;
+ await waitForNextFrame();
+ }
+ async function assertValueAt(scroller, target, args) {
+ if (args.scrollTop !== undefined)
+ await scrollTop(scroller, args.scrollTop);
+ if (args.scrollLeft !== undefined)
+ await scrollLeft(scroller, args.scrollLeft);
+ assert_equals(getComputedStyle(target).zIndex, args.expected.toString());
+ }
+</script>
+
+<!--
+ Explanation of scroll positions
+ ===============================
+
+ Please note the following:
+
+ - The scroller has a width x height of 80x100px.
+ - The content is 50x50px with a 150px margin on all sides.
+ In other words, the size of the scroller content is 200x200px.
+
+ This means that, for vertical direction scrolling, assuming no insets:
+
+ - The start offset is 50px (scroller height + 50px is 150px, which consumes
+ exactly the margin of the content).
+ - The end offset is 200px (this is where the bottom edge of the scroller has
+ just cleared the content).
+ - The halfway point is (50px + 200px) / 2 = 125px.
+
+ For horizontal direction scrolling, assuming no insets:
+
+ - The start offset is 70px (scroller width + 70px is 150px, which consumes
+ exactly the margin of the content).
+ - The end offset is 200px (this is where the left edge of the scroller has
+ just cleared the content).
+ - The halfway point is (70px + 200px) / 2 = 135px.
+
+ The start and end insets will adjust the start and end offsets accordingly,
+ and the expectations in this file explicitly write out those adjustments.
+ For example, if the start offset is normally 50px, but there's an inset of
+ 10px, we'll expect 50px + 10px rather than 60px.
+
+ Halfway-point expectations write out the adjustment from the "normal"
+ halfway-point, e.g. for start-inset:10px and end-inset:20px, we expect
+ "125px + 5px" since (20-10)/2 == 5.
+
+ Finally, note that for right-to-left and bottom-to-top scrolling directions
+ scroll offsets go the in the negative direction. This is why some expectations
+ negate all the offsets.
+-->
+
+<template id=test_one_value>
+ <style>
+ #target {
+ view-timeline: --t1;
+ view-timeline-inset: 10px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_one_value);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 10, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 0, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with one value');
+</script>
+<template id=test_two_values>
+ <style>
+ #target {
+ view-timeline: --t1;
+ view-timeline-inset: 10px 20px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_two_values);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with two values');
+</script>
+
+<template id=test_em_values>
+ <style>
+ #target {
+ font-size: 10px;
+ view-timeline: --t1;
+ view-timeline-inset: 10px 2em;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_em_values);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with em values');
+</script>
+
+<template id=test_percentage_values>
+ <style>
+ #target {
+ font-size: 10px;
+ view-timeline: --t1;
+ view-timeline-inset: calc(5px + max(1%, 5%)) 20%;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_percentage_values);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with percentage values');
+</script>
+
+<template id=test_outset>
+ <style>
+ #target {
+ view-timeline: --t1;
+ view-timeline-inset: -10px -20px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=vertical>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_outset);
+ await assertValueAt(scroller, target, { scrollTop:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 - 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 - 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 + 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with negative values');
+</script>
+
+<template id=test_horizontal>
+ <style>
+ #target {
+ view-timeline: --t1 x;
+ view-timeline-inset: 10px 20px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_horizontal);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with horizontal scroller');
+</script>
+
+<template id=test_block>
+ <style>
+ #target {
+ view-timeline: --t1 block;
+ view-timeline-inset: 10px 20px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_block);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with block scroller');
+</script>
+
+<template id=test_inline>
+ <style>
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: 10px 20px;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_inline);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset with inline scroller');
+</script>
+
+<template id=test_auto_block>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ }
+ #target {
+ view-timeline: --t1 block;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_block);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, block');
+</script>
+
+<template id=test_auto_block_vertical_lr>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ writing-mode: vertical-lr;
+ }
+ #target {
+ view-timeline: --t1 block;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_block_vertical_lr);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, block, vertical-lr');
+</script>
+
+<template id=test_auto_block_vertical_rl>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ writing-mode: vertical-rl;
+ }
+ #target {
+ view-timeline: --t1 block;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_block_vertical_rl);
+ // Note: this represents horizontal scrolling from right to left.
+ await assertValueAt(scroller, target, { scrollLeft:-20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:-(70 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:-(135 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, block, vertical-rl');
+</script>
+
+<template id=test_auto_inline>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline');
+</script>
+
+<template id=test_auto_inline_vertical_rl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-rl;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline_vertical_rl);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline, vertical-rl');
+</script>
+
+<template id=test_auto_inline_vertical_lr>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-lr;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline_vertical_lr);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline, vertical-lr');
+</script>
+
+<template id=test_auto_inline_rtl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ direction: rtl;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline_rtl);
+ await assertValueAt(scroller, target, { scrollLeft:-20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:-(70 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:-(135 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline, rtl');
+</script>
+
+<template id=test_auto_inline_vertical_rl_rtl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-rl;
+ direction: rtl;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline_vertical_rl_rtl);
+ await assertValueAt(scroller, target, { scrollTop:-50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:-(50 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:-(125 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline, vertical-rl, rtl');
+</script>
+
+<template id=test_auto_inline_vertical_lr_rtl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-lr;
+ direction: rtl;
+ }
+ #target {
+ view-timeline: --t1 inline;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_inline_vertical_lr_rtl);
+ await assertValueAt(scroller, target, { scrollTop:-50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:-(50 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:-(125 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, inline, vertical-lr, rtl');
+</script>
+
+<template id=test_auto_vertical>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ }
+ #target {
+ view-timeline: --t1 y;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_vertical);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, y');
+</script>
+
+<template id=test_auto_vertical_vertical_rl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-rl;
+ }
+ #target {
+ view-timeline: --t1 y;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_vertical_vertical_rl);
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, y, vertical-rl');
+</script>
+
+<template id=test_auto_vertical_vertical_rl_rtl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ writing-mode: vertical-rl;
+ direction: rtl;
+ }
+ #target {
+ view-timeline: --t1 y;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_vertical_vertical_rl_rtl);
+ await assertValueAt(scroller, target, { scrollTop:-50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:-(50 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:-(125 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, y, vertical-rl, rtl');
+</script>
+
+<template id=test_auto_horizontal>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ }
+ #target {
+ view-timeline: --t1 x;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_horizontal);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, x');
+</script>
+
+<template id=test_auto_horizontal_rtl>
+ <style>
+ #scroller {
+ scroll-padding-inline: 10px 20px;
+ direction: rtl;
+ }
+ #target {
+ view-timeline: --t1 x;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_horizontal_rtl);
+ await assertValueAt(scroller, target, { scrollLeft:-20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:-(70 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:-(135 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, x, rtl');
+</script>
+
+<template id=test_auto_horizontal_vertical_lr>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ writing-mode: vertical-lr;
+ }
+ #target {
+ view-timeline: --t1 x;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_horizontal_vertical_lr);
+ await assertValueAt(scroller, target, { scrollLeft:20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:70 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:135 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, x, vertical-lr');
+</script>
+
+<template id=test_auto_horizontal_vertical_rl>
+ <style>
+ #scroller {
+ scroll-padding-block: 10px 20px;
+ writing-mode: vertical-rl;
+ }
+ #target {
+ view-timeline: --t1 x;
+ view-timeline-inset: auto auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_horizontal_vertical_rl);
+ await assertValueAt(scroller, target, { scrollLeft:-20, expected:-1 });
+ await assertValueAt(scroller, target, { scrollLeft:-(70 + 20), expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollLeft:-(135 + 5), expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollLeft:-(200 - 10), expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, x, vertical-rl');
+</script>
+
+<template id=test_auto_mix>
+ <style>
+ #scroller {
+ font-size: 10px;
+ scroll-padding-block: 50px calc(10% + 1em);
+ }
+ #target {
+ view-timeline: --t1;
+ view-timeline-inset: 10% auto;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, test_auto_mix);
+ // Note: 10% of scroller height 100px is 10px, and 1em with font-size:10px
+ // is also 10px. Hence we expect the end inset specified as calc(10% + 1em)
+ // to be 20px.
+ await assertValueAt(scroller, target, { scrollTop:50, expected:-1 });
+ await assertValueAt(scroller, target, { scrollTop:50 + 20, expected:0 }); // 0%
+ await assertValueAt(scroller, target, { scrollTop:125 + 5, expected:50 }); // 50%
+ await assertValueAt(scroller, target, { scrollTop:200 - 10, expected:100 }); // 100%
+ }, 'view-timeline-inset:auto, mix');
+</script>
+
+<!--
+ TODO: How to test view-timeline:auto + scroll-padding:auto? The UA may
+ in theory use any value in that case.
+
+ https://drafts.csswg.org/css-scroll-snap-1/#valdef-scroll-padding-auto
+-->
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-computed.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-computed.html
new file mode 100644
index 0000000000..d9e1c9d790
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-computed.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-inset">
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/7243">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<style>
+ #outer { font-size:10px; }
+ #outer { view-timeline-inset: 1px 2px, auto 3px; }
+ #target { view-timeline-inset: 42px; }
+</style>
+<div id=outer>
+ <div id=target></div>
+</div>
+<script>
+test_computed_value('view-timeline-inset', 'initial', 'auto');
+test_computed_value('view-timeline-inset', 'inherit', '1px 2px, auto 3px');
+test_computed_value('view-timeline-inset', 'unset', 'auto');
+test_computed_value('view-timeline-inset', 'revert', 'auto');
+test_computed_value('view-timeline-inset', '1px');
+test_computed_value('view-timeline-inset', '1%');
+test_computed_value('view-timeline-inset', 'calc(1% + 1px)');
+test_computed_value('view-timeline-inset', '1px 2px');
+test_computed_value('view-timeline-inset', '1px 2em', '1px 20px');
+test_computed_value('view-timeline-inset', 'calc(1px + 1em) 2px', '11px 2px');
+test_computed_value('view-timeline-inset', '1px 2px, 3px 4px');
+test_computed_value('view-timeline-inset', '1px auto, auto 4px');
+test_computed_value('view-timeline-inset', '1px, 2px, 3px');
+test_computed_value('view-timeline-inset', '1px 1px, 2px 3px', '1px, 2px 3px');
+test_computed_value('view-timeline-inset', 'auto auto, auto auto', 'auto, auto');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('view-timeline-inset'), -1);
+}, 'The view-timeline-inset property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('view-timeline-inset'), -1);
+}, 'The view-timeline-inset property shows up in CSSStyleDeclaration.cssText');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-parsing.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-parsing.html
new file mode 100644
index 0000000000..d502b13593
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-inset-parsing.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-inset">
+<link rel="help" href="https://github.com/w3c/csswg-drafts/issues/7243">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<div id="target"></div>
+<script>
+test_valid_value('view-timeline-inset', 'initial');
+test_valid_value('view-timeline-inset', 'inherit');
+test_valid_value('view-timeline-inset', 'unset');
+test_valid_value('view-timeline-inset', 'revert');
+
+test_valid_value('view-timeline-inset', '1px');
+test_valid_value('view-timeline-inset', '1px 2px');
+test_valid_value('view-timeline-inset', '1px 2em');
+test_valid_value('view-timeline-inset', 'calc(1em + 1px) 2px');
+test_valid_value('view-timeline-inset', '1px 2px, 3px 4px');
+test_valid_value('view-timeline-inset', '1px auto, auto 4px');
+test_valid_value('view-timeline-inset', '1px, 2px, 3px');
+test_valid_value('view-timeline-inset', '1px 1px, 2px 3px', '1px, 2px 3px');
+test_valid_value('view-timeline-inset', 'auto auto, auto auto', 'auto, auto');
+
+test_invalid_value('view-timeline-inset', 'none');
+test_invalid_value('view-timeline-inset', 'foo bar');
+test_invalid_value('view-timeline-inset', '"foo" "bar"');
+test_invalid_value('view-timeline-inset', 'rgb(1, 2, 3)');
+test_invalid_value('view-timeline-inset', '#fefefe');
+test_invalid_value('view-timeline-inset', '1px 2px 3px');
+test_invalid_value('view-timeline-inset', '1px 2px auto');
+test_invalid_value('view-timeline-inset', 'auto 2px 3px');
+test_invalid_value('view-timeline-inset', 'auto auto auto');
+test_invalid_value('view-timeline-inset', '1px / 2px');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-keyframe-boundary-interpolation.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-keyframe-boundary-interpolation.html
new file mode 100644
index 0000000000..011f03cb5d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-keyframe-boundary-interpolation.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ cover 0% { /* resolves to -100% */
+ opacity: 0;
+ transform: none;
+ margin-left: 0px;
+ /* missing margin-right -- requires neutral keyframe at 0% */
+ }
+ cover 100% { /* resolves to 200% */
+ opacity: 1;
+ transform: translateX(300px);
+ margin-right: 0px;
+ /* missing margin-left -- requires neutral keyframe at 100% */
+ }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: --t1;
+ animation-range-start: contain 0%;
+ animation-range-end: contain 100%;
+ view-timeline: --t1 block;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function assert_progress_equals(anim, expected, errorMessage) {
+ assert_approx_equals(
+ anim.effect.getComputedTiming().progress,
+ expected, 1e-6, errorMessage);
+ }
+
+ function assert_opacity_equals(expected, errorMessage) {
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected, 1e-6,
+ errorMessage);
+ }
+
+ function assert_translate_x_equals(expected, errorMessage) {
+ const style = getComputedStyle(target).transform;
+ const regex = /matrix\(([^\)]*)\)/;
+ const captureGroupIndex = 1;
+ const translateIndex = 4;
+ const match = style.match(regex)[captureGroupIndex];
+ const translateX = parseFloat(match.split(',')[translateIndex].trim());
+ assert_approx_equals(translateX, expected, 1e-6, errorMessage);
+ }
+
+ function assert_property_equals(property, expected, errorMessage) {
+ const value = parseFloat(getComputedStyle(target)[property]);
+ assert_approx_equals(value, expected, 1e-6, errorMessage);
+ }
+
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anims = document.getAnimations();
+ assert_equals(anims.length, 1,
+ "Should have one animation attatched to the view-timeline");
+ const anim = anims[0];
+ await anim.ready;
+ await waitForNextFrame();
+
+ // @ contain 0%
+ scroller.scrollTop = 700;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 0, 'progress at contain 0%');
+ assert_translate_x_equals(100, 'translation at contain 0%');
+ assert_opacity_equals(1/3, 'opacity at contain 0%');
+ assert_property_equals('margin-left', 5, 'margin-left at contain 0%');
+ assert_property_equals('margin-right', 10, 'margin-right at contain 0%');
+
+ // @ contain 50%
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 0.5, 'progress at contain 50%');
+ assert_translate_x_equals(150, 'translation at contain 50%');
+ assert_opacity_equals(0.5, 'opacity at contain 50%');
+ assert_property_equals('margin-left', 7.5, 'margin-left at contain 50%');
+ assert_property_equals('margin-right', 7.5, 'margin-right at contain 50%');
+
+ // @ contain 100%
+ scroller.scrollTop = 800;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 1, 'progress at contain 100%');
+ assert_translate_x_equals(200, 'translation at contain 100%');
+ assert_opacity_equals(2/3, 'opacity at contain 100%');
+ assert_property_equals('margin-left', 10, 'margin-left at contain 100%');
+ assert_property_equals('margin-right', 5, 'margin-right at contain 100%');
+ }, 'ViewTimeline with timeline offset keyframes outside [0,1]');
+ }
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-lookup.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-lookup.html
new file mode 100644
index 0000000000..067ac1fa96
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-lookup.html
@@ -0,0 +1,253 @@
+<!DOCTYPE html>
+<title>Named view-timeline lookup</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timelines-named">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+ .scroller {
+ overflow: auto;
+ width: 100px;
+ height: 100px;
+ }
+ .scroller > div {
+ height: 25px;
+ z-index: -1;
+ }
+</style>
+<main id=main></main>
+<script>
+ async function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ return runAndWaitForFrameUpdate(() => {
+ main.append(template.content.cloneNode(true));
+ });
+ }
+</script>
+
+<template id=timeline_self>
+ <style>
+ #target {
+ height: 0px;
+ view-timeline: --t1;
+ animation: anim 1s linear forwards;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=target></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_self);
+ assert_equals(getComputedStyle(target).zIndex, '100');
+ }, 'view-timeline on self');
+</script>
+
+<template id=timeline_preceding_sibling>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ #timeline {
+ height: 0px;
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div id=timeline></div>
+ <div></div>
+ <div></div>
+ <div id=target></div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_preceding_sibling);
+ assert_equals(getComputedStyle(target).zIndex, '75');
+ }, 'timeline-scope on preceding sibling');
+</script>
+
+<template id=timeline_ancestor>
+ <style>
+ #timeline {
+ height: 0px;
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div id=timeline>
+ <div>
+ <div id=target></div>
+ </div>
+ </div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_ancestor);
+ assert_equals(getComputedStyle(target).zIndex, '25');
+ }, 'view-timeline on ancestor');
+</script>
+
+<template id=timeline_ancestor_sibling>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ #timeline {
+ height: 0px;
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div id=timeline></div>
+ <div></div>
+ <div>
+ <div>
+ <div id=target></div>
+ </div>
+ </div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_ancestor_sibling);
+ assert_equals(getComputedStyle(target).zIndex, '75');
+ }, 'timeline-scope on ancestor sibling');
+</script>
+
+<template id=timeline_ancestor_sibling_conflict>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ #timeline1, #timeline2 {
+ height: 0px;
+ view-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div id=timeline1></div>
+ <div></div>
+ <div id=timeline2></div>
+ <div>
+ <div>
+ <div id=target></div>
+ </div>
+ </div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_ancestor_sibling_conflict);
+ assert_equals(getComputedStyle(target).zIndex, 'auto');
+ }, 'timeline-scope on ancestor sibling, conflict remains unresolved');
+</script>
+
+<template id=timeline_ancestor_closer_timeline_wins>
+ <style>
+ #scroller {
+ timeline-scope: --t1;
+ }
+ #timeline {
+ height: 0px;
+ view-timeline: --t1;
+ }
+ #parent {
+ timeline-scope: --t1; /* Inactive */
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div></div>
+ <div id=timeline></div>
+ <div></div>
+ <div id=parent>
+ <div id=target></div>
+ </div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_ancestor_closer_timeline_wins);
+ assert_equals(getComputedStyle(target).zIndex, 'auto');
+ }, 'timeline-scope on ancestor sibling, closer timeline wins');
+</script>
+
+<template id=timeline_ancestor_scroll_timeline_wins_on_same_element>
+ <style>
+ #scroller {
+ view-timeline: --t1;
+ view-timeline-inset: 50px;
+ scroll-timeline: --t1;
+ }
+ #target {
+ animation: anim 1s linear;
+ animation-timeline: --t1;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=target></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ await inflate(t, timeline_ancestor_scroll_timeline_wins_on_same_element);
+ // In case of a name conflict on the same element, scroll progress timelines
+ // take precedence over view progress timelines.
+ // https://drafts.csswg.org/scroll-animations-1/#timeline-scope
+ assert_equals(getComputedStyle(target).zIndex, '0');
+ }, 'view-timeline on ancestor sibling, scroll-timeline wins on same element');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-name-computed.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-computed.html
new file mode 100644
index 0000000000..3304723f43
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-computed.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-name">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+</head>
+<style>
+ #outer { view-timeline-name: --foo, --bar; }
+ #target { view-timeline-name: --faz; }
+</style>
+<div id=outer>
+ <div id=target></div>
+</div>
+<script>
+test_computed_value('view-timeline-name', 'initial', 'none');
+test_computed_value('view-timeline-name', 'inherit', '--foo, --bar');
+test_computed_value('view-timeline-name', 'unset', 'none');
+test_computed_value('view-timeline-name', 'revert', 'none');
+test_computed_value('view-timeline-name', 'none');
+test_computed_value('view-timeline-name', '--foo');
+test_computed_value('view-timeline-name', '--foo, --bar');
+test_computed_value('view-timeline-name', '--bar, --foo');
+test_computed_value('view-timeline-name', '--a, --b, --c, --D, --e');
+test_computed_value('view-timeline-name', 'none, none');
+test_computed_value('view-timeline-name', '--a, --b, --c, none, --d, --e');
+
+test(() => {
+ let style = getComputedStyle(document.getElementById('target'));
+ assert_not_equals(Array.from(style).indexOf('view-timeline-name'), -1);
+}, 'The view-timeline-name property shows up in CSSStyleDeclaration enumeration');
+
+test(() => {
+ let style = document.getElementById('target').style;
+ assert_not_equals(style.cssText.indexOf('view-timeline-name'), -1);
+}, 'The view-timeline-name property shows up in CSSStyleDeclaration.cssText');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-name-parsing.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-parsing.html
new file mode 100644
index 0000000000..2b22cbe036
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-parsing.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-name">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<div id="target"></div>
+<script>
+test_valid_value('view-timeline-name', 'initial');
+test_valid_value('view-timeline-name', 'inherit');
+test_valid_value('view-timeline-name', 'unset');
+test_valid_value('view-timeline-name', 'revert');
+
+test_valid_value('view-timeline-name', 'none');
+test_valid_value('view-timeline-name', '--abc');
+test_valid_value('view-timeline-name', ' --abc', '--abc');
+test_valid_value('view-timeline-name', '--aBc');
+test_valid_value('view-timeline-name', '--foo, --bar');
+test_valid_value('view-timeline-name', '--bar, --foo');
+test_valid_value('view-timeline-name', 'none, none');
+test_valid_value('view-timeline-name', '--a, none, --b');
+
+test_invalid_value('view-timeline-name', 'auto');
+test_invalid_value('view-timeline-name', 'abc');
+test_invalid_value('view-timeline-name', 'default');
+test_invalid_value('view-timeline-name', '10px');
+test_invalid_value('view-timeline-name', 'foo bar');
+test_invalid_value('view-timeline-name', '"foo" "bar"');
+test_invalid_value('view-timeline-name', 'rgb(1, 2, 3)');
+test_invalid_value('view-timeline-name', '#fefefe');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-name-shadow.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-shadow.html
new file mode 100644
index 0000000000..11902a3c6e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-name-shadow.html
@@ -0,0 +1,181 @@
+<!DOCTYPE html>
+<title>view-timeline-name and and shadow trees</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timelines-named">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/8135">
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/8192">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+
+<main id=main></main>
+<script>
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ main.offsetTop;
+ }
+</script>
+<style>
+ @keyframes anim {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+</style>
+
+
+<template id=view_timeline_host>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ .scroller > div {
+ view-timeline: --timeline x;
+ }
+ </style>
+ <div class=scroller>
+ <div>
+ <div class=target>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ :host {
+ view-timeline: --timeline y;
+ }
+ </style>
+ </template>
+ </div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, view_timeline_host);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Outer animation can see view timeline defined by :host');
+</script>
+
+
+<template id=view_timeline_slotted>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ .host {
+ view-timeline: --timeline x;
+ }
+ </style>
+ <div class=scroller>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ ::slotted(.target) {
+ view-timeline: --timeline y;
+ }
+ </style>
+ <slot></slot>
+ </template>
+ <div class=target></div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, view_timeline_slotted);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Outer animation can see view timeline defined by ::slotted');
+</script>
+
+
+<template id=view_timeline_part>
+ <style>
+ .host {
+ view-timeline: --timeline y;
+ }
+ .host::part(foo) {
+ view-timeline: --timeline x;
+ }
+ </style>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ /* Not using 'anim' at document scope, due to https://crbug.com/1334534 */
+ @keyframes anim2 {
+ from { z-index: 100; }
+ to { z-index: 100; }
+ }
+ .target {
+ animation: anim2 10s linear;
+ animation-timeline: --timeline;
+ }
+ </style>
+ <div part=foo>
+ <div class=target></div>
+ </div>
+ </template>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, view_timeline_part);
+ let target = main.querySelector('.host').shadowRoot.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'x');
+ }, 'Inner animation can see view timeline defined by ::part');
+</script>
+
+
+<template id=view_timeline_shadow>
+ <style>
+ .target {
+ animation: anim 10s linear;
+ animation-timeline: --timeline;
+ }
+ .host {
+ view-timeline: --timeline x;
+ }
+ </style>
+ <div class=scroller>
+ <div class=host>
+ <template shadowrootmode=open shadowrootclonable>
+ <style>
+ div {
+ view-timeline: --timeline y;
+ }
+ </style>
+ <div>
+ <slot></slot>
+ </div>
+ </template>
+ <div class=target></div>
+ </div>
+ </div>
+ <style>
+ </style>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, view_timeline_shadow);
+ let target = main.querySelector('.target');
+ assert_equals(target.getAnimations().length, 1);
+ let anim = target.getAnimations()[0];
+ assert_not_equals(anim.timeline, null);
+ assert_equals(anim.timeline.axis, 'y');
+ }, 'Slotted element can see view timeline within the shadow');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-range-animation.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-animation.html
new file mode 100644
index 0000000000..9e5993b63a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-animation.html
@@ -0,0 +1,232 @@
+<!DOCTYPE html>
+<title>View timelines and animation attachment ranges</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#animation-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; background-color: skyblue;}
+ to { z-index: 100; background-color: coral; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ width: 200px;
+ height: 200px;
+ }
+ #scroller > div {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ }
+ #target {
+ font-size: 10px;
+ background-color: green;
+ z-index: -1;
+ }
+</style>
+<main id=main>
+</main>
+
+<template id=template_without_scope>
+ <div id=scroller>
+ <div id=target class=timeline></div>
+ </div>
+</template>
+
+<template id=template_with_scope>
+ <div id=scope>
+ <div id=target></div>
+ <div id=scroller>
+ <div class=timeline></div>
+ </div>
+ </div>
+</template>
+
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function waitForAnimationReady(target) {
+ await waitForNextFrame();
+ await Promise.all(target.getAnimations().map(x => x.ready));
+ }
+ async function assertValueAt(scroller, target, args) {
+ await waitForAnimationReady(target);
+ await scrollTop(scroller, args.scrollTop);
+ assert_equals(getComputedStyle(target).zIndex, args.expected.toString());
+ }
+ function test_animation_range(options, template, desc_suffix) {
+ if (template === undefined)
+ template = template_without_scope;
+ if (desc_suffix === undefined)
+ desc_suffix = '';
+
+ promise_test(async (t) => {
+ inflate(t, template);
+ let scroller = main.querySelector('#scroller');
+ let target = main.querySelector('#target');
+ let timeline = main.querySelector('.timeline');
+ let scope = main.querySelector('#scope');
+
+ if (scope != null) {
+ scope.style.timelineScope = '--t1';
+ }
+
+ timeline.style.viewTimeline = '--t1';
+ target.style.animation = 'anim auto linear';
+ target.style.animationTimeline = '--t1';
+ target.style.animationRangeStart = options.rangeStart;
+ target.style.animationRangeEnd = options.rangeEnd;
+
+ // Accommodates floating point precision errors at the endpoints.
+ target.style.animationFillMode = 'both';
+
+ // 0%
+ await assertValueAt(scroller, target,
+ { scrollTop: options.startOffset, expected: 0 });
+ // 50%
+ await assertValueAt(scroller, target,
+ { scrollTop: (options.startOffset + options.endOffset) / 2, expected: 50 });
+ // 100%
+ await assertValueAt(scroller, target,
+ { scrollTop: options.endOffset, expected: 100 });
+
+ // Test before/after phases (need to clear the fill mode for that).
+ target.style.animationFillMode = 'initial';
+ await assertValueAt(scroller, target,
+ { scrollTop: options.startOffset - 10, expected: -1 });
+ await assertValueAt(scroller, target,
+ { scrollTop: options.endOffset + 10, expected: -1 });
+ // Check 50% again without fill mode.
+ await assertValueAt(scroller, target,
+ { scrollTop: (options.startOffset + options.endOffset) / 2, expected: 50 });
+
+ }, `Animation with ranges [${options.rangeStart}, ${options.rangeEnd}] ${desc_suffix}`.trim());
+ }
+
+ test_animation_range({
+ rangeStart: 'initial',
+ rangeEnd: 'initial',
+ startOffset: 600,
+ endOffset: 900
+ });
+
+ test_animation_range({
+ rangeStart: 'cover 0%',
+ rangeEnd: 'cover 100%',
+ startOffset: 600,
+ endOffset: 900
+ });
+
+ test_animation_range({
+ rangeStart: 'contain 0%',
+ rangeEnd: 'contain 100%',
+ startOffset: 700,
+ endOffset: 800
+ });
+
+
+ test_animation_range({
+ rangeStart: 'entry 0%',
+ rangeEnd: 'entry 100%',
+ startOffset: 600,
+ endOffset: 700
+ });
+
+ test_animation_range({
+ rangeStart: 'exit 0%',
+ rangeEnd: 'exit 100%',
+ startOffset: 800,
+ endOffset: 900
+ });
+
+ test_animation_range({
+ rangeStart: 'contain -50%',
+ rangeEnd: 'entry 200%',
+ startOffset: 650,
+ endOffset: 800
+ });
+
+ test_animation_range({
+ rangeStart: 'entry 0%',
+ rangeEnd: 'exit 100%',
+ startOffset: 600,
+ endOffset: 900
+ });
+
+ test_animation_range({
+ rangeStart: 'cover 20px',
+ rangeEnd: 'cover 100px',
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ test_animation_range({
+ rangeStart: 'contain 20px',
+ rangeEnd: 'contain 100px',
+ startOffset: 720,
+ endOffset: 800
+ });
+
+ test_animation_range({
+ rangeStart: 'entry 20px',
+ rangeEnd: 'entry 100px',
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ test_animation_range({
+ rangeStart: 'entry-crossing 20px',
+ rangeEnd: 'entry-crossing 100px',
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ test_animation_range({
+ rangeStart: 'exit 20px',
+ rangeEnd: 'exit 80px',
+ startOffset: 820,
+ endOffset: 880
+ });
+
+ test_animation_range({
+ rangeStart: 'exit-crossing 20px',
+ rangeEnd: 'exit-crossing 80px',
+ startOffset: 820,
+ endOffset: 880
+ });
+
+ test_animation_range({
+ rangeStart: 'contain 20px',
+ rangeEnd: 'contain calc(100px - 10%)',
+ startOffset: 720,
+ endOffset: 790
+ });
+
+ test_animation_range({
+ rangeStart: 'exit 2em',
+ rangeEnd: 'exit 8em',
+ startOffset: 820,
+ endOffset: 880
+ });
+
+ // Test animation-range via timeline-scope.
+ test_animation_range({
+ rangeStart: 'exit 2em',
+ rangeEnd: 'exit 8em',
+ startOffset: 820,
+ endOffset: 880
+ }, template_with_scope, '(scoped)');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update-reversed-animation.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update-reversed-animation.html
new file mode 100644
index 0000000000..960a8e6ecf
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update-reversed-animation.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Update timeline range on reversed animation refTest</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="match" href="./animation-update-ref.html?translate=60px">
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: translateX(100px) }
+ to { transform: translateX(0px) }
+ }
+ #scroller {
+ border: 1px solid black;
+ overflow: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: --timeline;
+ view-timeline: --timeline;
+ }
+ #target.exit-range {
+ animation-range-start: exit 0%;
+ animation-range-end: exit 100%;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ await waitForCompositorReady();
+
+ const anim = target.getAnimations()[0];
+ anim.playbackRate = -1;
+
+ // Scroll to exit 60%.
+ scroller.scrollTop = 860;
+ await waitForNextFrame();
+
+ // Update the animation range.
+ target.classList.add('exit-range');
+ await waitForNextFrame();
+
+ // Make sure change to animation range was properly picked up.
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update.html
new file mode 100644
index 0000000000..e1938caf50
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-range-update.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Update timeline range refTest</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="match" href="./animation-update-ref.html?translate=40px">
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: translateX(100px) }
+ to { transform: translateX(0px) }
+ }
+ #scroller {
+ border: 1px solid black;
+ overflow: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: --timeline;
+ view-timeline: --timeline;
+ }
+ #target.exit-range {
+ animation-range-start: exit 0%;
+ animation-range-end: exit 100%;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ await waitForCompositorReady();
+
+ // Scroll to exit 60%.
+ scroller.scrollTop = 860;
+ await waitForNextFrame();
+
+ // Update the animation range.
+ target.classList.add('exit-range');
+ await waitForNextFrame();
+
+ // Make sure change to animation range was properly picked up.
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-shorthand.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-shorthand.html
new file mode 100644
index 0000000000..9027eb0b09
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-shorthand.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timeline-shorthand">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/css/support/computed-testcommon.js"></script>
+<script src="/css/support/parsing-testcommon.js"></script>
+<script src="/css/support/shorthand-testcommon.js"></script>
+<div id="target"></div>
+<script>
+test_valid_value('view-timeline', '--abcd');
+test_valid_value('view-timeline', 'none block', 'none');
+test_valid_value('view-timeline', 'none inline');
+
+// view-timeline-name: inline/block/x/y.
+test_valid_value('view-timeline', '--inline block', '--inline');
+test_valid_value('view-timeline', '--block block', '--block');
+test_valid_value('view-timeline', '--y block', '--y');
+test_valid_value('view-timeline', '--x block', '--x');
+
+test_valid_value('view-timeline', '--a, --b, --c');
+test_valid_value('view-timeline', '--a inline, --b block, --c y', '--a inline, --b, --c y');
+test_valid_value('view-timeline', '--auto');
+test_valid_value('view-timeline', '--abcd block auto', '--abcd');
+test_valid_value('view-timeline', '--abcd block auto auto', '--abcd');
+test_valid_value('view-timeline', '--abcd block 1px 2px', '--abcd 1px 2px');
+test_valid_value('view-timeline', '--abcd inline 1px 2px', '--abcd inline 1px 2px');
+test_valid_value('view-timeline', '--abcd 1px 2px inline', '--abcd inline 1px 2px');
+test_valid_value('view-timeline', '--abcd 1px 2px block', '--abcd 1px 2px');
+test_valid_value('view-timeline', '--abcd auto auto block', '--abcd');
+test_valid_value('view-timeline', '--abcd auto block', '--abcd');
+test_valid_value('view-timeline', '--abcd block 1px 1px', '--abcd 1px');
+
+test_invalid_value('view-timeline', '--abc --abc');
+test_invalid_value('view-timeline', 'block none');
+test_invalid_value('view-timeline', 'none none');
+test_invalid_value('view-timeline', 'default');
+test_invalid_value('view-timeline', ',');
+test_invalid_value('view-timeline', ',,--block,,');
+test_invalid_value('view-timeline', 'auto');
+test_invalid_value('view-timeline', 'auto auto');
+test_invalid_value('view-timeline', '--abc 500kg');
+test_invalid_value('view-timeline', '--abc #ff0000');
+test_invalid_value('view-timeline', '--abc red red');
+
+test_computed_value('view-timeline', '--abcd');
+test_computed_value('view-timeline', 'none block', 'none');
+test_computed_value('view-timeline', 'none inline');
+test_computed_value('view-timeline', '--inline block', '--inline');
+test_computed_value('view-timeline', '--block block', '--block');
+test_computed_value('view-timeline', '--y block', '--y');
+test_computed_value('view-timeline', '--x block', '--x');
+test_computed_value('view-timeline', '--a, --b, --c');
+test_computed_value('view-timeline', '--a inline, --b block, --c y', '--a inline, --b, --c y');
+test_computed_value('view-timeline', '--abcd block auto', '--abcd');
+test_computed_value('view-timeline', '--abcd block auto auto', '--abcd');
+test_computed_value('view-timeline', '--abcd block 1px 2px', '--abcd 1px 2px');
+test_computed_value('view-timeline', '--abcd inline 1px 2px', '--abcd inline 1px 2px');
+test_computed_value('view-timeline', '--abcd 1px 2px inline', '--abcd inline 1px 2px');
+test_computed_value('view-timeline', '--abcd 1px 2px block', '--abcd 1px 2px');
+test_computed_value('view-timeline', '--abcd auto auto block', '--abcd');
+test_computed_value('view-timeline', '--abcd auto block', '--abcd');
+test_computed_value('view-timeline', '--abcd block 1px 1px', '--abcd 1px');
+
+test_shorthand_value('view-timeline', '--abc y',
+{
+ 'view-timeline-name': '--abc',
+ 'view-timeline-axis': 'y',
+ 'view-timeline-inset': 'auto',
+});
+test_shorthand_value('view-timeline', '--abc y, --def',
+{
+ 'view-timeline-name': '--abc, --def',
+ 'view-timeline-axis': 'y, block',
+ 'view-timeline-inset': 'auto, auto',
+});
+test_shorthand_value('view-timeline', '--abc, --def',
+{
+ 'view-timeline-name': '--abc, --def',
+ 'view-timeline-axis': 'block, block',
+ 'view-timeline-inset': 'auto, auto',
+});
+test_shorthand_value('view-timeline', '--inline x',
+{
+ 'view-timeline-name': '--inline',
+ 'view-timeline-axis': 'x',
+ 'view-timeline-inset': 'auto',
+});
+test_shorthand_value('view-timeline', '--abc 1px 2px',
+{
+ 'view-timeline-name': '--abc',
+ 'view-timeline-axis': 'block',
+ 'view-timeline-inset': '1px 2px',
+});
+test_shorthand_value('view-timeline', '--abc 1px',
+{
+ 'view-timeline-name': '--abc',
+ 'view-timeline-axis': 'block',
+ 'view-timeline-inset': '1px',
+});
+test_shorthand_value('view-timeline', '--abc 1px inline',
+{
+ 'view-timeline-name': '--abc',
+ 'view-timeline-axis': 'inline',
+ 'view-timeline-inset': '1px',
+});
+
+function test_shorthand_contraction(shorthand, longhands, expected) {
+ let longhands_fmt = Object.entries(longhands).map((e) => `${e[0]}:${e[1]}:${e[2]}`).join(';');
+ test((t) => {
+ t.add_cleanup(() => {
+ for (let shorthand of Object.keys(longhands))
+ target.style.removeProperty(shorthand);
+ });
+ for (let [shorthand, value] of Object.entries(longhands))
+ target.style.setProperty(shorthand, value);
+ assert_equals(target.style.getPropertyValue(shorthand), expected, 'Declared value');
+ assert_equals(getComputedStyle(target).getPropertyValue(shorthand), expected, 'Computed value');
+ }, `Shorthand contraction of ${longhands_fmt}`);
+}
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--abc',
+ 'view-timeline-axis': 'inline',
+ 'view-timeline-inset': 'auto',
+}, '--abc inline');
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--a, --b',
+ 'view-timeline-axis': 'inline, block',
+ 'view-timeline-inset': 'auto, auto',
+}, '--a inline, --b');
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--a, --b',
+ 'view-timeline-axis': 'inline, block',
+ 'view-timeline-inset': '1px 2px, 3px 3px',
+}, '--a inline 1px 2px, --b 3px');
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': 'none, none',
+ 'view-timeline-axis': 'block, block',
+ 'view-timeline-inset': 'auto auto, auto',
+}, 'none, none');
+
+// Longhands with different lengths:
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--a, --b, --c',
+ 'view-timeline-axis': 'inline, inline',
+ 'view-timeline-inset': 'auto, auto',
+}, '--a inline, --b inline, --c inline');
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--a, --b',
+ 'view-timeline-axis': 'inline, inline, inline',
+ 'view-timeline-inset': 'auto, auto, auto',
+}, '--a inline, --b inline');
+
+test_shorthand_contraction('view-timeline', {
+ 'view-timeline-name': '--a, --b',
+ 'view-timeline-axis': 'inline, inline',
+ 'view-timeline-inset': 'auto, auto, auto',
+}, '--a inline, --b inline');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-subject-bounds-update.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-subject-bounds-update.html
new file mode 100644
index 0000000000..2961fedd42
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-subject-bounds-update.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Update subject bounds refTest</title>
+<link rel="help" src="https://github.com/w3c/csswg-drafts/issues/8694">
+<link rel="match"
+ href="./animation-update-ref.html?translate=100px&scroll=800">
+<script src="/web-animations/testcommon.js"></script>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: translateX(100px) }
+ to { transform: translateX(0px) }
+ }
+ #scroller {
+ border: 1px solid black;
+ overflow: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-bottom: 800px;
+ margin-top: 700px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 200px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: --timeline;
+ view-timeline: --timeline;
+ animation-range: exit;
+ }
+ #target.bounds-update {
+ height: 100px;
+ /* Keep the scroll range the same. */
+ margin-top: 800px;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ document.documentElement.addEventListener('TestRendered', async () => {
+ runTest();
+ }, { once: true });
+
+ async function runTest() {
+ await waitForCompositorReady();
+
+ const anim = target.getAnimations()[0];
+
+ // Scroll to exit 50%.
+ scroller.scrollTop = 800;
+ await waitForNextFrame();
+
+ // After the update to the animation range, the positioning is exit 0%
+ target.classList.add('bounds-update');
+ await waitForNextFrame();
+
+ // Make sure change to animation range was properly picked up.
+ document.documentElement.classList.remove("reftest-wait");
+ }
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-used-values.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-used-values.html
new file mode 100644
index 0000000000..1bd6f0468c
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-used-values.html
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<title>Used values of view-timeline properties</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timeline-axis">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#view-timeline-name">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ @keyframes anim {
+ from { z-index: 0; }
+ to { z-index: 100; }
+ }
+ .scroller {
+ overflow: hidden;
+ width: 100px;
+ height: 100px;
+ }
+ .scroller > div {
+ width: 300px;
+ height: 300px;
+ z-index: -1;
+ }
+</style>
+<main id=main></main>
+<script>
+ setup(assert_implements_animation_timeline);
+
+ function inflate(t, template) {
+ t.add_cleanup(() => main.replaceChildren());
+ main.append(template.content.cloneNode(true));
+ }
+ async function scrollTop(e, value) {
+ e.scrollTop = value;
+ await waitForNextFrame();
+ }
+ async function scrollLeft(e, value) {
+ e.scrollLeft = value;
+ await waitForNextFrame();
+ }
+</script>
+
+<template id=omitted_axis>
+ <style>
+ #target {
+ view-timeline-name: --t1, --t2; /* Two items */
+ view-timeline-axis: inline; /* One item */
+ animation: anim 1s linear;
+ animation-timeline: --t2;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, omitted_axis);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+
+ // enter 0% is at scrollTop/Left = -100
+ // exit 100% is at scrollTop/Left = 300
+ // This means that at scrollTop/Left=0, the animation is at 25%.
+
+ await scrollTop(scroller, 0);
+ await scrollLeft(scroller, 0);
+ assert_equals(getComputedStyle(target).zIndex, '25');
+
+ // The timeline should be inline-axis:
+ await scrollTop(scroller, 100); // 50%
+ await scrollLeft(scroller, 40); // 35%
+ assert_equals(getComputedStyle(target).zIndex, '35');
+ }, 'Use the last value from view-timeline-axis if omitted');
+</script>
+
+<template id=omitted_inset>
+ <style>
+ #target {
+ view-timeline-name: --t1, --t2; /* Two items */
+ view-timeline-inset: 100px; /* One item */
+ animation: anim 1s linear;
+ animation-timeline: --t2;
+ }
+ </style>
+ <div id=scroller class=scroller>
+ <div id=target></div>
+ </div>
+</template>
+<script>
+ promise_test(async (t) => {
+ inflate(t, omitted_inset);
+ assert_equals(getComputedStyle(target).zIndex, '-1');
+
+ // 0% is normally at at scrollTop = -100
+ // 100% is normally at scrollTop/Left = 300
+ // However, we have a 100px inset in both ends, which makes the
+ // range [0, 200].
+
+ await scrollTop(scroller, 0);
+ assert_equals(getComputedStyle(target).zIndex, '0');
+ await scrollTop(scroller, 100); // 50%
+ assert_equals(getComputedStyle(target).zIndex, '50');
+ }, 'Use the last value from view-timeline-inset if omitted');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-with-delay-and-range.tentative.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-with-delay-and-range.tentative.html
new file mode 100644
index 0000000000..db260f15f0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-with-delay-and-range.tentative.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { opacity: 0 }
+ to { opacity: 1 }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 0px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ view-timeline: --t1 block;
+ animation-range-start: entry 0%;
+ animation-range-end: entry 100%;
+ /* Sentinel value when in before or after phase of the animation. */
+ opacity: 0.96875;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+
+ function assert_opacity_equals(expected, errorMessage) {
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected, 1e-6,
+ errorMessage);
+ }
+
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+
+ await waitForNextFrame();
+ scroller.scrollTop = 650;
+ await waitForNextFrame();
+
+ const baseOpacity = 0.96875;
+ // Delays are percentages.
+ const testData = [
+ { delay: 0, endDelay: 0, opacity: 0.5 },
+ { delay: 20, endDelay: 0, opacity: 0.375 },
+ { delay: 0, endDelay: 20, opacity: 0.625 },
+ { delay: 20, endDelay: 20, opacity: 0.5 },
+ // Negative delays.
+ { delay: -25, endDelay: 0, opacity: 0.6 },
+ { delay: 0, endDelay: -25, opacity: 0.4 },
+ { delay: -25, endDelay: -25, opacity: 0.5 },
+ // Stress tests with >= 100% total delay. Verify effect is inactive.
+ { delay: 100, endDelay: 0, opacity: baseOpacity },
+ { delay: 0, endDelay: 100, opacity: baseOpacity },
+ { delay: 100, endDelay: 100, opacity: baseOpacity }
+ ];
+
+ testData.forEach(test => {
+ anim.effect.updateTiming({
+ delay: CSS.percent(test.delay),
+ endDelay: CSS.percent(test.endDelay)
+ });
+ assert_opacity_equals(
+ test.opacity,
+ `Opacity when delay=${test.delay} and endDelay=${test.endDelay}`);
+ });
+ }, 'ViewTimeline with animation delays and range');
+ }
+
+ window.onload = runTest;
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/css/view-timeline-with-transform-on-subject.html b/testing/web-platform/tests/scroll-animations/css/view-timeline-with-transform-on-subject.html
new file mode 100644
index 0000000000..e4abac7219
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/css/view-timeline-with-transform-on-subject.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { transform: scaleX(0) translateY(0); }
+ to { transform: scaleX(1) translatey(50vh); }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ .spacer {
+ height: 200px;
+ }
+ #target {
+ height: 50px;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: view();
+ animation-range-start: contain 0%;
+ animation-range-end: contain 100%;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function assert_progress_equals(anim, expected, errorMessage) {
+ assert_approx_equals(
+ anim.effect.getComputedTiming().progress,
+ expected, 1e-6, errorMessage);
+ }
+
+ promise_test(async t => {
+ await waitForNextFrame();
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ await waitForNextFrame();
+
+ // @ contain 0%
+ scroller.scrollTop = 50;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 0, 'progress at contain 0%');
+
+ // @ contain 50%
+ scroller.scrollTop = 125;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 0.5, 'progress at contain 50%');
+
+ // @ contain 100%
+ scroller.scrollTop = 200;
+ await waitForNextFrame();
+ assert_progress_equals(anim, 1, 'progress at contain 100%');
+ }, 'ViewTimeline use untransformed box for range calculations');
+ }
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html
new file mode 100644
index 0000000000..9158715321
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Reference for Web Animation with scroll timeline tests</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html
new file mode 100644
index 0000000000..b04aaf2d33
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-animatable-interface.html
@@ -0,0 +1,66 @@
+<html class="reftest-wait">
+<title>Scroll-linked animation with Animatable interface</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="ScrollTimeline should work with animatable
+interface">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const scroller = document.getElementById('scroller');
+ const scroll_timeline = new ScrollTimeline({source: scroller});
+ const box = document.getElementById('box');
+ const animation = box.animate(
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ], {
+ timeline: scroll_timeline
+ }
+ );
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html
new file mode 100644
index 0000000000..9d821f9e20
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-delay-crash.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#the-effecttiming-dictionaries">
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<div class="scroller">
+ <div class="contents"></div>
+</div>
+<script>
+ // Test passes if it does not crash.
+ // Scroll timeline animations are progress-based and not compatible with
+ // delays specified in milliseconds.
+ const timeline = new ScrollTimeline();
+ const options = {
+ timeline: timeline,
+ endDelay: 200
+ };
+ const keyframes = { opacity: [0, 1]};
+ const element = document.querySelector('.contents');
+ element.animate(keyframes, options);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html
new file mode 100644
index 0000000000..a62916833c
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-display-none.html
@@ -0,0 +1,75 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation and transition from display:none to display:block</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Scroll timeline should properly handle going from display:none to display:block">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ .removed {
+ display: none;
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ scroller.classList.add('removed');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ waitForAnimationFrames(2).then(_ => {
+ scroller.classList.remove('removed');
+ animation.ready.then(() => {
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html
new file mode 100644
index 0000000000..d4d1a55214
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-offsets-crash.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scrolltimeline-interface">
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<div class="scroller">
+ <div class="contents"></div>
+</div>
+<script>
+ // Test passes if it does not crash.
+ // Scroll timeline animations are progress-based and not compatible with
+ // delays specified in milliseconds.
+ const scroller = document.querySelector('.scroller');
+ const animation = new Animation();
+ const timeline = animation.timeline;
+ const duration = timeline.duration;
+ const options = {
+ source: scroller,
+ scrollOffsets: [new CSSMathInvert(duration)]
+ };
+ const scroll_timeline = new ScrollTimeline(options);
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html
new file mode 100644
index 0000000000..c045f1a1c9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: hidden;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html
new file mode 100644
index 0000000000..bc7611d05a
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-overflow-hidden.html
@@ -0,0 +1,64 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation using a scroller with overflow hidden</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Web animation correctly updates values when using a overflow: hidden on the scroller being used as the source for the ScrollTimeline">
+<link rel="match" href="animation-with-overflow-hidden-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: hidden;
+ height: 100px;
+ width: 100px;
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ {transform: 'translateY(0)', opacity: 1},
+ {transform: 'translateY(200px)', opacity: 0}
+ ]
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html
new file mode 100644
index 0000000000..58435be631
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller-ref.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<title>Reference for Scroll timeline with Web Animation using the root scroller</title>
+<style>
+ html {
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"><p>Covered Contents</p></div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html
new file mode 100644
index 0000000000..6ba1a22445
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-root-scroller.html
@@ -0,0 +1,60 @@
+<html class="reftest-wait">
+<title>Scroll timeline with Web Animation using the root scroller</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Web animation correctly updates values when using the root scroller as the source for the ScrollTimeline">
+<link rel="match" href="animation-with-root-scroller-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ html {
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+ }
+
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"><p>Covered Contents</p></div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ {transform: 'translateY(0)', opacity: 1},
+ {transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.scrollingElement;
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html
new file mode 100644
index 0000000000..f741cc634d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/animation-with-transform.html
@@ -0,0 +1,68 @@
+<html class="reftest-wait">
+<title>Basic use of scroll timeline with Web Animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Should be able to use the scroll timeline to drive the animation timing">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html
new file mode 100644
index 0000000000..7daf76a7a5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/cancel-animation.html
@@ -0,0 +1,214 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Canceling an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#canceling-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+
+ assert_equals(animation.startTime, null,
+ 'The start time of a canceled animation should be unresolved');
+ assert_equals(animation.currentTime, null,
+ 'The hold time of a canceled animation should be unresolved');
+}, 'Canceling an animation should cause its start time and hold time to be'
+ + ' unresolved');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ const retPromise = animation.ready.then(() => {
+ assert_unreached('ready promise was fulfilled');
+ }).catch(err => {
+ assert_equals(err.name, 'AbortError',
+ 'ready promise is rejected with AbortError');
+ });
+
+ animation.cancel();
+
+ return retPromise;
+}, 'A play-pending ready promise should be rejected when the animation is'
+ + ' canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make it pause-pending
+ animation.pause();
+
+ // We need to store the original ready promise since cancel() will
+ // replace it
+ const originalPromise = animation.ready;
+ animation.cancel();
+
+ await promise_rejects_dom(t, 'AbortError', originalPromise,
+ 'Cancel should abort ready promise');
+}, 'A pause-pending ready promise should be rejected when the animation is'
+ + ' canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+ const promiseResult = await animation.ready;
+ assert_equals(promiseResult, animation);
+}, 'When an animation is canceled, it should create a resolved Promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ const promise = animation.ready;
+ animation.cancel();
+ assert_not_equals(animation.ready, promise);
+ promise_rejects_dom(t, 'AbortError', promise,
+ 'Cancel should abort ready promise');
+}, 'The ready promise should be replaced when the animation is canceled');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.playState, 'idle',
+ 'The animation should be initially idle');
+
+ animation.finished.then(t.step_func(() => {
+ assert_unreached('Finished promise should not resolve');
+ }), t.step_func(() => {
+ assert_unreached('Finished promise should not reject');
+ }));
+
+ animation.cancel();
+
+ return waitForAnimationFrames(3);
+}, 'The finished promise should NOT be rejected if the animation is already'
+ + ' idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.playState, 'idle',
+ 'The animation should be initially idle');
+
+ animation.oncancel = t.step_func(() => {
+ assert_unreached('Cancel event should not be fired');
+ });
+
+ animation.cancel();
+
+ return waitForAnimationFrames(3);
+}, 'The cancel event should NOT be fired if the animation is already idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.effect.target.remove();
+
+ const eventWatcher = new EventWatcher(t, animation, 'cancel');
+
+ await animation.ready;
+ animation.cancel();
+
+ await eventWatcher.wait_for('cancel');
+
+ assert_equals(animation.effect.target.parentNode, null,
+ 'cancel event should be fired for the animation on an orphaned element');
+}, 'Canceling an animation should fire cancel event on orphaned element');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ animation.cancel();
+ assert_equals(animation.startTime, null,
+ 'The start time of a canceled animation should be unresolved');
+ assert_equals(animation.currentTime, null,
+ 'The current time of a canceled animation should be unresolved');
+}, 'Canceling an animation with inactive timeline should cause its start time'
+ + ' and hold time to be unresolved');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+
+ const eventWatcher = new EventWatcher(t, animation, 'cancel');
+ animation.cancel();
+ const cancelEvent = await eventWatcher.wait_for('cancel');
+
+ assert_equals(cancelEvent.currentTime, null,
+ 'event.currentTime should be unresolved when the timeline is inactive.');
+ assert_equals(cancelEvent.timelineTime, null,
+ 'event.timelineTime should be unresolved when the timeline is inactive');
+}, 'oncancel event is fired when the timeline is inactive.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html
new file mode 100644
index 0000000000..d2cc590bc7
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor-no-document.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline constructor - no document</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+'use strict';
+
+test(function() {
+ document.documentElement.remove();
+ assert_equals(document.scrollingElement, null);
+
+ const timeline = new ScrollTimeline();
+ assert_equals(timeline.source, null);
+ assert_equals(timeline.currentTime, null);
+}, 'The source can be null if the document.scrollingElement does not exist');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html
new file mode 100644
index 0000000000..88c6a453ec
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/constructor.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline constructor</title>
+ <link rel="help" href="https://wicg.github.io/scroll-animations/#scrolltimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+.scroller {
+ height: 100px;
+ width: 100px;
+ overflow: scroll;
+}
+
+.content {
+ height: 500px;
+ width: 500px;
+}
+</style>
+
+<div class="scroller">
+ <div class="content"></div>
+</div>
+
+<script>
+'use strict';
+
+function formatOffset(v) {
+ if (typeof(v) == 'object')
+ return `${v.constructor.name}(${v.toString()})`;
+ return `'${v.toString()}'`;
+}
+
+function assert_offsets_equal(a, b) {
+ assert_equals(formatOffset(a), formatOffset(b));
+}
+
+// source
+
+test(t => {
+ const scroller = document.querySelector('.scroller');
+ assert_equals(
+ new ScrollTimeline({source: scroller}).source, scroller);
+}, 'A ScrollTimeline can be created with a source');
+
+test(t => {
+ const div = document.createElement('div');
+ assert_equals(new ScrollTimeline({source: div}).source, div);
+}, 'A ScrollTimeline can be created with a non-scrolling source');
+
+test(t => {
+ assert_equals(new ScrollTimeline({source: null}).source, null);
+}, 'A ScrollTimeline created with a null source should have no source');
+
+test(t => {
+ assert_equals(new ScrollTimeline().source, document.scrollingElement);
+}, 'A ScrollTimeline created without a source should use the ' +
+ 'document.scrollingElement');
+
+// axis
+
+test(t => {
+ assert_equals(new ScrollTimeline().axis, 'block');
+}, 'A ScrollTimeline created with the default axis should default to ' +
+ `'block'`);
+
+const gValidAxisValues = [
+ 'block',
+ 'inline',
+ 'x',
+ 'y',
+];
+
+for (let axis of gValidAxisValues) {
+ test(function() {
+ const scrollTimeline =
+ new ScrollTimeline({axis: axis});
+ assert_equals(scrollTimeline.axis, axis);
+ }, `'${axis}' is a valid axis value`);
+}
+
+test(t => {
+ let constructorFunc = function() {
+ new ScrollTimeline({axis: 'nonsense'})
+ };
+ assert_throws_js(TypeError, constructorFunc);
+
+ // 'auto' for axis was previously in the spec, but was removed. Make
+ // sure that implementations do not support it.
+ constructorFunc = function() {
+ new ScrollTimeline({axis: 'auto'})
+ };
+ assert_throws_js(TypeError, constructorFunc);
+}, 'Creating a ScrollTimeline with an invalid axis value should throw');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html
new file mode 100644
index 0000000000..440b1f413e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-nan.html
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - NaN cases</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+.scroller {
+ height: 100px;
+ width: 100px;
+ overflow: auto;
+}
+
+.content {
+ height: 500px;
+ width: 500px;
+}
+</style>
+
+<div id='inlineScroller' class='scroller' style='display: inline;'>
+ <div class='content'></div>
+</div>
+<script>
+'use strict';
+
+test(function() {
+ const scroller = document.querySelector('#inlineScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for a display: inline source');
+</script>
+
+<div id='displayNoneScroller' class='scroller' style='display: none;'>
+ <div class='content'></div>
+</div>
+<script>
+test(function() {
+ const scroller = document.querySelector('#displayNoneScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for a display: none source');
+</script>
+
+<script>
+test(function() {
+ const scroller = document.createElement('div');
+ const content = document.createElement('div');
+
+ scroller.style.overflow = 'auto';
+ scroller.style.height = '100px';
+ scroller.style.width = '100px';
+ content.style.height = '250px';
+ content.style.width = '250px';
+
+ scroller.appendChild(content);
+
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null for an unattached source');
+</script>
+
+<div id='notAScroller' class='scroller' style='overflow: visible;'>
+ <div class='content'></div>
+</div>
+<script>
+test(function() {
+ const scroller = document.querySelector('#notAScroller');
+ const scrollTimeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+
+ assert_equals(scrollTimeline.currentTime, null);
+}, 'currentTime should be null when the source is not a scroller');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html
new file mode 100644
index 0000000000..be1d62bec5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-root-scroller.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - root scroller</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<style>
+html {
+ /* Ensure the document is scrollable. */
+ min-height: 100%;
+ min-width: 100%;
+ padding-bottom: 100px;
+ padding-right: 100px;
+}
+</style>
+
+<script>
+promise_test(async t => {
+ const scroller = document.scrollingElement;
+ // Allow layout to finish, otherwise the scroller isn't set up by the time
+ // we check the currentTime of the scroll timeline.
+ await waitForNextFrame();
+
+ const blockScrollTimeline = new ScrollTimeline(
+ { source: scroller, axis: 'block' });
+ const inlineScrollTimeline = new ScrollTimeline(
+ { source: scroller, axis: 'inline' });
+
+ // Wait for new animation frame which allows the timeline to fully initialize
+ await waitForNextFrame();
+
+ // Unscrolled, both timelines should read a currentTime of 0.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0);
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0);
+
+ // Now do some scrolling and make sure that the ScrollTimelines update.
+ scroller.scrollTop = 50;
+ scroller.scrollLeft = 75;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 50);
+ assert_percents_equal(inlineScrollTimeline.currentTime, 75);
+}, 'currentTime calculates the correct time for a document.scrollingElement source');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html
new file mode 100644
index 0000000000..748cda2f89
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/current-time-writing-modes.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline current time algorithm - interaction with writing modes</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<body></body>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['direction', 'rtl']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0 even though the
+ // X-axis will have started at the right hand side for rtl.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // The offset in the inline/horizontal direction should be inverted. The
+ // block/vertical direction should be unaffected.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = -0.8 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 10,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 80,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 80,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles direction: rtl correctly');
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['writing-mode', 'vertical-rl']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0 even though the
+ // X-axis will have started at the right hand side for vertical-rl.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // For vertical-rl, the X-axis starts on the right-hand-side and is the block
+ // axis. The Y-axis is normal but is the inline axis. For the
+ // horizontal/vertical cases, horizontal starts on the right-hand-side and
+ // vertical is normal.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = -0.8 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 80,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 10,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 80,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles writing-mode: vertical-rl correctly');
+
+promise_test(async t => {
+ const scrollerOverrides = new Map([['writing-mode', 'vertical-lr']]);
+ const scroller = setupScrollTimelineTest(scrollerOverrides);
+ const verticalScrollRange = scroller.scrollHeight - scroller.clientHeight;
+ const horizontalScrollRange = scroller.scrollWidth - scroller.clientWidth;
+
+ const blockScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'block'});
+ const inlineScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'inline'});
+ const horizontalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'x'});
+ const verticalScrollTimeline = new ScrollTimeline(
+ {source: scroller, axis: 'y'});
+
+ // Unscrolled, all timelines should read a current time of 0.
+ assert_percents_equal(blockScrollTimeline.currentTime, 0,
+ 'Unscrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 0,
+ 'Unscrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 0,
+ 'Unscrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 0,
+ 'Unscrolled vertical timeline');
+
+ // For vertical-lr, both axes start at their 'normal' positions but the X-axis
+ // is the block direction and the Y-axis is the inline direction. This does
+ // not affect horizontal/vertical.
+ scroller.scrollTop = 0.1 * verticalScrollRange;
+ scroller.scrollLeft = 0.2 * horizontalScrollRange;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(blockScrollTimeline.currentTime, 20,
+ 'Scrolled block timeline');
+ assert_percents_equal(inlineScrollTimeline.currentTime, 10,
+ 'Scrolled inline timeline');
+ assert_percents_equal(horizontalScrollTimeline.currentTime, 20,
+ 'Scrolled horizontal timeline');
+ assert_percents_equal(verticalScrollTimeline.currentTime, 10,
+ 'Scrolled vertical timeline');
+}, 'currentTime handles writing-mode: vertical-lr correctly');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html
new file mode 100644
index 0000000000..66e29cde65
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property-ref.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+ <script src="/web-animations/testcommon.js"></script>
+ <script src="/common/reftest-wait.js"></script>
+ <style>
+ html {
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300vh;
+ }
+ .box {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 100px;
+ background: black;
+ border: solid red;
+ translate: 100px;
+ will-change: transform;
+ }
+ </style>
+</head>
+<body>
+ <div class="box"></div>
+ <div class='spacer'></div>
+</body>
+<script>
+ waitForCompositorReady().then(takeScreenshot);
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html
new file mode 100644
index 0000000000..d6fdda6752
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/custom-property.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <link rel="help" src="https://github.com/w3c/csswg-drafts/issues/7759">
+ <link rel="match" href="custom-property-ref.html">
+ <script src="/web-animations/testcommon.js"></script>
+ <script src="/common/reftest-wait.js"></script>
+ <style>
+ html {
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300vh;
+ }
+ .box {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 100px;
+ background: black;
+ border: solid red;
+ animation: move auto linear;
+ animation-timeline: scroll();
+ }
+
+ @keyframes move {
+ to {
+ translate: var(--adjustment);
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="box"></div>
+ <div class='spacer'></div>
+</body>
+<script>
+ scroller = document.scrollingElement;
+ scroller.scrollTop
+ = scroller.scrollHeight - scroller.clientHeight;
+ document.documentElement.style.setProperty(
+ '--adjustment', `100px`);
+ waitForCompositorReady().then(takeScreenshot);
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html
new file mode 100644
index 0000000000..0c7a546572
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/effect-updateTiming.html
@@ -0,0 +1,630 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Scroll based animation: AnimationEffect.updateTiming</title>
+<!-- Adapted to progressed based scroll animations from "wpt\web-animations\interfaces\AnimationEffect\updateTiming.html" -->
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#dom-animationeffect-updatetiming">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<script src="/web-animations/resources/easing-tests.js"></script>
+<script src="/web-animations/resources/timing-tests.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+// ------------------------------
+// delay
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000, delay: 200})
+ anim.play();
+
+ assert_equals(anim.effect.getTiming().delay, 200, 'initial delay 200');
+ assert_equals(anim.effect.getComputedTiming().delay, 200,
+ 'getComputedTiming() initially delay 200');
+
+ anim.effect.updateTiming({ delay: 100 });
+ assert_equals(anim.effect.getTiming().delay, 100, 'set delay 100');
+ assert_equals(anim.effect.getComputedTiming().delay, 100,
+ 'getComputedTiming() after set delay 100');
+}, 'Allows setting the delay to a positive number');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100, delay: -100})
+ anim.play();
+ anim.effect.updateTiming({ delay: -100 });
+ assert_equals(anim.effect.getTiming().delay, -100, 'set delay -100');
+ assert_equals(anim.effect.getComputedTiming().delay, -100,
+ 'getComputedTiming() after set delay -100');
+ assert_percents_equal(anim.effect.getComputedTiming().endTime, 0,
+ 'getComputedTiming().endTime after set delay -100');
+}, 'Allows setting the delay to a negative number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 100})
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: 100 });
+ assert_equals(anim.effect.getComputedTiming().progress, null);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, null);
+}, 'Allows setting the delay of an animation in progress: positive delay that'
+ + ' causes the animation to be no longer in-effect');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: -50 });
+ assert_equals(anim.effect.getComputedTiming().progress, 0.5);
+}, 'Allows setting the delay of an animation in progress: negative delay that'
+ + ' seeks into the active interval');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(t, { fill: 'both', duration: 100 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ delay: -100 });
+ assert_equals(anim.effect.getComputedTiming().progress, 1);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0);
+}, 'Allows setting the delay of an animation in progress: large negative delay'
+ + ' that causes the animation to be finished');
+
+for (const invalid of gBadDelayValues) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t)
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ delay: invalid });
+ });
+ }, `Throws when setting invalid delay value: ${invalid}`);
+}
+
+
+// ------------------------------
+// endDelay
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ endDelay: 123.45 });
+ assert_time_equals_literal(anim.effect.getTiming().endDelay, 123.45,
+ 'set endDelay 123.45');
+ assert_time_equals_literal(anim.effect.getComputedTiming().endDelay, 123.45,
+ 'getComputedTiming() after set endDelay 123.45');
+}, 'Allows setting the endDelay to a positive number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ endDelay: -1000 });
+ assert_equals(anim.effect.getTiming().endDelay, -1000, 'set endDelay -1000');
+ assert_equals(anim.effect.getComputedTiming().endDelay, -1000,
+ 'getComputedTiming() after set endDelay -1000');
+}, 'Allows setting the endDelay to a negative number');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ endDelay: Infinity });
+ });
+}, 'Throws when setting the endDelay to infinity');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ await anim.ready;
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ endDelay: -Infinity });
+ });
+}, 'Throws when setting the endDelay to negative infinity');
+
+
+// ------------------------------
+// fill
+// ------------------------------
+
+for (const fill of ['none', 'forwards', 'backwards', 'both']) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100 })
+ anim.play();
+ anim.effect.updateTiming({ fill });
+ assert_equals(anim.effect.getTiming().fill, fill, 'set fill ' + fill);
+ assert_equals(anim.effect.getComputedTiming().fill, fill,
+ 'getComputedTiming() after set fill ' + fill);
+ }, `Allows setting the fill to '${fill}'`);
+}
+
+
+// ------------------------------
+// iterationStart
+// ------------------------------
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2,
+ iterations: 1,
+ fill: 'both',
+ duration: 100,
+ delay: 1 })
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 2);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' backwards-filling');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.2,
+ iterations: 1,
+ fill: 'both',
+ duration: 100,
+ delay: 0 })
+ anim.play();
+ await anim.ready;
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 2);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' active phase');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterationStart: 0.3,
+ iterations: 1,
+ fill: 'both',
+ duration: 200,
+ delay: 0 })
+ anim.play();
+ await anim.ready;
+ assert_percents_equal(anim.currentTime, 0);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 0,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0);
+
+ anim.finish();
+ assert_percents_equal(anim.currentTime, 100);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 100,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.3);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1);
+
+ anim.effect.updateTiming({ iterationStart: 2.5 });
+ assert_percents_equal(anim.currentTime, 100);
+ assert_percents_equal(anim.effect.getComputedTiming().localTime, 100,
+ "localTime");
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.5);
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3);
+}, 'Allows setting the iterationStart of an animation in progress:'
+ + ' forwards-filling');
+
+for (const invalid of gBadIterationStartValues) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t)
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ iterationStart: invalid });
+ }, `setting ${invalid}`);
+ }, `Throws when setting invalid iterationStart value: ${invalid}`);
+}
+
+// ------------------------------
+// iterations
+// ------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ anim.effect.updateTiming({ iterations: 2 });
+ assert_equals(anim.effect.getTiming().iterations, 2, 'set duration 2');
+ assert_equals(anim.effect.getComputedTiming().iterations, 2,
+ 'getComputedTiming() after set iterations 2');
+}, 'Allows setting iterations to a double value');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+ assert_throws_js(TypeError, () => {
+ anim.effect.updateTiming({ iterations: Infinity });
+ }, "test");
+}, `Throws when setting iterations to Infinity`);
+
+
+// progress based animations behave a bit differently than time based animations
+// when changing iterations.
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ anim.finish();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100,
+ 'duration when animation is finished');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration when animation is finished');
+
+ anim.effect.updateTiming({ iterations: 2 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after adding an iteration');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 50,
+ 'duration after adding an iteration');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1,
+ 'current iteration after adding an iteration');
+
+ anim.effect.updateTiming({ iterations: 4 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting iterations to 4');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25,
+ 'duration after setting iterations to 4');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3,
+ 'current iteration after setting iterations to 4');
+
+ anim.effect.updateTiming({ iterations: 0 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress after setting iterations to zero');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 0,
+ 'duration after setting iterations to zero');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration after setting iterations to zero');
+}, 'Allows setting the iterations of an animation in progress');
+
+// Added test for checking duration "auto"
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'both' });
+ anim.play();
+ anim.finish();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100,
+ 'duration when animation is finished');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration when animation is finished');
+
+ anim.effect.updateTiming({ iterations: 2 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after adding an iteration');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 50,
+ 'duration after adding an iteration');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 1,
+ 'current iteration after adding an iteration');
+
+ anim.effect.updateTiming({ iterations: 4 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting iterations to 4');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25,
+ 'duration after setting iterations to 4');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 3,
+ 'current iteration after setting iterations to 4');
+
+ anim.effect.updateTiming({ iterations: 0 });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress after setting iterations to zero');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 0,
+ 'duration after setting iterations to zero');
+ assert_equals(anim.effect.getComputedTiming().currentIteration, 0,
+ 'current iteration after setting iterations to zero');
+}, 'Allows setting the iterations of an animation in progress with duration ' +
+ '"auto"');
+
+
+// ------------------------------
+// duration
+// ------------------------------
+// adapted for progress based animations
+const gGoodDurationValuesForProgressBased = [
+ // until duration returns a CSSNumberish which can handle percentages, 100%
+ // will be represented as 100
+ { specified: 123.45, computed: 100 },
+ { specified: 'auto', computed: 100 },
+];
+
+for (const duration of gGoodDurationValuesForProgressBased) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, 2000);
+ anim.play();
+ anim.effect.updateTiming({ duration: duration.specified });
+ if (typeof duration.specified === 'number') {
+ assert_time_equals_literal(anim.effect.getTiming().duration,
+ duration.specified,
+ 'Updates specified duration');
+ } else {
+ assert_equals(anim.effect.getTiming().duration, duration.specified,
+ 'Updates specified duration');
+ }
+ assert_percents_equal(anim.effect.getComputedTiming().duration,
+ duration.computed,
+ 'Updates computed duration');
+ }, `Allows setting the duration to ${duration.specified}`);
+}
+
+// adapted for progress based animations
+const gBadDurationValuesForProgressBased = [
+ -1, NaN, Infinity, -Infinity, 'abc', '100'
+];
+
+for (const invalid of gBadDurationValuesForProgressBased) {
+ test(t => {
+ assert_throws_js(TypeError, () => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: invalid })
+ anim.play();
+ });
+ }, 'Throws when setting invalid duration: '
+ + (typeof invalid === 'string' ? `"${invalid}"` : invalid));
+}
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ anim.finish();
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress when animation is finished');
+ anim.effect.updateTiming({ duration: anim.effect.getTiming().duration * 2 });
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 1,
+ 'progress after doubling the duration');
+ anim.effect.updateTiming({ duration: 0 });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting duration to zero');
+ anim.effect.updateTiming({ duration: 'auto' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after setting duration to \'auto\'');
+}, 'Allows setting the duration of an animation in progress');
+
+promise_test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 100000, fill: 'both' });
+ anim.play();
+ return anim.ready.then(() => {
+ const originalStartTime = anim.startTime;
+ const originalCurrentTime = anim.currentTime;
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration,
+ 100,
+ 'Initial duration should be as set on KeyframeEffect'
+ );
+
+ anim.effect.updateTiming({ duration: 200000 });
+ assert_percents_equal(
+ anim.effect.getComputedTiming().duration,
+ 100,
+ 'Effect duration should remain at 100% for progress based animations'
+ );
+ assert_percents_equal(anim.startTime, originalStartTime,
+ 'startTime should be unaffected by changing effect ' +
+ 'duration');
+
+ assert_percents_equal(anim.currentTime, originalCurrentTime,
+ 'currentTime should be unaffected by changing ' +
+ 'effect duration');
+ });
+}, 'Allows setting the duration of an animation in progress such that the' +
+ ' the start and current time do not change');
+
+
+// ------------------------------
+// direction
+// ------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 2000 });
+ anim.play();
+
+ const directions = ['normal', 'reverse', 'alternate', 'alternate-reverse'];
+ for (const direction of directions) {
+ anim.effect.updateTiming({ direction: direction });
+ assert_equals(anim.effect.getTiming().direction, direction,
+ `set direction to ${direction}`);
+ }
+}, 'Allows setting the direction to each of the possible keywords');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 10000, direction: 'normal' });
+
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.07
+ await waitForNextFrame();
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.07,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.93,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' ' +
+ 'to \'reverse\'');
+
+promise_test(async t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { duration: 10000, direction: 'normal' });
+ anim.play();
+ await anim.ready;
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'reverse\' while at start of active interval');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { fill: 'backwards',
+ duration: 10000,
+ delay: 10000,
+ direction: 'normal' });
+ anim.play();
+ await anim.ready;
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'reverse' });
+
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'reverse\' while filling backwards');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2,
+ duration: 10000,
+ direction: 'normal' });
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.17 // 34% through first iteration
+ await waitForNextFrame();
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'alternate' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'normal\' to'
+ + ' \'alternate\'');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { iterations: 2,
+ duration: 10000,
+ direction: 'alternate' });
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+ scroller.scrollTop = maxScroll * 0.17
+ await waitForNextFrame();
+ // anim.currentTime = 17000; // 34% through first iteration
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.34,
+ 'progress before updating direction');
+
+ anim.effect.updateTiming({ direction: 'alternate-reverse' });
+
+ assert_time_equals_literal(anim.effect.getComputedTiming().progress, 0.66,
+ 'progress after updating direction');
+}, 'Allows setting the direction of an animation in progress from \'alternate\''
+ + ' to \'alternate-reverse\'');
+
+
+// ------------------------------
+// easing
+// ------------------------------
+
+async function assert_progress(animation, scrollPercent, easingFunction) {
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll * scrollPercent
+ await waitForNextFrame();
+ assert_approx_equals(animation.effect.getComputedTiming().progress,
+ easingFunction(scrollPercent),
+ 0.01,
+ 'The progress of the animation should be approximately'
+ + ` ${easingFunction(scrollPercent)} at ${scrollPercent}%`);
+}
+
+for (const options of gEasingTests) {
+ promise_test(async t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 100000,
+ fill: 'forwards' });
+ anim.play();
+ anim.effect.updateTiming({ easing: options.easing });
+ assert_equals(anim.effect.getTiming().easing,
+ options.serialization || options.easing);
+
+ const easing = options.easingFunction;
+ await assert_progress(anim, 0, easing);
+ await assert_progress(anim, 0.25, easing);
+ await assert_progress(anim, 0.5, easing);
+ await assert_progress(anim, 0.75, easing);
+ await assert_progress(anim, 1, easing);
+ }, `Allows setting the easing to a ${options.desc}`);
+}
+
+for (const easing of gRoundtripEasings) {
+ test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t);
+ anim.play();
+ anim.effect.updateTiming({ easing: easing });
+ assert_equals(anim.effect.getTiming().easing, easing);
+ }, `Updates the specified value when setting the easing to '${easing}'`);
+}
+
+// Because of the delay being so large, this progress based animation is always
+// in the finished state with progress 1. Included here because it is in the
+// original test file for time based animations.
+promise_test(async t => {
+ const delay = 1000000;
+
+ const anim = createScrollLinkedAnimationWithTiming(t,
+ { duration: 1000000, fill: 'both', delay: delay, easing: 'steps(2, start)' });
+
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ anim.play();
+ await anim.ready;
+
+ anim.effect.updateTiming({ easing: 'steps(2, end)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 0,
+ 'easing replace to steps(2, end) at before phase');
+
+ scroller.scrollTop = maxScroll * 0.875
+ await waitForNextFrame();
+
+ assert_equals(anim.effect.getComputedTiming().progress, 0.5,
+ 'change currentTime to active phase');
+
+ anim.effect.updateTiming({ easing: 'steps(2, start)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'easing replace to steps(2, start) at active phase');
+
+ scroller.scrollTop = maxScroll * 1.25
+ await waitForNextFrame();
+
+ anim.effect.updateTiming({ easing: 'steps(2, end)' });
+ assert_equals(anim.effect.getComputedTiming().progress, 1,
+ 'easing replace to steps(2, end) again at after phase');
+}, 'Allows setting the easing of an animation in progress');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html
new file mode 100644
index 0000000000..3faff63dc9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/finish-animation.html
@@ -0,0 +1,393 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Finishing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#finishing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0;
+
+ assert_throws_dom('InvalidStateError', () => {
+ animation.finish();
+ });
+}, 'Finishing an animation with a zero playback rate throws');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 100,
+ 'After finishing, the currentTime should be set to the end of the'
+ + ' active duration');
+}, 'Finishing an animation seeks to the end time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // 1% past effect end
+ animation.currentTime = CSSNumericValue.parse("101%");
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 100,
+ 'After finishing, the currentTime should be set back to the end of the'
+ + ' active duration');
+}, 'Finishing an animation with a current time past the effect end jumps'
+ + ' back to the end');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ animation.playbackRate = -1;
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'After finishing a reversed animation the ' +
+ 'currentTime should be set to zero');
+}, 'Finishing a reversed animation jumps to zero time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("100%");
+ await animation.finished;
+
+ animation.playbackRate = -1;
+ animation.currentTime = CSSNumericValue.parse("-1000%");
+ animation.finish();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'After finishing a reversed animation the ' +
+ 'currentTime should be set back to zero');
+}, 'Finishing a reversed animation with a current time less than zero'
+ + ' makes it jump back to zero');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0.5;
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a play-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100 / 0.5,
+ 'The start time of a play-pending animation should ' +
+ 'be set');
+}, 'Finishing an animation while play-pending resolves the pending'
+ + ' task immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ await runAndWaitForFrameUpdate(() => {
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ });
+ animation.play();
+ animation.finish();
+
+ await animation.finished;
+
+ assert_true(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a play-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time of a play-pending animation should ' +
+ 'be set to the end of the active duration');
+ assert_equals(animation.startTime, null,
+ 'The start time of a finished play-pending animation should ' +
+ 'be unresolved');
+}, 'Finishing an animation attached to inactive timeline while play-pending '
+ + 'doesn\'t resolves the pending task');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ let resolvedFinished = false;
+ animation.finished.then(() => {
+ resolvedFinished = true;
+ });
+
+ await animation.ready;
+
+ animation.finish();
+ await Promise.resolve();
+
+ assert_true(resolvedFinished, 'finished promise should be resolved');
+}, 'Finishing an animation resolves the finished promise synchronously');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ let readyResolved = false;
+
+ animation.finish();
+ animation.ready.then(() => { readyResolved = true; });
+
+ const promiseResult = await animation.finished;
+ await animation.ready;
+
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+ assert_true(readyResolved);
+}, 'A pending ready promise is resolved and not replaced when the animation'
+ + ' is finished');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.effect.target.remove();
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+
+ await animation.ready;
+ animation.finish();
+
+ await eventWatcher.wait_for('finish');
+ assert_equals(animation.effect.target.parentNode, null,
+ 'finish event should be fired for the animation on an orphaned element');
+}, 'Finishing an animation fires finish event on orphaned element');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ const originalFinishPromise = animation.finished;
+
+ animation.cancel();
+ assert_equals(animation.startTime, null);
+ assert_equals(animation.currentTime, null);
+
+ const resolvedFinishPromise = animation.finished;
+ assert_not_equals(originalFinishPromise, resolvedFinishPromise,
+ 'Canceling an animation should create a new finished promise');
+
+ animation.finish();
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a canceled animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100,
+ 'The start time of a finished animation should be set');
+ assert_percents_equal(animation.currentTime, 100,
+ 'Hold time should be set to end boundary of the ' +
+ 'animation');
+
+}, 'Finishing a canceled animation sets the current and start times');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+ animation.finish();
+ await animation.finished;
+ const finishEvent = await eventWatcher.wait_for('finish');
+ assert_equals(animation.playState, 'finished',
+ 'Animation is finished.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time is the end of the active duration in finished state.');
+ assert_percents_equal(animation.startTime, -75,
+ 'The start time is calculated to match the current time.');
+ assert_percents_equal(finishEvent.currentTime, 100,
+ 'event.currentTime is the animation current time.');
+ assert_percents_equal(finishEvent.timelineTime, 25,
+ 'event.timelineTime is timeline.currentTime');
+}, 'Finishing idle animation produces correct state and fires finish event.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ animation.finish();
+ assert_equals(animation.playState, 'paused', 'Animation is paused.');
+}, 'Finishing idle animation attached to inactive timeline pauses the ' +
+ 'animation.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ animation.play();
+ await animation.ready;
+
+ const eventWatcher = new EventWatcher(t, animation, 'finish');
+ animation.finish();
+ await animation.finished;
+ const finishEvent = await eventWatcher.wait_for('finish');
+ assert_equals(animation.playState, 'finished',
+ 'Animation is finished.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'The current time is the end of active duration in finished state.');
+ assert_percents_equal(animation.startTime, -75,
+ 'The start time is calculated to match animation current time.');
+ assert_percents_equal(finishEvent.currentTime, 100,
+ 'event.currentTime is the animation current time.');
+ assert_percents_equal(finishEvent.timelineTime, 25,
+ 'event.timelineTime is timeline.currentTime');
+}, 'Finishing running animation produces correct state and fires finish event.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ 'Sanity check the animation is running.');
+
+ animation.finish();
+ assert_equals(animation.playState, 'paused', 'Animation is paused.');
+}, 'Finishing running animation attached to inactive timeline pauses the ' +
+ 'animation.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.pause();
+ await animation.ready;
+
+ animation.finish();
+
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a paused animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100,
+ 'The start time of a paused animation should be set');
+}, 'Finishing a paused animation resolves the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Update playbackRate so we can test that the calculated startTime
+ // respects it
+ animation.playbackRate = 2;
+ animation.pause();
+ // While animation is still pause-pending call finish()
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a pause-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime.value - 100 / 2,
+ 'The start time of a pause-pending animation should ' +
+ 'be set');
+}, 'Finishing a pause-pending animation resolves the pending task'
+ + ' immediately and update the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -2;
+ animation.pause();
+ animation.finish();
+
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'finished',
+ 'The play state of a pause-pending animation should become ' +
+ '"finished"');
+ assert_percents_equal(animation.startTime,
+ animation.timeline.currentTime,
+ 'The start time of a pause-pending animation should ' +
+ 'be set');
+}, 'Finishing a pause-pending animation with negative playback rate'
+ + ' resolves the pending task immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.pause();
+ animation.play();
+ // We are now in the unusual situation of being play-pending whilst having
+ // a resolved start time. Check that finish() still triggers a transition
+ // to the finished state immediately.
+ animation.finish();
+
+ assert_equals(animation.playState, 'finished',
+ 'After aborting a pause then finishing an animation its play ' +
+ 'state should become "finished" immediately');
+}, 'Finishing an animation during an aborted pause makes it finished'
+ + ' immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ assert_true(animation.pending);
+
+ animation.finish();
+ assert_false(animation.pending);
+ assert_equals(animation.playbackRate, 2);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'A pending playback rate should be applied immediately when an animation'
+ + ' is finished');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(0);
+
+ assert_throws_dom('InvalidStateError', () => {
+ animation.finish();
+ });
+}, 'An exception should be thrown if the effective playback rate is zero');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js
new file mode 100644
index 0000000000..90157580ce
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/idlharness.window.js
@@ -0,0 +1,16 @@
+// META: script=/resources/WebIDLParser.js
+// META: script=/resources/idlharness.js
+
+'use strict';
+
+idl_test(
+ ['scroll-animations'],
+ // The css-pseudo dependency shouldn't be necessary, but is:
+ // https://github.com/web-platform-tests/wpt/issues/12574
+ ['web-animations', 'css-pseudo', 'dom'],
+ idl_array => {
+ idl_array.add_objects({
+ ScrollTimeline: ['new ScrollTimeline()'],
+ });
+ }
+);
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html
new file mode 100644
index 0000000000..4bcea1adba
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/intrinsic-iteration-duration.tentative.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Scroll based animation: AnimationEffect.getComputedTiming</title>
+<link rel="help" href="https://www.w3.org/TR/web-animations-2/#dom-animationeffect-getcomputedtiming">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<body>
+<div id="log"></div>
+<script type="text/javascript">
+
+//------------------------------------
+// Time-based duration
+//------------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {duration: 1000 });
+ assert_equals(anim.effect.getTiming().duration, 1000);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100);
+}, 'Computed duration in percent even when specified in ms');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, { duration: 1000 });
+ anim.rangeStart = { offset: CSS.percent(20) };
+ anim.rangeEnd = { offset: CSS.percent(80) };
+ assert_equals(anim.effect.getTiming().duration, 1000);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 60);
+}, 'Time-based duration normalized to fill animation range.');
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, {duration: 700, delay: 100, endDelay: 200 });
+ assert_equals(anim.effect.getTiming().duration, 700);
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 70);
+}, 'Time-based duration normalized to preserve proportional delays.');
+
+//-------------------------------------------------
+// Duration 'auto' = Intrinsic iteration duration
+//-------------------------------------------------
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {});
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 100);
+}, 'Intrinsic iteration duration fills timeline.');
+
+test(t => {
+ const anim = createScrollLinkedAnimationWithTiming(t, {});
+ anim.rangeStart = { offset: CSS.percent(30) };
+ anim.rangeEnd = { offset: CSS.percent(90) };
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 60);
+}, 'Intrinsic iteration duration accounts for animation range.');
+
+test(t => {
+ const anim =
+ createScrollLinkedAnimationWithTiming(
+ t, { iterations: 4 });
+ assert_equals(anim.effect.getTiming().duration, 'auto');
+ assert_percents_equal(anim.effect.getComputedTiming().duration, 25);
+}, 'Intrinsic iteration duration accounts for number of iterations');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html
new file mode 100644
index 0000000000..c5a46a501e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/layout-changes-on-percentage-based-timeline.html
@@ -0,0 +1,84 @@
+<html class="reftest-wait">
+<title>Layout changes on percentage-based scroll timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Scroll timeline should properly handle
+layout changes on percentage-based scroll offset">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+
+ #contents {
+ height: 500px;
+ width: 100%;
+ }
+
+ #spacer {
+ height: 80px;
+ }
+
+ .invisible {
+ display: none;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered">Scrolling Test</div>
+<div id="scroller">
+ <div id="contents"></div>
+ <div id="spacer" class="invisible"></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1 },
+ { transform: 'translateY(200px)', opacity: 0 }
+ ]
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline({
+ source: scroller
+ });
+ const animation = new Animation(effect, timeline);
+ animation.play();
+ animation.ready.then(_ => {
+ // Moves the scroller to the end point (240px).
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll * 0.6;
+
+ // Makes sure that the animation runs on compositor with current scroll offset
+ waitForAnimationFrames(2).then(_ => {
+ // Adds 80px to scroll height which pushes scroll progress back to 50%.
+ const spacer = document.getElementById('spacer');
+ spacer.classList.remove('invisible');
+ // Makes sure that the change is propagated to the compositor.
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html
new file mode 100644
index 0000000000..53ad0d9285
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/null-scroll-source-crash.html
@@ -0,0 +1,24 @@
+<link rel="help" href="https://bugs.chromium.org/p/chromium/issues/detail?id=1088319">
+<meta name="assert" content="Playing animation with null scroll source should not crash.">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ html {
+ overflow: scroll;
+ }
+
+ body {
+ overflow: scroll;
+ }
+</style>
+<div id="box"></div>
+<script>
+ test(() => {
+ const effect = new KeyframeEffect(box, []);
+ const timeline = new ScrollTimeline();
+ const animation = new Animation(effect, timeline);
+ assert_equals(document.scrollingElement, null,
+ "This test relies on scrolling element being nil");
+ animation.play();
+ }, "Playing animation with null scroll source should not crash");
+</script> \ No newline at end of file
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html
new file mode 100644
index 0000000000..1f9641e2f8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/pause-animation.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Pausing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#pausing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ const startTimeBeforePausing = animation.startTime;
+
+ animation.pause();
+ assert_percents_equal(animation.startTime, startTimeBeforePausing,
+ 'The start time does not change when pausing-pending');
+
+ await animation.ready;
+
+ assert_equals(animation.startTime, null,
+ 'The start time is unresolved when paused');
+}, 'Pausing clears the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ animation.pause();
+
+ const promiseResult = await promise;
+
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+ assert_false(animation.pending, 'No longer pause-pending');
+}, 'A pending ready promise should be resolved and not replaced when the'
+ + ' animation is paused');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Let animation start roughly half-way through
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ // Go pause-pending and also set a pending playback rate
+ animation.pause();
+ animation.updatePlaybackRate(0.5);
+
+ await animation.ready;
+ // If the current time was updated using the new playback rate it will jump
+ // back to 25% but if we correctly used the old playback rate the current time
+ // will be > 50%.
+ assert_percents_equal(animation.currentTime, 50);
+}, 'A pause-pending animation maintains the current time when applying a'
+ + ' pending playback rate');
+
+promise_test(async t => {
+ // This test does not cover a specific step in the algorithm but serves as a
+ // high-level sanity check that pausing does, in fact, freeze the current
+ // time.
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ await animation.ready;
+
+ animation.pause();
+ await animation.ready;
+
+ const currentTimeAfterPausing = animation.currentTime;
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 20,
+ 'Sanity check timeline time changed');
+
+ assert_percents_equal(animation.currentTime, currentTimeAfterPausing,
+ 'Animation.currentTime is unchanged after pausing');
+}, 'The animation\'s current time remains fixed after pausing');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+
+ const originalReadyPromise = animation.ready;
+ animation.cancel();
+ assert_equals(animation.startTime, null);
+ assert_equals(animation.currentTime, null);
+
+ const readyPromise = animation.ready;
+ assert_not_equals(originalReadyPromise, readyPromise,
+ 'Canceling an animation should create a new ready promise');
+
+ animation.pause();
+ assert_equals(animation.playState, 'paused',
+ 'Pausing a canceled animation should update the play state');
+ assert_true(animation.pending, 'animation should be pause-pending');
+ await animation.ready;
+ assert_false(animation.pending,
+ 'animation should no longer be pause-pending');
+ assert_equals(animation.startTime, null, 'start time should be unresolved');
+ assert_percents_equal(animation.currentTime, 0,
+ 'current time should be set to zero');
+}, 'Pausing a canceled animation sets the current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Pause the animation when the timeline is inactive.
+ animation.pause();
+ assert_equals(animation.currentTime, null,
+ 'The current time is null when the timeline is inactive.');
+ assert_equals(animation.startTime, null,
+ 'The start time is null in Pending state.');
+ await waitForNextFrame();
+ assert_true(animation.pending,
+ 'Animation has pause pending task while the timeline is inactive.');
+ assert_equals(animation.playState, 'paused',
+ `State is 'paused' in Pending state.`);
+}, `Pause pending task doesn't run when the timeline is inactive.`);
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.pause();
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await animation.ready;
+ // Ready promise is resolved as a result of the timeline becoming active.
+ assert_percents_equal(animation.currentTime, 20,
+ 'Animation current time is resolved when the animation is ready.');
+ assert_equals(animation.startTime, null,
+ 'Animation start time is unresolved when the animation is ready.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after animation.pause call.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html
new file mode 100644
index 0000000000..7d95eaa257
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/play-animation.html
@@ -0,0 +1,276 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Playing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#playing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ assert_equals(animation.startTime, null);
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0);
+
+ animation.cancel();
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50);
+
+}, 'Playing an animations aligns the start time with the start of the active ' +
+ 'range');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.playbackRate = -1;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+}, 'Playing an animations with a negative playback rate aligns the start ' +
+ 'time with the end of the active range');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.startTime = CSSNumericValue.parse("10%");
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 10);
+}, 'Start time set while play pending is preserved.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("10%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Current time set while play pending is preserved.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.currentTime, 10);
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Playing a running animation resets a sticky start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Playing a finished animation restarts the animation aligned at the start');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.currentTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.currentTime, 0);
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 100);
+}, 'Playing a finished and reversed animation restarts the animation aligned ' +
+ 'at the end');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ await animation.finished;
+
+ // Start time is now sticky since modified by an explicit API call.
+ // All current time calculations will be based on the new start time
+ // while running.
+ assert_percents_equal(animation.startTime, -100,
+ 'start time when finished');
+ assert_percents_equal(animation.currentTime, 100,
+ 'current time when finished');
+
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ await waitForNextFrame();
+ assert_percents_equal(animation.startTime, -100,
+ 'start time after scrolling a finished animation');
+ // Clamped at effect end time.
+ assert_percents_equal(animation.currentTime, 100,
+ 'current time after scrolling a finished animation');
+
+ // Initiate a pause then abort it
+ animation.pause();
+ animation.play();
+
+ // Wait to return to running state
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 0,
+ 'After aborting a pause when finished, the start time should no ' +
+ 'longer be sticky');
+ assert_percents_equal(animation.currentTime, 50,
+ 'After aborting a pause when finished, the current time should realign ' +
+ 'with the scroll position');
+}, 'Playing a pause-pending but previously finished animation realigns'
+ + ' with the scroll position');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.finish();
+ await animation.ready;
+
+ animation.play();
+ assert_equals(animation.startTime, null);
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0, 'start time is zero');
+}, 'Playing a finished animation clears the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.cancel();
+ const promise = animation.ready;
+ animation.play();
+ assert_not_equals(animation.ready, promise);
+}, 'The ready promise should be replaced if the animation is not already'
+ + ' pending');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ const promise = animation.ready;
+ const promiseResult = await promise;
+ assert_equals(promiseResult, animation);
+ assert_equals(animation.ready, promise);
+}, 'A pending ready promise should be resolved and not replaced when the'
+ + ' animation enters the running state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.pause();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 0);
+}, 'Resuming an animation from paused realigns with scroll position.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Go to pause-pending state
+ animation.pause();
+ assert_true(animation.pending, 'Animation is pending');
+ const pauseReadyPromise = animation.ready;
+
+ // Now play again immediately (abort the pause)
+ animation.play();
+ assert_true(animation.pending, 'Animation is still pending');
+ assert_equals(animation.ready, pauseReadyPromise,
+ 'The pause Promise is re-used when playing while waiting to pause');
+
+ // Sanity check: Animation proceeds to running state
+ await animation.ready;
+ assert_true(!animation.pending && animation.playState === 'running',
+ 'Animation is running after aborting a pause');
+}, 'If a pause operation is interrupted, the ready promise is reused');
+
+promise_test(async t => {
+ // Seek animation beyond target end
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("-100%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, -100);
+
+ // Set pending playback rate to the opposite direction
+ animation.updatePlaybackRate(-1);
+ assert_true(animation.pending);
+ // Note: Updating the playback rate calls play without rewind. For a
+ // scroll-timeline, this will immediately apply the playback rate.
+ // TODO: Determine if we should defer applying the new playback rate.
+ assert_equals(animation.playbackRate, 1);
+
+ // When we play, we should align to the target end, NOT to zero (which
+ // is where we would seek to if we used the playbackRate of 1.
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'A pending playback rate is used when determining timeline range alignment');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.cancel();
+ assert_equals(animation.startTime, null,
+ 'Start time should be unresolved');
+
+ animation.play();
+ assert_true(animation.pending, 'Animation should be play-pending');
+
+ await animation.ready;
+
+ assert_false(animation.pending, 'animation should no longer be pending');
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time of the playing animation should be zero');
+}, 'Playing a canceled animation sets the start time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.cancel();
+ assert_equals(animation.startTime, null, 'Start time should be unresolved');
+
+ animation.play();
+ assert_true(animation.pending, 'Animation should be play-pending');
+
+ await animation.ready;
+
+ assert_false(animation.pending, 'Animation should no longer be pending');
+ assert_percents_equal(animation.startTime, 100,
+ 'The start time of the playing animation should be set');
+}, 'Playing a canceled animation backwards sets the start time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html
new file mode 100644
index 0000000000..59366a88dd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay-ref.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<title>Reference for Web Animation with scroll timeline and effect delay tests</title>
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ transform: translate(0, 100px);
+ opacity: 0.5;
+ will-change: transform; /* force compositing */
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ window.addEventListener('load', function() {
+ // Move the scroller to halfway.
+ const scroller = document.getElementById("scroller");
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.75 * maxScroll;
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html
new file mode 100644
index 0000000000..525d8448ff
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/progress-based-effect-delay.tentative.html
@@ -0,0 +1,69 @@
+<html class="reftest-wait">
+<title>Animation effect delays should be accounted for when using a progress based timeline</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Effect delay should be accounted for by progress based animations">
+<link rel="match" href="progress-based-effect-delay-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ delay: 1000,
+ duration: 1000
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.75 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html
new file mode 100644
index 0000000000..1054ed3983
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/reverse-animation.html
@@ -0,0 +1,164 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Reversing an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations/#reversing-an-animation-section">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.reverse();
+ animation.currentTime = CSSNumericValue.parse("40%");
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 40);
+}, 'Setting current time while reverse-pending preserves currentTime');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSSNumericValue.parse("50%");
+ const previousPlaybackRate = animation.playbackRate;
+ animation.reverse();
+ assert_equals(animation.playbackRate, previousPlaybackRate,
+ 'Playback rate should not have changed');
+ await animation.ready;
+
+ assert_equals(animation.playbackRate, -previousPlaybackRate,
+ 'Playback rate should be inverted');
+}, 'Reversing an animation inverts the playback rate');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("40%");
+ await animation.ready;
+ assert_percents_equal(animation.startTime, -40);
+ assert_percents_equal(animation.currentTime, 40);
+
+ animation.reverse();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 100);
+ assert_percents_equal(animation.currentTime, 100);
+}, 'Reversing an animation resets a sticky start time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ assert_true(animation.pending,
+ 'The animation is pending before we call reverse');
+
+ animation.reverse();
+
+ assert_true(animation.pending,
+ 'The animation is still pending after calling reverse');
+}, 'Reversing an animation does not cause it to leave the pending state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ let readyResolved = false;
+ animation.ready.then(() => { readyResolved = true; });
+
+ animation.reverse();
+
+ await Promise.resolve();
+ assert_false(readyResolved,
+ 'ready promise should not have been resolved yet');
+}, 'Reversing an animation does not cause it to resolve the ready promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = -1;
+ animation.reverse();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 0);
+}, 'Reversing an animation with a negative playback rate should cause ' +
+ 'the animation to play in a forward direction');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.playbackRate = 0;
+ animation.currentTime = CSSNumericValue.parse("50%");
+ animation.reverse();
+
+ await animation.ready;
+ assert_equals(animation.playbackRate, 0,
+ 'reverse() should preserve playbackRate if the playbackRate == 0');
+ assert_percents_equal(animation.currentTime, 0,
+ 'Anchors to range start boundary when playback rate is zero');
+}, 'Reversing when when playbackRate == 0 should preserve the playback rate');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_equals(animation.currentTime, null);
+
+ animation.reverse();
+ await animation.ready;
+
+ assert_percents_equal(animation.startTime, 100,
+ 'animation.startTime should be at its effect end');
+ assert_percents_equal(animation.currentTime, 100,
+ 'animation.currentTime should be at its effect end');
+}, 'Reversing an idle animation aligns startTime with the rangeEnd boundary');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_throws_dom('InvalidStateError', () => { animation.reverse(); });
+}, 'Reversing an animation without an active timeline throws an ' +
+ 'InvalidStateError');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ animation.pause();
+ await animation.ready;
+
+ animation.reverse();
+ assert_equals(animation.playState, 'running',
+ 'Animation.playState should be "running" after reverse()');
+}, 'Reversing an animation plays a pausing animation');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ animation.reverse();
+
+ await animation.ready;
+ assert_equals(animation.playbackRate, -2);
+}, 'Reversing should use the negative pending playback rate');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html
new file mode 100644
index 0000000000..b9cc154676
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-fill-modes.tentative.html
@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Verify applied effect output for all fill modes in all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+ .contents {
+ /* Make scroll range 1000 to simplify the math and avoid rounding errors */
+ height: 1200px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+
+ test(t => {
+ const effect = new KeyframeEffect(createDiv(t), { opacity: [0.3, 0.7] });
+ const animation = new Animation(effect, createScrollTimeline(t));
+
+ assert_equals(effect.getTiming().fill, "auto");
+ assert_equals(effect.getComputedTiming().fill, "none");
+ }, "Scroll based animation effect fill mode should return 'auto' for" +
+ " getTiming() and should return 'none' for getComputedTiming().")
+
+ /* All interesting transitions:
+ before start delay
+ at start delay
+ within active phase
+ at effect end
+ after effect end
+
+ test_case data structure:
+ fill_mode: {
+ scroll_percentage: ["state description", expected applied effect value]
+ }
+ */
+ const test_cases = {
+ "none": {
+ 0.10: ["before start delay", 1],
+ 0.25: ["at start delay", 0.3],
+ 0.50: ["at midpoint", 0.5],
+ 0.75: ["at effect end", 1],
+ 0.90: ["after effect end", 1]
+ },
+ "backwards": {
+ 0.10: ["before start delay", 0.3],
+ 0.25: ["at start delay", 0.3],
+ 0.50: ["at midpoint", 0.5],
+ 0.75: ["at effect end", 1],
+ 0.90: ["after effect end", 1]
+ },
+ "forwards": {
+ 0.10: ["before timeline start", 1],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 0.7],
+ 0.90: ["after timeline end", 0.7]
+ },
+ "both": {
+ 0.10: ["before timeline start", 0.3],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 0.7],
+ 0.90: ["after timeline end", 0.7]
+ },
+ "auto": {
+ 0.10: ["before timeline start", 1],
+ 0.25: ["at timeline start", 0.3],
+ 0.50: ["in timeline range", 0.5],
+ 0.75: ["at timeline end", 1],
+ 0.90: ["after timeline end", 1]
+ }
+ }
+
+ for (const fill_mode in test_cases) {
+ const scroll_percents = test_cases[fill_mode]
+
+ for (const scroll_percentage in scroll_percents) {
+ const expectation = scroll_percents[scroll_percentage];
+
+ const [test_name, expected_value] = expectation;
+
+ const description =
+ `Applied effect value ${test_name} with fill: ${fill_mode}`
+ promise_test(
+ create_scroll_timeline_fill_test(
+ fill_mode, scroll_percentage, expected_value),
+ description);
+ }
+ }
+
+ function create_scroll_timeline_fill_test(
+ fill_mode, scroll_percentage, expected){
+ return async t => {
+ const target = createDiv(t);
+
+ const timeline = createScrollTimeline(t);
+ const effect =
+ new KeyframeEffect(target,
+ { opacity: [0.3, 0.7] },
+ {
+ fill: fill_mode,
+ /* Animation times normalized to fill scroll
+ range */
+ duration: 2000,
+ delay: 1000,
+ endDelay: 1000
+ });
+ const animation = new Animation(effect, timeline);
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ await animation.ready;
+
+ scroller.scrollTop = scroll_percentage * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute
+ // new current time.
+ await waitForNextFrame();
+
+ assert_equals(parseFloat(window.getComputedStyle(target).opacity),
+ expected,
+ "animation effect applied property value");
+ }
+ }
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html
new file mode 100644
index 0000000000..41ae0e0612
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-effect-phases.tentative.html
@@ -0,0 +1,555 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Verify timeline time, animation time, effect time, and effect progress for all timeline states: before start, at start, in range, at end, after end while using various effect delay values</title>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+ .contents {
+ /* Make scroll range 1000 to simplify the math and avoid rounding errors */
+ height: 1200px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+ // Note: effects are scaled to fill the timeline.
+
+ // Each entry is [[test input], [test expectations]]
+ // test input = ["description", delay, end_delay, scroll percent]
+ // test expectations = [timeline time, animation current time,
+ // effect local time, effect progress, effect phase,
+ // opacity]
+
+ /* All interesting transitions:
+ at timeline start
+ before effect delay
+ at effect start
+ in active range
+ at effect end
+ after effect end
+ at timeline end
+ */
+ const test_cases = [
+ // Case 1: No delays.
+ // Boundary at end of active phase is inclusive.
+ [
+ ["at start", 0, 0, 0],
+ [0, 0, 0, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 0, 0, 0.50],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 0, 0, 1.0],
+ [100, 100, 100, 1.0, "active", 0.7]
+ ],
+
+ // Case 2: Positive start delay and no end delay.
+ // Boundary at end of active phase is inclusive.
+ [
+ ["at timeline start", 500, 0, 0],
+ [0, 0, 0, null, "before", 1]
+ ],
+ [
+ ["before start delay", 500, 0, 0.25],
+ [25, 25, 25, null, "before", 1]
+ ],
+ [
+ ["at start delay", 500, 0, 0.5],
+ [50, 50, 50, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 500, 0, 0.75],
+ [75, 75, 75, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 500, 0, 1.0],
+ [100, 100, 100, 1.0, "active", 0.7]
+ ],
+
+ // case 3: No start delay, Positive end delay.
+ // Boundary at end of active phase is exclusive.
+ [
+ ["at timeline start", 0, 500, 0],
+ [0, 0, 0, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 0, 500, 0.25],
+ [25, 25, 25, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 0, 500, 0.5],
+ [50, 50, 50, null, "after", 1.0]
+ ],
+ [
+ ["after effect end time", 0, 500, 0.75],
+ [75, 75, 75, null, "after", 1.0]
+ ],
+ [
+ ["at timeline boundary", 0, 500, 1.0],
+ [100, 100, 100, null, "after", 1.0]
+ ],
+
+ // case 4: Positive start and end delays.
+ // Boundary at end of active phase is exclusive.
+ [
+ ["at timeline start", 250, 250, 0],
+ [0, 0, 0, null, "before", 1]
+ ],
+ [
+ ["before start delay", 250, 250, 0.1],
+ [10, 10, 10, null, "before", 1]
+ ],
+ [
+ ["at start delay", 250, 250, 0.25],
+ [25, 25, 25, 0, "active", 0.3]
+ ],
+ [
+ ["in active range", 250, 250, 0.5],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at effect end time", 250, 250, 0.75],
+ [75, 75, 75, null, "after", 1.0]
+ ],
+ [
+ ["after effect end time", 250, 250, 0.9],
+ [90, 90, 90, null, "after", 1.0]
+ ],
+ [
+ ["at timeline boundary", 250, 250, 1.0],
+ [100, 100, 100, null, "after", 1.0]
+ ],
+
+ // Case 5: Negative start and end delays.
+ // Effect boundaries are not reachable.
+ [
+ ["at timeline start", -125, -125, 0],
+ [0, 0, 0, 0.25, "active", 0.4]
+ ],
+ [
+ ["in active range", -125, -125, 0.5],
+ [50, 50, 50, 0.5, "active", 0.5]
+ ],
+ [
+ ["at timeline end", -125, -125, 1.0],
+ [100, 100, 100, 0.75, "active", 0.6]
+ ]
+ ];
+
+ for (const test_case of test_cases) {
+ const [inputs, expected] = test_case;
+ const [test_name, delay, end_delay, scroll_percentage] = inputs;
+
+ const description = `Current times and effect phase ${test_name} when` +
+ ` delay = ${delay} and endDelay = ${end_delay} |`;
+
+ promise_test(
+ create_scroll_timeline_delay_test(
+ delay, end_delay, scroll_percentage, expected),
+ description);
+ }
+
+ function create_scroll_timeline_delay_test(
+ delay, end_delay, scroll_percentage, expected){
+ return async t => {
+ const target = createDiv(t);
+ const timeline = createScrollTimeline(t);
+ const effect = new KeyframeEffect(
+ target,
+ {
+ opacity: [0.3, 0.7]
+ },
+ {
+ duration: 500,
+ delay: delay,
+ endDelay: end_delay
+ }
+ );
+ const animation = new Animation(effect, timeline);
+ t.add_cleanup(() => {
+ animation.cancel();
+ });
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ await animation.ready;
+
+ scroller.scrollTop = scroll_percentage * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute
+ // new current time.
+ await waitForNextFrame();
+
+ const [expected_timeline_current_time,
+ expected_animation_current_time,
+ expected_effect_local_time,
+ expected_effect_progress,
+ expected_effect_phase,
+ expected_opacity] = expected;
+
+ assert_percents_equal(
+ animation.timeline.currentTime,
+ expected_timeline_current_time,
+ "timeline current time");
+ assert_percents_equal(
+ animation.currentTime,
+ expected_animation_current_time,
+ "animation current time");
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_effect_local_time,
+ "animation effect local time");
+ assert_approx_equals_or_null(
+ animation.effect.getComputedTiming().progress,
+ expected_effect_progress,
+ 0.001,
+ "animation effect progress");
+ assert_phase(
+ animation, expected_effect_phase);
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected_opacity,
+ 0.001,
+ 'target opacity');
+ }
+ }
+
+ function createKeyframeEffectOpacity(test){
+ return new KeyframeEffect(
+ createDiv(test),
+ {
+ opacity: [0.3, 0.7]
+ },
+ {
+ duration: 1000
+ }
+ );
+ }
+
+ function verifyEffectBeforePhase(animation) {
+ // If currentTime is null, we are either idle, or running with an
+ // inactive timeline. Either way, the animation is not in effect and cannot
+ // be in the before phase.
+ assert_true(animation.currentTime != null,
+ 'Animation is not in effect');
+
+ const fillMode = animation.effect.getTiming().fill;
+ animation.effect.updateTiming({ fill: 'none' });
+
+ // progress == null AND opacity == 1 implies we are in the effect before
+ // or after phase.
+ assert_equals(animation.effect.getComputedTiming().progress, null);
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "1");
+
+ // If the progress is no longer null after adding fill: backwards, then we
+ // are in the before phase.
+ animation.effect.updateTiming({ fill: 'backwards' });
+ assert_true(animation.effect.getComputedTiming().progress != null);
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.3");
+
+ // Reset fill mode to avoid side-effects.
+ animation.effect.updateTiming({ fill: fillMode });
+ }
+
+ function createScrollLinkedOpacityAnimationWithDelays(t) {
+ const animation = new Animation(
+ createKeyframeEffectOpacity(t),
+ createScrollTimeline(t)
+ );
+ t.add_cleanup(() => {
+ animation.cancel();
+ });
+ animation.effect.updateTiming({
+ duration: 1000,
+ delay: 500,
+ endDelay: 500
+ });
+ return animation;
+ }
+
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // scroll pos
+ // current time
+ // start time
+ // |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.startTime, 0);
+ assert_phase(animation, 'before');
+
+ // start time scroll pos
+ // | current time
+ // | |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+ assert_phase(animation, 'active');
+
+ // start time scroll pos current time
+ // | | |
+ // |---- 25% before ----|---- 50% active ----|---- 25% after ----|
+ animation.playbackRate = 2;
+ assert_phase(animation, 'after');
+
+ // start time scroll pos current time
+ // | | |
+ // |---- 33.3% before ----|---- 66.7% active ---------------------|
+ animation.effect.updateTiming({ endDelay: 0 });
+ assert_phase(animation, 'active');
+
+ // scroll pos start time
+ // current time |
+ // | |
+ // |---- 33.3% before ----|---- 66.7% active ----------------------|
+ animation.playbackRate = -1;
+ assert_percents_equal(animation.startTime, 100);
+ assert_phase(animation, 'active');
+
+ // start time
+ // scroll pos current time
+ // | | |
+ // |---- 33.3% before ----|---- 66.7% active -----------------------|
+ animation.playbackRate = -2;
+ assert_phase(animation, 'active');
+
+ // current time start time
+ // | scroll pos
+ // | |
+ // |---- 33.3% before ----|---- 66.7% active -----------------------|
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+ assert_phase(animation, 'before');
+
+ // current time start time
+ // | scroll pos
+ // | |
+ // |--------------------- 100% active -------------------------------|
+ animation.effect.updateTiming({ delay: 0 });
+ assert_phase(animation, 'active');
+
+ // Finally, switch to a document timeline. The before-active boundary
+ // becomes exclusive.
+ animation.timeline = document.timeline;
+ animation.currentTime = 0;
+ await waitForNextFrame();
+ assert_phase(animation, 'before');
+
+ }, 'Playback rate affects whether active phase boundary is inclusive.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await waitForNextFrame();
+ verifyEffectBeforePhase(animation);
+
+ animation.play();
+ await waitForNextFrame();
+
+ verifyEffectBeforePhase(animation);
+ }, 'Verify that (play -> pause -> play) doesn\'t change phase/progress.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ // Scrolling should not cause the animation effect to change.
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+
+ // Check timeline phase
+ assert_percents_equal(animation.timeline.currentTime, 50);
+ assert_percents_equal(animation.currentTime, 0);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 0,
+ "effect local time");
+
+ // Make sure the effect is still in the before phase even though the
+ // timeline is not.
+ verifyEffectBeforePhase(animation);
+ }, 'Pause in before phase, scroll timeline into active phase, animation ' +
+ 'should remain in the before phase');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ verifyEffectBeforePhase(animation);
+
+ animation.pause();
+ await waitForNextFrame();
+ verifyEffectBeforePhase(animation);
+
+ // Setting the current time should force the animation into effect.
+ const expected_time = 50;
+ animation.currentTime = CSS.percent(expected_time);
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 0);
+ assert_percents_equal(animation.currentTime, expected_time,
+ 'Current time matches set value');
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_time, "Effect local time after setting animation.currentTime");
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "Progress after setting animation.currentTime");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5", "Opacity after setting animation.currentTime");
+
+ // Scrolling should not cause the animation effect to change since
+ // paused.
+ scroller.scrollTop = 0.75 * maxScroll; // scroll so that timeline is 75%
+ await waitForNextFrame();
+ assert_percents_equal(animation.timeline.currentTime, 75);
+
+ // animation and effect timings are unchanged.
+ assert_percents_equal(animation.currentTime, expected_time,
+ "Current time after scrolling while paused");
+ assert_percents_equal(
+ animation.effect.getComputedTiming().localTime,
+ expected_time,
+ "Effect local time after scrolling while paused");
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "Progress after scrolling while paused");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5", "Opacity after scrolling while paused");
+ }, 'Pause in before phase, set animation current time to be in active ' +
+ 'range, animation should become active. Scrolling should have no effect.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // Causes the timeline to be inactive
+ scroller.style.overflow = "visible";
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ // Verify that he timeline is inactive
+ assert_equals(animation.timeline.currentTime, null,
+ "Timeline is inactive");
+ assert_equals(
+ animation.currentTime, null,
+ "Current time for running animation with an inactive timeline");
+ assert_equals(animation.effect.getComputedTiming().localTime, null,
+ "effect local time with inactive timeline");
+
+ // Setting the current time while timeline is inactive should pause the
+ // animation at the specified time.
+ animation.currentTime = CSS.percent(50);
+ await waitForNextFrame();
+ await waitForNextFrame();
+
+ // Verify that animation currentTime is properly set despite the inactive
+ // timeline.
+ assert_equals(animation.timeline.currentTime, null);
+ assert_percents_equal(animation.currentTime, 50);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 50,
+ "effect local time after setting animation current time");
+
+ // Check effect phase
+ // progress == 0.5 AND opacity == 0.5 shows we are in the effect active
+ // phase.
+ assert_equals(animation.effect.getComputedTiming().progress, 0.5,
+ "effect progress");
+ assert_equals(
+ window.getComputedStyle(animation.effect.target)
+ .getPropertyValue("opacity"),
+ "0.5",
+ "effect opacity after setting animation current time");
+ }, 'Make scroller inactive, then set current time to an in range time');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ // Update timeline.currentTime.
+ await waitForNextFrame();
+
+ animation.pause();
+ await animation.ready;
+ // verify effect is applied.
+ const expected_progress = 0.5;
+ assert_equals(
+ animation.effect.getComputedTiming().progress,
+ expected_progress,
+ "Verify effect progress after pausing.");
+
+ // cause the timeline to become inactive
+ scroller.style.overflow = 'visible';
+ await waitForAnimationFrames(2);
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(
+ animation.effect.getComputedTiming().progress,
+ expected_progress,
+ "Verify effect progress after the timeline goes inactive.");
+ }, 'Animation effect is still applied after pausing and making timeline ' +
+ 'inactive.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedOpacityAnimationWithDelays(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // cause the timeline to become inactive
+ scroller.style.overflow = 'visible';
+
+ scroller.scrollTop;
+
+ animation.pause();
+ }, 'Make timeline inactive, force style update then pause the animation. ' +
+ 'No crashing indicates test success.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html
new file mode 100644
index 0000000000..02220cee14
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation-inactive-timeline.html
@@ -0,0 +1,170 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Test basic functionality of scroll linked animation.</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Ensure we have a valid animation frame time before continuing the test.
+ // This is so that we can properly determine frame advancement after the
+ // style change.
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.play();
+ assert_equals(animation.currentTime, null,
+ 'The current time is null when the timeline is inactive.');
+ assert_equals(animation.startTime, null,
+ 'The start time is unresolved while play-pending.');
+ await waitForNextFrame();
+ assert_true(animation.pending,
+ 'Animation has play pending task while timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ `State is 'running' in Pending state.`);
+}, `Play pending task doesn't run when the timeline is inactive.`);
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Play the animation when the timeline is inactive.
+ animation.play();
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await animation.ready;
+ // Ready promise is resolved as a result of the timeline becoming active.
+ assert_percents_equal(animation.currentTime, 0,
+ 'Animation current time is resolved when the animation is ready.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Animation start time is resolved when the animation is ready.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after animation.play call.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const target = animation.effect.target;
+
+ await waitForNextFrame();
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ // Set start time when the timeline is inactive.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_equals(animation.currentTime, null,
+ 'Sanity check current time is unresolved when the timeline ' +
+ 'is inactive.');
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Animation current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Animation start time is resolved.');
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 0,
+ 'Effect local time is resolved when the timeline is active.');
+ assert_equals(Number(getComputedStyle(target).opacity), 0,
+ 'Animation has an effect when the timeline is active.');
+}, 'Animation start and current times are correct if scroll timeline is ' +
+ 'activated after setting start time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const target = animation.effect.target;
+
+ await waitForNextFrame();
+
+ // Advance the scroller.
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ // Play the animation when the timeline is active.
+ animation.play();
+ await animation.ready;
+
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive.');
+ assert_equals(animation.playState, 'running',
+ `State is 'running' when the timeline is inactive.`);
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Start time is zero when the timeline is inactive.');
+ assert_equals(animation.effect.getComputedTiming().localTime, null,
+ 'Effect local time is null when the timeline is inactive.');
+ assert_equals(Number(getComputedStyle(target).opacity), 1,
+ 'Animation does not have an effect when the timeline is inactive.');
+
+ // Make the scroll timeline active.
+ scroller.style.overflow = 'auto';
+ await waitForNextFrame();
+
+ assert_equals(animation.playState, 'running',
+ `State is 'running' when the timeline is active.`);
+ assert_percents_equal(animation.currentTime, 20,
+ 'Current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.startTime, 0,
+ 'Start time is zero when the timeline is active.');
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, 20,
+ 'Effect local time is resolved when the timeline is active.');
+ assert_equals(Number(getComputedStyle(target).opacity), 0.2,
+ 'Animation has an effect when the timeline is active.');
+}, 'Animation current time is correct when the timeline becomes newly ' +
+ 'inactive and then active again.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html
new file mode 100644
index 0000000000..e3544762f6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-animation.html
@@ -0,0 +1,160 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Test basic functionality of scroll linked animation.</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+<script>
+ 'use strict';
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Verify initial start and current times in Idle state.
+ assert_equals(animation.currentTime, null,
+ "The current time is null in Idle state.");
+ assert_equals(animation.startTime, null,
+ "The start time is null in Idle state.");
+ animation.play();
+ assert_true(animation.pending, "Animation is in the pending state.");
+ // Verify initial start and current times in the pending state.
+ assert_equals(animation.currentTime, null,
+ "The current time remains null while in the pending state.");
+ assert_equals(animation.startTime, null,
+ "The start time remains null while in the pending state.");
+
+ await animation.ready;
+ // Verify initial start and current times once ready.
+ assert_percents_equal(animation.currentTime, 0,
+ "The current time is resolved when ready.");
+ assert_percents_equal(animation.startTime, 0,
+ "The start time is resolved when ready.");
+
+ // Now do some scrolling and make sure that the Animation current time is
+ // correct.
+ scroller.scrollTop = 0.4 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "The current time corresponds to the scroll position of the scroller.");
+ assert_times_equal(
+ animation.effect.getComputedTiming().progress,
+ animation.timeline.currentTime.value / 100,
+ 'Effect progress corresponds to the scroll position of the scroller.');
+}, 'Animation start and current times are correct for each animation state.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Advance the scroller.
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Verify initial start and current times in Idle state.
+ assert_equals(animation.currentTime, null,
+ "The current time is null in Idle state.");
+ assert_equals(animation.startTime, null,
+ "The start time is null in Idle state.");
+ animation.play();
+ // Verify initial start and current times in Pending state.
+ assert_equals(animation.currentTime, null,
+ "The current time remains unresolved while play-pending.");
+ assert_equals(animation.startTime, null,
+ "The start time remains unresolved while play-pending.");
+
+ await animation.ready;
+ // Verify initial start and current times in Playing state.
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "The current corresponds to the scroll position of the scroller.");
+ assert_percents_equal(animation.startTime, 0,
+ "The start time is zero in Playing state.");
+}, 'Animation start and current times are correct for each animation state' +
+ ' when the animation starts playing with advanced scroller.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+ // Advance the scroller to max position.
+ scroller.scrollTop = maxScroll;
+
+ await animation.finished;
+
+ assert_equals(animation.playState, 'finished',
+ 'Animation state is in finished state.');
+ assert_percents_equal(animation.currentTime, 100,
+ 'Animation current time is at 100% on reverse scrolling.');
+
+ // Scroll back.
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ // Verify animation state and current time on reverse scrolling.
+ assert_equals(animation.playState, 'running',
+ 'Animation state is playing on reverse scrolling.');
+ assert_percents_equal(animation.currentTime, 20,
+ 'Animation current time is updated on reverse scrolling.');
+}, 'Finished animation plays on reverse scrolling.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ await animation.ready;
+
+ // Advance the scroller to max position.
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ var sent_finish_event = false;
+ animation.onfinish = function() {
+ sent_finish_event = true;
+ };
+
+ // Scroll back.
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_false(sent_finish_event,
+ "No animation finished event is sent on reverse scroll.");
+
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ // Wait for next frame to allow the animation to send finish events. The
+ // finished promise fires before events are sent.
+ await waitForNextFrame();
+
+ assert_true(sent_finish_event,
+ "Animation finished event is sent on reaching max scroll.");
+}, 'Sending animation finished events by finished animation on reverse ' +
+ 'scrolling.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html
new file mode 100644
index 0000000000..07edbd83f1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-in-removed-iframe-crash.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Starting an animation with a scroll timeline in a removed iframe
+ should not crash</title>
+</head>
+<body>
+ <div id="target"></div>
+ <iframe id="frame"></iframe>
+</body>
+<script type="text/javascript">
+ const target = document.getElementById('target');
+ const frame = document.getElementById('frame');
+ const timeline = new frame.contentWindow.ScrollTimeline();
+ frame.remove();
+ target.animate(null, {timeline: timeline});
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html
new file mode 100644
index 0000000000..a26500989e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-invalidation.html
@@ -0,0 +1,133 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline invalidation</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#current-time-algorithm">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+<div id="log"></div>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const effect_duration = 350;
+ animation.effect.updateTiming({ duration: effect_duration });
+ const scroller = animation.timeline.source;
+ let maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ const initial_progress = (scroller.scrollTop / maxScroll) * 100;
+
+ animation.play();
+ await animation.ready;
+
+ // Animation current time is at 20% because scroller was scrolled to 20%
+ assert_percents_equal(animation.currentTime, 20);
+ assert_equals(scroller.scrollTop, 180);
+ assert_equals(maxScroll, 900);
+
+ // Shrink scroller content size (from 1000 to 500).
+ // scroller.scrollTop maintains the same offset, which means shrinking the
+ // content has the effect of skipping the animation forward.
+ scroller.firstChild.style.height = "500px";
+ maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ assert_equals(scroller.scrollTop, 180);
+ assert_equals(maxScroll, 400);
+ await waitForNextFrame();
+
+ const expected_progress = (scroller.scrollTop / maxScroll) * 100;
+ assert_true(expected_progress > initial_progress)
+ // @ 45%
+ assert_percents_equal(animation.currentTime, expected_progress);
+ assert_percents_equal(animation.timeline.currentTime, expected_progress);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime, expected_progress);
+}, 'Animation current time and effect local time are updated after scroller ' +
+ 'content size changes.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.effect.updateTiming({ duration: 350 });
+ const scroller = animation.timeline.source;
+ let maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const scrollOffset = 0.2 * maxScroll
+ scroller.scrollTop = scrollOffset;
+ const initial_progress = (scroller.scrollTop / maxScroll) * 100;
+
+ animation.play();
+ await animation.ready;
+
+ // Animation current time is at 20% because scroller was scrolled to 20%
+ // assert_equals(animation.currentTime.value, 20);
+ assert_percents_equal(animation.currentTime, 20);
+ assert_equals(scroller.scrollTop, scrollOffset);
+ assert_equals(maxScroll, 900);
+
+ // Change scroller size.
+ scroller.style.height = "500px";
+ maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ assert_equals(scroller.scrollTop, scrollOffset);
+ assert_equals(maxScroll, 500);
+ await waitForNextFrame();
+
+ const expected_progress = (scroller.scrollTop / maxScroll) * 100;
+ assert_true(expected_progress > initial_progress);
+ // @ 45%
+ assert_percents_equal(animation.currentTime, expected_progress);
+ assert_percents_equal(animation.timeline.currentTime, expected_progress);
+ assert_percents_equal(animation.effect.getComputedTiming().localTime,
+ expected_progress);
+}, 'Animation current time and effect local time are updated after scroller ' +
+ 'size changes.');
+
+promise_test(async t => {
+ await waitForNextFrame();
+
+ const timeline = createScrollTimeline(t);
+ const scroller = timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ // Instantiate scroll animation that resizes its scroll timeline scroller.
+ const animation = new Animation(
+ new KeyframeEffect(
+ timeline.source.firstChild,
+ [{ height: '1000px', easing: 'steps(2, jump-none)'},
+ { height: '2000px' }],
+ ), timeline);
+
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(timeline.currentTime, 0);
+ assert_equals(scroller.scrollHeight, 1000);
+
+ await runAndWaitForFrameUpdate(() => {
+ scroller.scrollTop = 0.6 * maxScroll;
+ });
+
+ // Applying the animation effect alters the height of the scroll content and
+ // makes the scroll timeline stale.
+ // https://github.com/w3c/csswg-drafts/issues/8694
+
+ // With a single layout, timeline current time would be at 60%, but the
+ // timeline would be stale.
+ const expected_progress = 60 * maxScroll / (maxScroll + 1000);
+ assert_approx_equals(timeline.currentTime.value, expected_progress, 0.1);
+
+}, 'If scroll animation resizes its scroll timeline scroller, ' +
+ 'layout reruns once per frame.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html
new file mode 100644
index 0000000000..cc844cb748
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-range.html
@@ -0,0 +1,185 @@
+<!DOCTYPE html>
+<title>Scroll timelines and animation attachment ranges</title>
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#animation-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<style>
+ #scroller {
+ overflow-y: scroll;
+ width: 200px;
+ height: 200px;
+ scroll-timeline: --t1;
+ }
+ .spacer {
+ height: 1100px;
+ }
+ #target {
+ height: 100px;
+ width: 0;
+ font-size: 10px;
+ background-color: green;
+ }
+ @keyframes grow {
+ to { width: 200px }
+ }
+ .anim-1 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 25%;
+ animation-range-end: 75%;
+ }
+ .anim-2 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 40px;
+ animation-range-end: 700px;
+ }
+ .anim-3 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: calc(30% + 40px);
+ animation-range-end: calc(70% - 20px);
+ }
+ .anim-4 {
+ animation: auto grow linear;
+ animation-timeline: --t1;
+ animation-range-start: 5em;
+ animation-range-end: calc(100% - 5em);
+ }
+
+</style>
+<div id=scroller>
+ <div id=target></div>
+ <div class=spacer></div>
+</div>
+<script>
+ // Test via web-animation API.
+
+ async function test_range_waapi(t, options) {
+ const timeline = new ScrollTimeline({source: scroller, axis: 'block'});
+ const anim =
+ target.animate([{ width: "200px" }],
+ {
+ timeline: timeline,
+ rangeStart: options.rangeStart,
+ rangeEnd: options.rangeEnd
+ });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ await anim.ready;
+ scroller.scrollTop = 600;
+ await waitForNextFrame();
+
+ const expectedProgress =
+ (600 - options.startOffset) / (options.endOffset - options.startOffset);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "25%",
+ rangeEnd: "75%",
+ startOffset: 250,
+ endOffset: 750
+ });
+ }, 'Scroll timeline with percentage range [JavaScript API]');
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "40px",
+ rangeEnd: "700px",
+ startOffset: 40,
+ endOffset: 700
+ });
+ }, 'Scroll timeline with px range [JavaScript API]');
+
+ promise_test(async t => {
+ await test_range_waapi(t, {
+ rangeStart: "calc(30% + 40px)",
+ rangeEnd: "calc(70% - 20px)",
+ startOffset: 340,
+ endOffset: 680
+ });
+ }, 'Scroll timeline with calculated range [JavaScript API]');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.style.fontSize = '';
+ });
+ await test_range_waapi(t, {
+ rangeStart: "5em",
+ rangeEnd: "calc(100% - 5em)",
+ startOffset: 50,
+ endOffset: 950
+ });
+ target.style.fontSize = '20px';
+ await waitForNextFrame();
+ const anim = target.getAnimations()[0];
+ const expectedProgress = (600 - 100) / (900 - 100);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }, 'Scroll timeline with EM range [JavaScript API]');
+
+ // Test via CSS.
+ async function test_range_css(t, options) {
+ t.add_cleanup(() => {
+ target.classList.remove(options.animation);
+ });
+ target.classList.add(options.animation);
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ scroller.scrollTop = 600;
+ await waitForNextFrame();
+
+ const expectedProgress =
+ (600 - options.startOffset) / (options.endOffset - options.startOffset);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-1",
+ startOffset: 250,
+ endOffset: 750
+ });
+ }, 'Scroll timeline with percentage range [CSS]');
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-2",
+ startOffset: 40,
+ endOffset: 700
+ });
+ }, 'Scroll timeline with px range [CSS]');
+
+ promise_test(async t => {
+ await test_range_css(t, {
+ animation: "anim-3",
+ startOffset: 340,
+ endOffset: 680
+ });
+ }, 'Scroll timeline with calculated range [CSS]');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.style.fontSize = '';
+ });
+ await test_range_css(t, {
+ animation: "anim-4",
+ startOffset: 50,
+ endOffset: 950
+ });
+ target.style.fontSize = '20px';
+ await waitForNextFrame();
+ const anim = target.getAnimations()[0];
+ const expectedProgress = (600 - 100) / (900 - 100);
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ expectedProgress, 0.001);
+ }, 'Scroll timeline with EM range [CSS]');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html
new file mode 100644
index 0000000000..1e43699d5b
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>ScrollTimeline snapshotting</title>
+<link rel="help" href="https://wicg.github.io/scroll-animations/#avoiding-cycles">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/resources/testdriver-actions.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="./testcommon.js"></script>
+
+<style>
+ body {
+ height: 800px;
+ width: 800px;
+ }
+</style>
+<div id="log"></div>
+
+<script>
+'use strict';
+
+promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const timeline = new ScrollTimeline({source: scroller});
+ scroller.scrollTo(0, 0);
+ assert_equals(scroller.scrollTop, 0, "verify test pre-condition");
+
+ scroller.scrollBy({top: 100, behavior: 'smooth'});
+ // Wait for the scroll to change.
+ const startScroll = scroller.scrollTop;
+ do {
+ await waitForNextFrame();
+ } while(scroller.scrollTop == startScroll);
+ assert_percents_equal(
+ timeline.currentTime,
+ (scroller.scrollTop / maxScroll) * 100,
+ 'Scroll timeline current time corresponds to the scroll position.');
+}, 'ScrollTimeline current time is updated after programmatic animated ' +
+ 'scroll.');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html
new file mode 100644
index 0000000000..280346e755
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/set-current-time-before-play.html
@@ -0,0 +1,75 @@
+<html class="reftest-wait">
+<title>Setting current time before play should not timeout</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Regression test to make sure the ready promise is correctly resolved">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"></div>
+</div>
+
+<script>
+ async function runTest() {
+ await waitForCompositorReady();
+
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ animation.currentTime = CSS.percent(0);
+ animation.play();
+
+ animation.ready.then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html
new file mode 100644
index 0000000000..f6c826db69
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-current-time.html
@@ -0,0 +1,286 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the current time of an animation</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations-1/#setting-the-current-time-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+
+ assert_throws_js(TypeError, () => {
+ animation.currentTime = null;
+ });
+}, 'Setting animation current time to null throws TypeError.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("300");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("300ms");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.currentTime = CSSNumericValue.parse("0.3s");
+ });
+}, 'Setting the current time to an absolute time value throws exception');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a valid value without playing.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a valid value while playing.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("200%");
+
+ assert_equals(animation.playState, "finished");
+ assert_percents_equal(animation.currentTime, 200,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a value beyond effect end.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("-10%");
+
+ assert_equals(animation.playState, "running");
+ assert_percents_equal(animation.currentTime, -10,
+ "Animation current time should be equal to the set value."
+ );
+}, 'Set animation current time to a negative value.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+
+ assert_equals(animation.playState, "running");
+ assert_true(animation.pending);
+ assert_percents_equal(animation.currentTime, 30);
+}, "Setting current time while play pending overrides the current time");
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ animation.currentTime = CSSNumericValue.parse("33.3%");
+
+ assert_percents_equal(animation.currentTime, 33.3,
+ "Animation current time should be equal to the set value."
+ );
+
+ // Cancel the animation and play it again, check that current time has reset
+ // to scroll offset based current time.
+ animation.cancel();
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, animation.timeline.currentTime,
+ "Animation current time should return to a value matching its" +
+ " timeline current time after animation is cancelled and played again."
+ );
+}, 'Setting animation.currentTime then restarting the animation should' +
+ ' reset the current time.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ const originalCurrentTime = animation.currentTime.value;
+
+ // Set the current time to something other than where the scroll offset.
+ animation.currentTime = CSSNumericValue.parse("50%");
+
+ // Setting current time is internally setting the start time to
+ // scrollTimeline.currentTime - newAnimationCurrentTime.
+ // Which results in current time of (timeline.currentTime - start_time).
+ // This behavior puts the animation in a strange "out of sync" state between
+ // the scroller and the animation effect, this is currently expected
+ // behavior.
+
+ const expectedStartTime = originalCurrentTime - animation.currentTime.value;
+ assert_percents_equal(animation.startTime, expectedStartTime,
+ "Animation current time should be updated when setting the current time" +
+ " to a time within the range of the animation.");
+
+ scroller.scrollTop = 0.7 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.startTime, expectedStartTime,
+ "Animation start time should remain unchanged when the scroller changes" +
+ " position."
+ );
+ assert_percents_equal(animation.currentTime,
+ animation.timeline.currentTime.value - animation.startTime.value,
+ "Animation current time should return to a value equal to" +
+ " (timeline.currentTime - animation.startTime) after timeline scroll" +
+ " source has been scrolled."
+ );
+}, 'Set Animation current time then scroll.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time should be equal to the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation play state is \'paused\' when current time is set and ' +
+ 'timeline is inactive.');
+}, 'Animation current time and play state are correct when current time is ' +
+ 'set while the timeline is inactive.');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ // Make the timeline inactive.
+ scroller.style.overflow = 'visible';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_equals(animation.timeline.currentTime, null,
+ 'Current time is unresolved when the timeline is inactive.');
+
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time should be equal to the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation play state is \'paused\' when current time is set and ' +
+ 'timeline is inactive.');
+
+ // Make the timeline active.
+ scroller.style.overflow = 'auto';
+ scroller.scrollTop;
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.timeline.currentTime, 0,
+ 'Current time is resolved when the timeline is active.');
+ assert_percents_equal(animation.currentTime, 30,
+ 'Animation current time holds the set value.');
+ assert_equals(animation.playState, 'paused',
+ 'Animation holds \'paused\' state.');
+}, 'Animation current time set while the timeline is inactive holds when the ' +
+ 'timeline becomes active again.');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html
new file mode 100644
index 0000000000..e7e96a27e1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-playback-rate.html
@@ -0,0 +1,298 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the playback rate of an animation that is using a ScrollTimeline</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-playback-rate-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+ 'use strict';
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // this forces a layout which results in an active timeline
+ scroller.scrollTop = 0;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.playbackRate = 0.5;
+ animation.play();
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Zero current time is not affected by playbackRate change.');
+ }, 'Zero current time is not affected by playbackRate set while the ' +
+ 'animation is in idle state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // this forces a layout which results in an active timeline
+ scroller.scrollTop = 0;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.play();
+ await animation.ready;
+ animation.playbackRate = 0.5;
+
+ assert_percents_equal(animation.currentTime, 0,
+ 'Zero current time is not affected by playbackRate change.');
+ }, 'Zero current time is not affected by playbackRate set while the ' +
+ 'animation is in play-pending state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.playbackRate = 0.5;
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10,
+ 'Initial current time is scaled by playbackRate change.');
+ }, 'Initial current time is scaled by playbackRate set while ' +
+ 'scroll-linked animation is in running state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ const playbackRate = 2;
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ animation.play();
+ await animation.ready;
+ // Set playback rate while the animation is playing.
+ animation.playbackRate = playbackRate;
+ assert_percents_equal(animation.currentTime, 40,
+ 'The current time is scaled by the playback rate.');
+ }, 'The current time is scaled by playbackRate set while the ' +
+ 'scroll-linked animation is in play state.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Set playback rate while the animation is in 'idle' state.
+ animation.playbackRate = 2;
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(animation.currentTime, 40,
+ 'The current time should increase two times faster ' +
+ 'than timeline time.');
+ }, 'The playback rate set before scroll-linked animation started playing ' +
+ 'affects the rate of progress of the current time');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+
+ await animation.ready;
+
+ animation.playbackRate = 2;
+ scroller.scrollTop = 0.25 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ assert_percents_equal(
+ animation.currentTime,
+ animation.timeline.currentTime.value * animation.playbackRate,
+ 'The current time should increase two times faster than timeline time');
+ }, 'The playback rate affects the rate of progress of the current time' +
+ ' when scrolling');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ // Setting the current time while play-pending sets the hold time and not
+ // the start time. currentTime is unaffected by playback rate until no
+ // longer pending.
+ animation.currentTime = CSSNumericValue.parse("25%");
+ animation.playbackRate = 2;
+
+ assert_equals(animation.playState, "running");
+ assert_true(animation.pending);
+ assert_percents_equal(animation.currentTime, 25);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 25);
+ }, 'Setting the playback rate while play-pending does not scale current ' +
+ 'time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.25 * maxScroll;
+ animation.play();
+ await animation.ready;
+ animation.playbackRate = 2;
+
+ assert_percents_equal(animation.currentTime, 50);
+ }, 'Setting the playback rate while playing scales current time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("25%");
+ await animation.ready;
+ animation.playbackRate = 2;
+
+ assert_percents_equal(animation.currentTime, 50);
+ }, 'Setting the playback rate while playing scales the set current time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.playbackRate = -1;
+ scroller.scrollTop = 0.3 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ const expectedCurrentTime = 100 - animation.timeline.currentTime.value;
+ assert_percents_equal(animation.currentTime, expectedCurrentTime);
+ }, 'Negative initial playback rate should correctly modify initial current' +
+ ' time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+ animation.play();
+
+ await animation.ready;
+ const startingTimelineTime = animation.timeline.currentTime;
+ const startingCurrentTime = animation.currentTime;
+ assert_percents_equal(startingCurrentTime, 50);
+ assert_percents_equal(startingTimelineTime, 50);
+
+ animation.playbackRate = -1;
+
+ scroller.scrollTop = 0.8 * maxScroll;
+ await waitForNextFrame();
+ // -300 = 500 - 800
+
+ // let timelineDiff =
+ // startingTimelineTime.value - animation.timeline.currentTime.value;
+ // // 200 = 500 + (-300)
+ // let expected = startingCurrentTime.value + timelineDiff;
+ assert_percents_equal(animation.timeline.currentTime, 80);
+ assert_percents_equal(animation.currentTime, 20);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+ // // 300 = 500 - 200
+ // timelineDiff =
+ // startingTimelineTime.value - animation.timeline.currentTime.value;
+ // // 800 = 500 + 300
+ // expected = startingCurrentTime.value + timelineDiff;
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 80);
+ }, 'Reversing the playback rate while playing correctly impacts current' +
+ ' time during future scrolls');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.playbackRate = 0;
+ scroller.scrollTop = 0.3 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 0);
+ }, 'Zero initial playback rate should correctly modify initial current' +
+ ' time.');
+
+ promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 20);
+ animation.playbackRate = 0;
+ scroller.scrollTop = 0.5 * maxScroll;
+ await waitForNextFrame();
+
+ // Ensure that current time does not change.
+ assert_percents_equal(animation.timeline.currentTime, 50);
+ assert_percents_equal(animation.currentTime, 0);
+ }, 'Setting a zero playback rate while running preserves the start time');
+
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ await animation.ready;
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 20);
+ animation.startTime = animation.currentTime;
+ // timeline current time [0%, 100%] --> animation current time [-20%, 80%].
+ assert_percents_equal(animation.currentTime, 0);
+
+ animation.playbackRate = -1;
+ // timeline current time [0%, 100%] --> animation current time [80%, -20%].
+ // timeline @ 20% --> animation current time @ (20% - 80%) * (-1) = 60%.
+ assert_percents_equal(animation.timeline.currentTime, 20);
+ assert_percents_equal(animation.currentTime, 60);
+ }, 'Reversing an animation with non-boundary aligned start time ' +
+ 'symmetrically adjusts the start time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html
new file mode 100644
index 0000000000..aae1849565
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-start-time.html
@@ -0,0 +1,401 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the start time of scroll animation</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#setting-the-start-time-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("300");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("300ms");
+ });
+ assert_throws_dom('NotSupportedError', () => {
+ animation.startTime = CSSNumericValue.parse("0.3s");
+ });
+}, 'Setting the start time to an absolute time value throws exception');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // So long as a hold time is set, querying the current time will return
+ // the hold time.
+
+ // Since the start time is unresolved at this point, setting the current time
+ // will set the hold time
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_equals(animation.startTime, null, 'The start time stays unresolved');
+ assert_percents_equal(animation.currentTime, 30,
+ 'The current time is calculated from the hold time');
+
+ // If we set the start time, however, we should clear the hold time.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 20,
+ 'The current time is calculated from the start time, ' +
+ 'not the hold time');
+ // Sanity check
+ assert_equals(animation.playState, 'running',
+ 'Animation reports it is running after setting a resolved ' +
+ 'start time');
+}, 'Setting the start time clears the hold time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ // So long as a hold time is set, querying the current time will return
+ // the hold time.
+
+ // Since the start time is unresolved at this point, setting the current time
+ // will set the hold time
+ animation.currentTime = CSSNumericValue.parse("30%");
+ assert_equals(animation.startTime, null, 'The start time stays unresolved');
+ assert_percents_equal(animation.currentTime, 30,
+ 'The current time is calculated from the hold time');
+
+ // If we set the start time, however, we should clear the hold time.
+ animation.startTime = CSSNumericValue.parse("0%");
+ assert_percents_equal(animation.startTime, 0,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'The current time is calculated from the start time, not' +
+ ' the hold time');
+ // Sanity check
+ assert_equals(animation.playState, 'running',
+ 'Animation reports it is running after setting a resolved ' +
+ 'start time');
+}, 'Setting the start time clears the hold time when the timeline is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.2 * maxScroll;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Set up a running animation (i.e. both start time and current time
+ // are resolved).
+ animation.startTime = CSSNumericValue.parse("5%");
+ assert_equals(animation.playState, 'running');
+ assert_percents_equal(animation.startTime, 5,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 15,
+ 'Current time is resolved for a running animation');
+
+ // Clear start time
+ animation.startTime = null;
+ assert_equals(animation.startTime, null,
+ 'The start time is set to the requested value');
+ assert_percents_equal(animation.currentTime, 15,
+ 'Hold time is set after start time is made unresolved');
+ assert_equals(animation.playState, 'paused',
+ 'Animation reports it is paused after setting an unresolved'
+ + ' start time');
+}, 'Setting an unresolved start time sets the hold time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ // Set up a running animation (i.e. both start time and current time
+ // are resolved).
+ animation.startTime = CSSNumericValue.parse("5%");
+ assert_equals(animation.playState, 'running');
+ assert_percents_equal(animation.startTime, 5,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'Current time is unresolved for a running animation when the ' +
+ 'timeline is inactive');
+
+ // Clear start time
+ animation.startTime = null;
+ assert_equals(animation.startTime, null,
+ 'The start time is set to the requested value');
+ assert_equals(animation.currentTime, null,
+ 'Hold time is set to unresolved after start time is made ' +
+ 'unresolved');
+ assert_equals(animation.playState, 'idle',
+ 'Animation reports it is idle after setting an unresolved'
+ + ' start time');
+}, 'Setting an unresolved start time sets the hold time to unresolved when ' +
+ 'the timeline is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'running',
+ 'Animation is in play-pending state');
+
+ // Setting the start time should resolve the 'ready' promise, i.e.
+ // it should schedule a microtask to run the promise callbacks.
+ animation.startTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.startTime, 10,
+ 'The start time is set to the requested value');
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ // If we schedule another microtask then it should run immediately after
+ // the ready promise resolution microtask.
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending ready promise');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ // Make the scroll timeline inactive.
+ scroller.style.overflow = 'visible';
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ assert_equals(animation.timeline.currentTime, null,
+ 'Sanity check the timeline is inactive');
+
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'running',
+ 'Animation is in play-pending state');
+
+ // Setting the start time should resolve the 'ready' promise, i.e.
+ // it should schedule a microtask to run the promise callbacks.
+ animation.startTime = CSSNumericValue.parse("10%");
+ assert_percents_equal(animation.startTime, 10,
+ 'The start time is set to the requested value');
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ // If we schedule another microtask then it should run immediately after
+ // the ready promise resolution microtask.
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending ready promise when the timeline' +
+ 'is inactive');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Put the animation in the play-pending state
+ animation.play();
+
+ // Sanity check
+ assert_true(animation.pending, 'Animation is pending');
+ assert_equals(animation.playState, 'running', 'Animation is play-pending');
+ assert_equals(animation.startTime, null, 'Start time is unresolved');
+
+ // Setting start time should cancel the pending task.
+ animation.startTime = null;
+ assert_false(animation.pending, 'Animation is no longer pending');
+ assert_equals(animation.playState, 'idle', 'Animation is idle');
+}, 'Setting an unresolved start time on a play-pending animation makes it'
+ + ' idle');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+
+ // Set start time such that the current time is past the end time
+ animation.startTime = CSSNumericValue.parse("-110%");
+ assert_percents_equal(animation.startTime, -110,
+ 'The start time is set to the requested value');
+ assert_equals(animation.playState, 'finished',
+ 'Seeked to finished state using the startTime');
+
+ // If the 'did seek' flag is true, the current time should be greater than
+ // the effect end.
+ assert_greater_than(animation.currentTime.value,
+ animation.effect.getComputedTiming().endTime.value,
+ 'Setting the start time updated the finished state with'
+ + ' the \'did seek\' flag set to true');
+
+ // Furthermore, that time should persist if we have correctly updated
+ // the hold time
+ const finishedCurrentTime = animation.currentTime;
+ await waitForNextFrame();
+ assert_percents_equal(animation.currentTime, finishedCurrentTime,
+ 'Current time does not change after seeking past the ' +
+ 'effect end time by setting the current time');
+}, 'Setting the start time updates the finished state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+
+ await animation.ready;
+ assert_equals(animation.playState, 'running');
+
+ // Setting the start time updates the finished state. The hold time is not
+ // constrained by the effect end time.
+ animation.startTime = CSSNumericValue.parse("-110%");
+ assert_equals(animation.playState, 'finished');
+
+ assert_percents_equal(animation.currentTime, 110);
+}, 'Setting the start time on a running animation updates the play state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Setting the start time updates the finished state. The hold time is not
+ // constrained by the normal range of the animation time.
+ animation.currentTime = CSSNumericValue.parse("100%");
+ assert_equals(animation.playState, 'finished', 'Animation is finished');
+ animation.playbackRate = -1;
+ assert_equals(animation.playState, 'running', 'Animation is running');
+ animation.startTime = CSSNumericValue.parse("-200%");
+ assert_equals(animation.playState, 'finished', 'Animation is finished');
+ assert_percents_equal(animation.currentTime, -200);
+}, 'Setting the start time on a reverse running animation updates the play '
+ + 'state');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ let readyPromiseCallbackCalled = false;
+ animation.ready.then(() => { readyPromiseCallbackCalled = true; } );
+ animation.pause();
+
+ // Sanity check
+ assert_true(animation.pending && animation.playState === 'paused',
+ 'Animation is in pause-pending state');
+
+ // Setting the start time should resolve the 'ready' promise although
+ // the resolution callbacks when be run in a separate microtask.
+ animation.startTime = null;
+ assert_false(readyPromiseCallbackCalled,
+ 'Ready promise callback is not called synchronously');
+
+ await Promise.resolve();
+ assert_true(readyPromiseCallbackCalled,
+ 'Ready promise callback called after setting startTime');
+}, 'Setting the start time resolves a pending pause task');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ // We should be play-pending now
+ assert_true(anim.pending);
+ assert_equals(anim.playState, 'running');
+
+ // Apply a pending playback rate
+ anim.updatePlaybackRate(2);
+ assert_equals(anim.playbackRate, 1);
+ assert_true(anim.pending);
+
+ // Setting the start time should apply the pending playback rate
+ anim.startTime = CSSNumericValue.parse(
+ anim.timeline.currentTime.value - 2500 + "%");
+ assert_equals(anim.playbackRate, 2);
+ assert_false(anim.pending);
+
+ // Sanity check that the start time is preserved and current time is
+ // calculated using the new playback rate
+ assert_percents_equal(anim.startTime,
+ anim.timeline.currentTime.value - 2500);
+ assert_percents_equal(anim.currentTime, 5000);
+}, 'Setting the start time of a play-pending animation applies a pending '
+ + 'playback rate');
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ anim.play();
+ await anim.ready;
+
+ // We should be running now
+ assert_false(anim.pending);
+ assert_equals(anim.playState, 'running');
+
+ // Apply a pending playback rate
+ anim.updatePlaybackRate(2);
+ assert_equals(anim.playbackRate, 1);
+ assert_true(anim.pending);
+
+ // Setting the start time should apply the pending playback rate
+ anim.startTime = CSSNumericValue.parse(
+ anim.timeline.currentTime.value - 25 + "%");
+ assert_equals(anim.playbackRate, 2);
+ assert_false(anim.pending);
+
+ // Sanity check that the start time is preserved and current time is
+ // calculated using the new playback rate
+ assert_percents_equal(anim.startTime,
+ anim.timeline.currentTime.value - 25);
+ assert_percents_equal(anim.currentTime, 50);
+}, 'Setting the start time of a playing animation applies a pending playback '
+ + 'rate');
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html
new file mode 100644
index 0000000000..5813de60fa
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/setting-timeline.tentative.html
@@ -0,0 +1,429 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Setting the timeline of scroll animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations-1/#setting-the-timeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+ .scroller {
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 200px;
+ width: 100px;
+ will-change: transform;
+ }
+ .contents {
+ /* The height is set to align scrolling in pixels with logical time in ms */
+ height: 1200px;
+ width: 100%;
+ }
+ @keyframes anim {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+ .anim {
+ animation: anim 1s paused linear;
+ }
+ #target {
+ height: 100px;
+ width: 100px;
+ background-color: green;
+ margin-top: -1000px;
+ }
+</style>
+<body>
+<script>
+'use strict';
+
+function createAnimation(t) {
+ const elem = createDiv(t);
+ const animation = elem.animate({ opacity: [1, 0] }, 1000);
+ return animation;
+}
+
+function createPausedCssAnimation(t) {
+ const elem = createDiv(t);
+ elem.classList.add('anim');
+ return elem.getAnimations()[0];
+}
+
+function updateScrollPosition(timeline, offset) {
+ const scroller = timeline.source;
+ assert_true(!!scroller, 'source is resolved');
+ scroller.scrollTop = offset;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ return waitForNextFrame();
+}
+
+function assert_timeline_current_time(animation, timeline_current_time) {
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.timeline.currentTime, timeline_current_time,
+ `Timeline's currentTime aligns with the scroll ` +
+ `position even when paused`);
+ }
+ else {
+ assert_times_equal(animation.timeline.currentTime, timeline_current_time,
+ `Timeline's currentTime aligns with the scroll ` +
+ `position even when paused`);
+ }
+}
+
+function assert_scroll_synced_times(animation, timeline_current_time,
+ animation_current_time) {
+ assert_timeline_current_time(animation, timeline_current_time);
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime aligns with the scroll position`);
+ }
+ else {
+ assert_times_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime aligns with the scroll position`);
+ }
+}
+
+function assert_paused_times(animation, timeline_current_time,
+ animation_current_time) {
+ assert_timeline_current_time(animation, timeline_current_time);
+ if (animation.currentTime instanceof CSSUnitValue){
+ assert_percents_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime is fixed while paused`);
+ }
+ else {
+ assert_times_equal(animation.currentTime, animation_current_time,
+ `Animation's currentTime is fixed while paused`);
+ }
+}
+
+function createViewTimeline(t) {
+ const parent = document.querySelector('.scroller');
+ const elem = document.createElement('div');
+ elem.id = 'target';
+ t.add_cleanup(() => {
+ elem.remove();
+ });
+ parent.appendChild(elem);
+ return new ViewTimeline({ subject: elem });
+}
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ assert_equals(animation.playState, 'running');
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a play-pending animation synchronizes ' +
+ 'currentTime of the animation with the scroll position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 0);
+
+ await updateScrollPosition(animation.timeline, 200);
+
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 20, 0);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 20, 20);
+}, 'Setting a scroll timeline on a pause-pending animation fixes the ' +
+ 'currentTime of the animation based on the scroll position once resumed');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.reverse();
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+
+ assert_equals(animation.playState, 'running');
+ assert_scroll_synced_times(animation, 10, 90);
+}, 'Setting a scroll timeline on a reversed play-pending animation ' +
+ 'synchronizes the currentTime of the animation with the scroll ' +
+ 'position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_true(animation.pending);
+ assert_equals(animation.playState, 'running');
+ await animation.ready;
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a running animation synchronizes the ' +
+ 'currentTime of the animation with the scroll position.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 0);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 10);
+}, 'Setting a scroll timeline on a paused animation fixes the currentTime of ' +
+ 'the animation based on the scroll position when resumed');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.reverse();
+ animation.pause();
+ await animation.ready;
+
+ animation.timeline = scrollTimeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_paused_times(animation, 10, 100);
+
+ animation.play();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 90);
+}, 'Setting a scroll timeline on a reversed paused animation ' +
+ 'fixes the currentTime of the animation based on the scroll ' +
+ 'position when resumed');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.timeline = document.timeline;
+ assert_times_equal(animation.currentTime, 100);
+}, 'Transitioning from a scroll timeline to a document timeline on a running ' +
+ 'animation preserves currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.pause();
+ animation.timeline = document.timeline;
+
+ await animation.ready;
+ assert_times_equal(animation.currentTime, 100);
+}, 'Transitioning from a scroll timeline to a document timeline on a ' +
+ 'pause-pending animation preserves currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+
+ animation.reverse();
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.pause();
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 90);
+
+ animation.timeline = document.timeline;
+ assert_false(animation.pending);
+ assert_equals(animation.playState, 'paused');
+ assert_times_equal(animation.currentTime, 900);
+}, 'Transition from a scroll timeline to a document timeline on a reversed ' +
+ 'paused animation maintains correct currentTime');
+
+promise_test(async t => {
+ const animation = createAnimation(t);
+ const scrollTimeline = createScrollTimeline(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const progress = animation.currentTime.value / 100;
+ const duration = animation.effect.getTiming().duration;
+ animation.timeline = null;
+
+ const expectedCurrentTime = progress * duration;
+ assert_times_equal(animation.currentTime, expectedCurrentTime);
+}, 'Transitioning from a scroll timeline to a null timeline on a running ' +
+ 'animation preserves current progress.');
+
+promise_test(async t => {
+ const keyframeEfect = new KeyframeEffect(createDiv(t),
+ { opacity: [0, 1] },
+ 1000);
+ const animation = new Animation(keyframeEfect, null);
+ animation.startTime = 0;
+ assert_equals(animation.playState, 'running');
+
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ animation.timeline = scrollTimeline;
+ assert_equals(animation.playState, 'running');
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a null timeline to a scroll timeline on an animation with ' +
+ 'a resolved start time preserved the play state');
+
+promise_test(async t => {
+ const firstScrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(firstScrollTimeline, 100);
+
+ const secondScrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(secondScrollTimeline, 200);
+
+ const animation = createAnimation(t);
+ animation.timeline = firstScrollTimeline;
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+
+ animation.timeline = secondScrollTimeline;
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 20);
+}, 'Switching from one scroll timeline to another updates currentTime');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createPausedCssAnimation(t);
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ assert_equals(animation.playState, 'paused');
+ assert_percents_equal(animation.currentTime, 0);
+
+ const target = animation.effect.target;
+ target.style.animationPlayState = 'running';
+ await animation.ready;
+
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a document timeline to a scroll timeline updates ' +
+ 'currentTime when unpaused via CSS.');
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+
+ const animation = createAnimation(t);
+ animation.pause();
+ animation.currentTime = 500; // 50%
+ animation.timeline = scrollTimeline;
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50);
+
+ animation.play();
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 10);
+}, 'Switching from a document timeline to a scroll timeline and updating ' +
+ 'currentTime preserves the progress while paused.');
+
+promise_test(async t => {
+ const elem = createDiv(t);
+ const animation = elem.animate(null, Infinity);
+ await animation.ready;
+
+ animation.timeline = new ScrollTimeline();
+ let timing = animation.effect.getComputedTiming();
+ assert_percents_equal(timing.endTime, 100);
+ assert_percents_equal(timing.activeDuration, 100);
+ assert_percents_equal(timing.duration, 100);
+
+ animation.effect.updateTiming({ iterations: 2 });
+ timing = animation.effect.getComputedTiming();
+ assert_percents_equal(timing.endTime, 100);
+ assert_percents_equal(timing.activeDuration, 100);
+ assert_percents_equal(timing.duration, 50);
+
+ // Blink implementation does not permit setting an infinite number of
+ // iterations on a scroll-linked animation. Workaround by temporarily
+ // switching back to a document timeline.
+ animation.timeline = document.timeline;
+ animation.effect.updateTiming({ iterations: Infinity });
+ animation.timeline = new ScrollTimeline();
+ timing = animation.effect.getComputedTiming();
+ // Having an infinite number of iterations with a finite timeline results in
+ // each iteration having zero duration.
+ assert_percents_equal(timing.duration, 0);
+ // If either the iteration duration or iteration count is zero, the active
+ // duration is always zero.
+ assert_percents_equal(timing.activeDuration, 0);
+ assert_percents_equal(timing.endTime, 0);
+
+}, 'Switching from a document timeline to a scroll timeline on an infinite ' +
+ 'duration animation.');
+
+
+promise_test(async t => {
+ const scrollTimeline = createScrollTimeline(t);
+ const view_timeline = createViewTimeline(t);
+ await updateScrollPosition(scrollTimeline, 100);
+ const animation = createAnimation(t);
+ animation.timeline = scrollTimeline;
+ // Range name is ignored while attached to a non-view scroll-timeline.
+ // Offsets are still applied to the scroll-timeline.
+ animation.rangeStart = { rangeName: 'contain', offset: CSS.percent(10) };
+ animation.rangeEnd = { rangeName: 'contain', offset: CSS.percent(90) };
+ await animation.ready;
+
+ assert_scroll_synced_times(animation, 10, 0);
+ assert_percents_equal(animation.startTime, 10);
+
+ animation.timeline = view_timeline;
+ assert_true(animation.pending);
+ await animation.ready;
+
+ // Cover range is [0px, 300px]
+ // Contain range is [100px, 200px]
+ // start time = (contain 10% pos - cover start pos) / cover range * 100%
+ const expected_start_time = 110 / 300 * 100;
+ // timeline time = (scroll pos - cover start pos) / cover range * 100%
+ const expected_timeline_time = 100 / 300 * 100;
+ // current time = timeline time - start time.
+ const expected_current_time = expected_timeline_time - expected_start_time;
+
+ assert_percents_equal(animation.startTime, expected_start_time);
+ assert_percents_equal(animation.timeline.currentTime, expected_timeline_time);
+ assert_percents_equal(animation.currentTime, expected_current_time);
+}, 'Changing from a scroll-timeline to a view-timeline updates start time.');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html
new file mode 100644
index 0000000000..17e95a0519
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/source-quirks-mode.html
@@ -0,0 +1,36 @@
+<!-- Quirks mode -->
+<html>
+<head>
+ <title>ScrollTimeline default source in quirks mode</title>
+ <link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#dom-scrolltimeline-scrolltimeline">
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <style>
+ /* This is just to make it possible for #body1 to be
+ "potentially scrollable".
+
+ https://drafts.csswg.org/cssom-view/#potentially-scrollable */
+ html {
+ overflow: hidden;
+ }
+ </style>
+</head>
+<body id=body1></body>
+<script>
+test(() => {
+ try {
+ assert_equals(document.scrollingElement.id, 'body1');
+ assert_equals(new ScrollTimeline({}).source, body1);
+
+ // Make #body1 "potentially scrollable". This causes the scrollingElement
+ // of the document to become null.
+ //
+ // https://drafts.csswg.org/cssom-view/#dom-document-scrollingelement
+ body1.style = 'overflow:scroll';
+ assert_equals(new ScrollTimeline({}).source, null);
+ } finally {
+ body1.style = '';
+ }
+}, 'Style of <body> is reflected in source attribute in quirks mode');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js
new file mode 100644
index 0000000000..97e81f494c
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/testcommon.js
@@ -0,0 +1,124 @@
+'use strict';
+
+// Builds a generic structure that looks like:
+//
+// <div class="scroller"> // 100x100 viewport
+// <div class="contents"></div> // 500x500
+// </div>
+//
+// The |scrollerOverrides| and |contentOverrides| parameters are maps which
+// are applied to the scroller and contents style after basic setup.
+//
+// Appends the outer 'scroller' element to the document body, and returns it.
+function setupScrollTimelineTest(
+ scrollerOverrides = new Map(), contentOverrides = new Map()) {
+ let scroller = document.createElement('div');
+ scroller.style.width = '100px';
+ scroller.style.height = '100px';
+ // Hide the scrollbars, but maintain the ability to scroll. This setting
+ // ensures that variability in scrollbar sizing does not contribute to test
+ // failures or flakes.
+ scroller.style.overflow = 'hidden';
+ for (const [key, value] of scrollerOverrides) {
+ scroller.style[key] = value;
+ }
+
+ let contents = document.createElement('div');
+ contents.style.width = '500px';
+ contents.style.height = '500px';
+ for (const [key, value] of contentOverrides) {
+ contents.style[key] = value;
+ }
+
+ scroller.appendChild(contents);
+ document.body.appendChild(scroller);
+ return scroller;
+}
+
+// Helper method to calculate the current time, implementing only step 5 of
+// https://wicg.github.io/scroll-animations/#current-time-algorithm
+function calculateCurrentTime(
+ currentScrollOffset, startScrollOffset, endScrollOffset) {
+ return ((currentScrollOffset - startScrollOffset) /
+ (endScrollOffset - startScrollOffset)) *
+ 100;
+}
+
+function createScroller(test) {
+ var scroller = createDiv(test);
+ scroller.innerHTML = "<div class='contents'></div>";
+ scroller.classList.add('scroller');
+ // Trigger layout run.
+ scroller.scrollTop;
+ return scroller;
+}
+
+function createScrollerWithStartAndEnd(test, orientationClass = 'vertical') {
+ var scroller = createDiv(test);
+ scroller.innerHTML =
+ `<div class='contents'>
+ <div id='start'></div>
+ <div id='end'></div>
+ </div>`;
+ scroller.classList.add('scroller');
+ scroller.classList.add(orientationClass);
+
+ return scroller;
+}
+
+function createScrollTimeline(test, options) {
+ options = options || {
+ source: createScroller(test)
+ }
+ return new ScrollTimeline(options);
+}
+
+function createScrollLinkedAnimation(test, timeline) {
+ return createScrollLinkedAnimationWithTiming(test, /* duration in ms*/ 1000, timeline);
+}
+
+function createScrollLinkedAnimationWithTiming(test, timing, timeline) {
+ if (timeline === undefined)
+ timeline = createScrollTimeline(test);
+ const KEYFRAMES = { opacity: [0, 1] };
+ return new Animation(
+ new KeyframeEffect(createDiv(test), KEYFRAMES, timing), timeline);
+}
+
+function assert_approx_equals_or_null(actual, expected, tolerance, name) {
+ if (actual === null || expected === null){
+ assert_equals(actual, expected, name);
+ }
+ else {
+ assert_approx_equals(actual, expected, tolerance, name);
+ }
+}
+
+function assert_percents_approx_equal(actual, expected, maxScroll,
+ description) {
+ // Base the tolerance on being out by up to half a pixel.
+ const tolerance = 0.5 / maxScroll * 100;
+ assert_equals(actual.unit, "percent", `'actual' unit type must be ` +
+ `'percent' for "${description}"`);
+ assert_true(actual instanceof CSSUnitValue, `'actual' must be of type ` +
+ `CSSNumberish for "${description}"`);
+ if (expected instanceof CSSUnitValue){
+ // Verify that when the expected in a CSSUnitValue, it is the correct unit
+ // type
+ assert_equals(expected.unit, "percent", `'expected' unit type must be ` +
+ `'percent' for "${description}"`);
+ assert_approx_equals(actual.value, expected.value, tolerance,
+ `values do not match for "${description}"`);
+ } else if (typeof expected, "number"){
+ assert_approx_equals(actual.value, expected, tolerance,
+ `values do not match for "${description}"`);
+ }
+}
+
+function assert_percents_equal(actual, expected, description) {
+ // Rough estimate of the default size of the scrollable area based on
+ // sizes in setupScrollTimelineTest.
+ const defaultScrollRange = 400;
+ return assert_percents_approx_equal(actual, expected, defaultScrollRange,
+ description);
+}
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html
new file mode 100644
index 0000000000..ed8e8337a6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline-cancel-one.html
@@ -0,0 +1,84 @@
+<html class="reftest-wait">
+<title>Scroll timeline shared by two animation, one gets cancelled</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Cancelling animations should not affect other
+ animation that is attached to the same timeline.">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)', opacity: 1},
+ { transform: 'translateY(200px)', opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+ const temporary_effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateX(0)'},
+ { transform: 'translateX(200px)'}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const animation = new Animation(effect, timeline);
+ const temporary_animation = new Animation(temporary_effect, timeline);
+ animation.play();
+ temporary_animation.play();
+
+ Promise.all([animation.ready, temporary_animation.ready]).then(() => {
+ temporary_animation.cancel();
+ temporary_animation.ready.then(() => {
+ waitForAnimationFrames(2).then(_ => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html
new file mode 100644
index 0000000000..de50599fba
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/two-animations-attach-to-same-scroll-timeline.html
@@ -0,0 +1,79 @@
+<html class="reftest-wait">
+<title>Scroll timeline shared by two animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations/">
+<meta name="assert" content="Should be able to use the same scroll timeline to
+drive two animations">
+<link rel="match" href="animation-ref.html">
+
+<script src="/web-animations/testcommon.js"></script>
+<script src="/common/reftest-wait.js"></script>
+
+<style>
+ #box {
+ width: 100px;
+ height: 100px;
+ background-color: green;
+ }
+
+ #covered {
+ width: 100px;
+ height: 100px;
+ background-color: red;
+ }
+
+ #scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform; /* force compositing */
+ }
+
+ #contents {
+ height: 1000px;
+ width: 100%;
+ }
+</style>
+
+<div id="box"></div>
+<div id="covered"></div>
+<div id="scroller">
+ <div id="contents"><p>Scrolling Contents</p></div>
+</div>
+
+<script>
+ const box = document.getElementById('box');
+ const transform_effect = new KeyframeEffect(box,
+ [
+ { transform: 'translateY(0)'},
+ { transform: 'translateY(200px)'}
+ ], {
+ duration: 1000,
+ }
+ );
+ const opacity_effect = new KeyframeEffect(box,
+ [
+ { opacity: 1},
+ { opacity: 0}
+ ], {
+ duration: 1000,
+ }
+ );
+
+ const scroller = document.getElementById('scroller');
+ const timeline = new ScrollTimeline(
+ { source: scroller, orientation: 'block' });
+ const transform_animation = new Animation(transform_effect, timeline);
+ transform_animation.play();
+ const opacity_animation = new Animation(opacity_effect, timeline);
+ opacity_animation.play();
+
+ Promise.all([transform_animation.ready, opacity_animation.ready]).then(() => {
+ // Move the scroller to the halfway point.
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = 0.5 * maxScroll;
+
+ waitForAnimationFrames(2).then(_ => {
+ takeScreenshot();
+ });
+ });
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html
new file mode 100644
index 0000000000..10535319fc
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/update-playback-rate.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Seamlessly updating the playback rate of an animation</title>
+<link rel="help"
+ href="https://drafts.csswg.org/web-animations-1/#seamlessly-updating-the-playback-rate-of-an-animation">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSSNumericValue.parse("50%");
+
+ animation.updatePlaybackRate(0.5);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50,
+ 'Reducing the playback rate should not change the ' +
+ 'current time of a playing animation');
+
+ animation.updatePlaybackRate(2);
+ await animation.ready;
+ assert_percents_equal(animation.currentTime, 50,
+ 'Increasing the playback rate should not change the ' +
+ 'current time of a playing animation');
+}, 'Updating the playback rate maintains the current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ await animation.ready;
+
+ assert_false(animation.pending);
+ animation.updatePlaybackRate(2);
+ assert_true(animation.pending);
+}, 'Updating the playback rate while running makes the animation pending');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ assert_true(animation.pending);
+
+ animation.updatePlaybackRate(0.5);
+
+ // Check that the hold time is updated as expected
+ assert_percents_equal(animation.currentTime, 50);
+
+ await animation.ready;
+
+ // As above, check that the currentTime is not calculated by simply
+ // substituting in the updated playbackRate without updating the startTime.
+ assert_percents_equal(animation.currentTime, 50,
+ 'Reducing the playback rate should not change the ' +
+ 'current time of a play-pending animation');
+}, 'Updating the playback rate on a play-pending animation maintains the ' +
+ 'current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.currentTime = CSSNumericValue.parse("50%");
+ await animation.ready;
+
+ animation.pause();
+ animation.updatePlaybackRate(0.5);
+
+ assert_percents_equal(animation.currentTime, 50);
+}, 'Updating the playback rate on a pause-pending animation maintains the ' +
+ 'current time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+
+ animation.updatePlaybackRate(2);
+ animation.updatePlaybackRate(3);
+ animation.updatePlaybackRate(4);
+
+ assert_equals(animation.playbackRate, 1);
+ await animation.ready;
+
+ assert_equals(animation.playbackRate, 4);
+}, 'If a pending playback rate is set multiple times, the latest wins');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.cancel();
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_false(animation.pending);
+}, 'In the idle state, the playback rate is applied immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.pause();
+ await animation.ready;
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_false(animation.pending);
+}, 'In the paused state, the playback rate is applied immediately');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+
+ animation.updatePlaybackRate(2);
+ assert_equals(animation.playbackRate, 2);
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+}, 'Updating the playback rate on a finished animation maintains the current ' +
+ 'time');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ animation.play();
+ animation.finish();
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+
+ animation.updatePlaybackRate(0);
+ assert_equals(animation.playbackRate, 0);
+ assert_percents_equal(animation.currentTime, 100);
+ assert_false(animation.pending);
+}, 'Updating the playback rate to zero on a finished animation maintains the ' +
+ 'current time');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html
new file mode 100644
index 0000000000..86b52d5aa0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/scroll-timelines/updating-the-finished-state.html
@@ -0,0 +1,565 @@
+<!DOCTYPE html>
+<meta charset=utf-8>
+<title>Updating the finished state</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#updating-the-finished-state">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="testcommon.js"></script>
+<style>
+.scroller {
+ overflow: auto;
+ height: 100px;
+ width: 100px;
+ will-change: transform;
+}
+
+.contents {
+ height: 1000px;
+ width: 100%;
+}
+</style>
+<body>
+<script>
+'use strict';
+
+// --------------------------------------------------------------------
+//
+// TESTS FOR UPDATING THE HOLD TIME
+//
+// --------------------------------------------------------------------
+
+// CASE 1: playback rate > 0 and current time >= target effect end
+// (Also the start time is resolved and there is pending task)
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(200);
+ scroller.scrollTop = 0.7 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 200,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking past end');
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ await anim.ready;
+
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 100,
+ 'Hold time is set to target end clamping current time');
+}, 'Updating the finished state when playing exactly to end');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(100);
+ scroller.scrollTop = 0.7 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 100,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking exactly to end');
+
+
+// CASE 2: playback rate < 0 and current time <= 0
+// (Also the start time is resolved and there is pending task)
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play(); // Make sure animation is not initially finished
+
+ await anim.ready;
+
+ // Seek to 1ms before 0 and then wait 1ms
+ anim.currentTime = CSS.percent(1);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set to zero clamping current time');
+}, 'Updating the finished state when playing in reverse past zero');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(-100);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, -100,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking a reversed animation past zero');
+
+// Did seek = false
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+ await anim.ready;
+
+ scroller.scrollTop = maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set to target end clamping current time');
+}, 'Updating the finished state when playing a reversed animation exactly ' +
+ 'to zero');
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.playbackRate = -1;
+ anim.play();
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(0);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 0,
+ 'Hold time is set so current time should NOT change');
+}, 'Updating the finished state when seeking a reversed animation exactly ' +
+ 'to zero');
+
+// CASE 3: playback rate > 0 and current time < target end OR
+// playback rate < 0 and current time > 0
+// (Also the start time is resolved and there is pending task)
+
+// Did seek = true; playback rate > 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.finish();
+ await anim.ready;
+ assert_percents_equal(anim.startTime, -100);
+
+ anim.currentTime = CSS.percent(50);
+ // When did seek = true, updating the finished state: (i) updates
+ // the animation's start time and (ii) clears the hold time.
+ // We can test both by checking that the currentTime is initially
+ // updated and then increases.
+ assert_percents_equal(anim.currentTime, 50, 'Start time is updated');
+ assert_percents_equal(anim.startTime, -50);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 70,
+ 'Hold time is not set so current time should increase');
+}, 'Updating the finished state when seeking before end');
+
+// Did seek = false; playback rate < 0
+//
+// Unfortunately it is not possible to test this case. We need to have
+// a hold time set, a resolved start time, and then perform some
+// operation that updates the finished state with did seek set to true.
+//
+// However, the only situation where this could arrive is when we
+// replace the timeline and that procedure is likely to change. For all
+// other cases we either have an unresolved start time (e.g. when
+// paused), we don't have a set hold time (e.g. regular playback), or
+// the current time is zero (and anything that gets us out of that state
+// will set did seek = true).
+
+// Did seek = true; playback rate < 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = -1;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(50);
+ assert_percents_equal(anim.startTime, 50, 'Start time is updated');
+ assert_percents_equal(anim.currentTime, 50, 'Current time is updated');
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 30,
+ 'Hold time is not set so current time should decrease');
+}, 'Updating the finished state when seeking a reversed animation before end');
+
+
+// CASE 4: playback rate == 0
+
+// current time < 0
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(-100);
+
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, -100,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current ' +
+ 'time is less than zero');
+
+// current time < target end
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(50);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 50,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current ' +
+ 'time is less than end');
+
+// current time > target end
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.playbackRate = 0;
+ await anim.ready;
+
+ anim.currentTime = CSS.percent(200);
+ scroller.scrollTop = 0.2 * maxScroll;
+ await waitForNextFrame();
+
+ assert_percents_equal(anim.currentTime, 200,
+ 'Hold time should not be cleared so current time should NOT change');
+}, 'Updating the finished state when playback rate is zero and the current' +
+ 'time is greater than end');
+
+// CASE 5: current time unresolved
+
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.play();
+ anim.cancel();
+ // Trigger a change that will cause the "update the finished state"
+ // procedure to run.
+ anim.effect.updateTiming({ duration: 2000 });
+ assert_equals(anim.currentTime, null,
+ 'The animation hold time / start time should not be updated');
+ // The "update the finished state" procedure is supposed to run after any
+ // change to timing, but just in case an implementation defers that, let's
+ // wait a frame and check that the hold time / start time has still not been
+ // updated.
+ await waitForAnimationFrames(1);
+
+ assert_equals(anim.currentTime, null,
+ 'The animation hold time / start time should not be updated');
+}, 'Updating the finished state when current time is unresolved');
+
+// CASE 7: start time unresolved
+
+// Did seek = true
+promise_test(async t => {
+ const anim = createScrollLinkedAnimation(t);
+ // Wait for new animation frame which allows the timeline to compute new
+ // current time.
+ await waitForNextFrame();
+ anim.cancel();
+ anim.currentTime = CSS.percent(150);
+ // Trigger a change that will cause the "update the finished state"
+ // procedure to run.
+ anim.currentTime = CSS.percent(50);
+ assert_percents_equal(anim.currentTime, 50,
+ 'The animation hold time should not be updated');
+ assert_equals(anim.startTime, null,
+ 'The animation start time should not be updated');
+}, 'Updating the finished state when start time is unresolved and did seek = ' +
+ 'true');
+
+// --------------------------------------------------------------------
+//
+// TESTS FOR RUNNING FINISH NOTIFICATION STEPS
+//
+// --------------------------------------------------------------------
+
+function waitForFinishEventAndPromise(animation) {
+ const eventPromise = new Promise(resolve => {
+ animation.onfinish = resolve;
+ });
+ return Promise.all([eventPromise, animation.finished]);
+}
+
+promise_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ animation.onfinish =
+ t.unreached_func('Seeking to finish should not fire finish event');
+ animation.finished.then(
+ t.unreached_func(
+ 'Seeking to finish should not resolve finished promise'));
+ animation.currentTime = CSS.percent(100);
+ animation.currentTime = CSS.percent(0);
+ animation.pause();
+ scroller.scrollTop = 0.2 * maxScroll;
+ return waitForAnimationFrames(3);
+}, 'Finish notification steps don\'t run when the animation seeks to finish ' +
+ 'and then seeks back again');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = maxScroll;
+
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when the animation completes normally');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ animation.effect.target = null;
+
+ animation.play();
+ await animation.ready;
+ scroller.scrollTop = maxScroll;
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when an animation without a target effect ' +
+ 'completes normally');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ animation.currentTime = CSS.percent(101);
+ return waitForFinishEventAndPromise(animation);
+}, 'Finish notification steps run when the animation seeks past finish');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ animation.play();
+ await animation.ready;
+
+ // Register for notifications now since once we seek away from being
+ // finished the 'finished' promise will be replaced.
+ const finishNotificationSteps = waitForFinishEventAndPromise(animation);
+ animation.finish();
+ animation.currentTime = CSS.percent(0);
+ animation.pause();
+ return finishNotificationSteps;
+}, 'Finish notification steps run when the animation completes with ' +
+ '.finish(), even if we then seek away');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ const initialFinishedPromise = animation.finished;
+ await animation.finished;
+
+ animation.currentTime = CSS.percent(0);
+ assert_not_equals(initialFinishedPromise, animation.finished);
+}, 'Animation finished promise is replaced after seeking back to start');
+
+promise_test(async t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ const initialFinishedPromise = animation.finished;
+ scroller.scrollTop = maxScroll;
+ await animation.finished;
+
+ scroller.scrollTop = 0;
+ await waitForNextFrame();
+
+ animation.play();
+ assert_not_equals(initialFinishedPromise, animation.finished);
+}, 'Animation finished promise is replaced after replaying from start');
+
+async_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ animation.onfinish = event => {
+ scroller.scrollTop = 0;
+ window.requestAnimationFrame(function() {
+ window.requestAnimationFrame(function() {
+ scroller.scrollTop = maxScroll;
+ });
+ });
+ animation.onfinish = event => {
+ t.done();
+ };
+ };
+ scroller.scrollTop = maxScroll;
+}, 'Animation finish event is fired again after seeking back to start');
+
+async_test(t => {
+ const animation = createScrollLinkedAnimation(t);
+ const scroller = animation.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ animation.play();
+
+ animation.onfinish = event => {
+ scroller.scrollTop = 0;
+ window.requestAnimationFrame(function() {
+ animation.play();
+ scroller.scrollTop = maxScroll;
+ animation.onfinish = event => {
+ t.done();
+ };
+ });
+ };
+ scroller.scrollTop = maxScroll;
+}, 'Animation finish event is fired again after replaying from start');
+
+async_test(t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ anim.effect.updateTiming({ duration: 800, endDelay: 200});
+
+ anim.onfinish = t.step_func(event => {
+ assert_unreached('finish event should not be fired');
+ });
+ anim.play();
+ anim.ready.then(() => {
+ scroller.scrollTop = 0.9 * maxScroll;
+ return waitForAnimationFrames(3);
+ }).then(t.step_func(() => {
+ t.done();
+ }));
+}, 'finish event is not fired at the end of the active interval when the ' +
+ 'endDelay has not expired');
+
+async_test(t => {
+ const anim = createScrollLinkedAnimation(t);
+ const scroller = anim.timeline.source;
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+
+ anim.effect.updateTiming({ duration: 800, endDelay: 100});
+ anim.play();
+ anim.ready.then(() => {
+ scroller.scrollTop = 0.95 * maxScroll; // during endDelay
+ anim.onfinish = t.step_func(event => {
+ assert_unreached('onfinish event should not be fired during endDelay');
+ });
+ return waitForAnimationFrames(2);
+ }).then(t.step_func(() => {
+ anim.onfinish = t.step_func(event => {
+ t.done();
+ });
+ scroller.scrollTop = maxScroll;
+ return waitForAnimationFrames(2);
+ }));
+}, 'finish event is fired after the endDelay has expired');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html b/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html
new file mode 100644
index 0000000000..b456794225
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/animation-events.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#events">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow: auto;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ height: 400px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ const keyframes = {transform: ['translateX(0)', 'translateX(100px)']};
+ let target = document.getElementById('target');
+ let scroller = document.querySelector('#container');
+ let timeline = new ViewTimeline({subject: target});
+ promise_test(async t => {
+ let animation = target.animate(keyframes, {
+ timeline,
+ fill: 'both'
+ });
+ scroller.scrollTo({top: 0});
+ await waitForCompositorReady();
+ let finishedPromise = animation.finished;
+ let finished = false;
+ let finishEvents = 0;
+ finishedPromise.then(() => {
+ finished = true;
+ });
+ animation.addEventListener('finish', () => { finishEvents++; });
+
+ scroller.scrollTo({top: 100});
+ await waitForNextFrame();
+ assert_false(finished, "Animation is not finished before starting");
+ assert_equals(finishEvents, 0, "No finish event before scrolling");
+
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+ assert_false(finished, "Animation is not finished while active");
+ assert_equals(finishEvents, 0, "No finish event while active");
+
+ scroller.scrollTo({top: 600});
+ await waitForNextFrame();
+ assert_true(finished, "Animation is finished after passing end");
+ assert_equals(finishEvents, 1, "A finish event is generated after end");
+
+ scroller.scrollTo({top: 400});
+ await waitForNextFrame();
+ assert_not_equals(finishedPromise, animation.finished,
+ "A new finish promise is created when back in active range");
+ finished = false;
+ animation.finished.then(() => {
+ finished = true;
+ });
+
+ scroller.scrollTo({top: 600});
+ await waitForNextFrame();
+ assert_true(finished, "Finishes after passing end");
+ assert_equals(finishEvents, 2, "Another finish event is generated after end");
+ animation.cancel();
+ }, 'View timeline generates and resolves finish promises and events' );
+
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html
new file mode 100644
index 0000000000..beb380060e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time-vertical-rl.tentative.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time with vertical-rl writing mode</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ writing-mode: vertical-rl;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ width: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 200px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target, {axis: 'block'});
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = -600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = -800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = -1000;
+ anim.effect.updateTiming({ fill: 'forwards' });
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ 'Opacity with fill forwards at effect end time');
+ anim.effect.updateTiming({ fill: 'none' });
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Opacity with fill none at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = -1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with container having vertical-rl layout' );
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html
new file mode 100644
index 0000000000..c24d04412f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-current-time.tentative.html
@@ -0,0 +1,207 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ .spacer {
+ height: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 200px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollTop = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with start and end scroll offsets that do not align with ' +
+ 'the scroll boundaries' );
+
+ promise_test(async t => {
+ const leading = document.getElementById('leading-space');
+ leading.style = 'display: none';
+ t.add_cleanup(() => {
+ leading.style = null;
+ });
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect enters active phase at container start boundary');
+
+
+ // Advance to midpoint
+ container.scrollTop = 100;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 75,
+ "Timeline's current time at midpoint");
+ assert_percents_equal(anim.currentTime, 75,
+ "Animation's current time at midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.6',
+ 'Effect at the middle of the active phase');
+
+ // Advance to end-offset
+ container.scrollTop = 200;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's current time at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect inactive at the end offset');
+
+ // Advance to scroll limit.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's current time at scroll limit");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at scroll limit");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect inactive in the after phase');
+
+ }, 'View timeline does not clamp starting scroll offset at 0');
+
+ promise_test(async t => {
+ const trailing = document.getElementById('trailing-space');
+ trailing.style = 'display: none';
+ t.add_cleanup(() => {
+ trailing.style = null;
+ });
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially in before phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect enters active phase at container start boundary');
+
+ // Advance to start offset.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to midpoint.
+ container.scrollTop = 700;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 25,
+ "Timeline's current time at the midpoint");
+ assert_percents_equal(anim.currentTime, 25,
+ "Animation's current time at the midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.4',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end offset.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at max scroll offset");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at max scroll offset");
+ // The active-after boundary is inclusive since at the maximum scroll
+ // position.
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect at end of active phase');
+ }, 'View timeline does not clamp end scroll offset at max scroll');
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html
new file mode 100644
index 0000000000..6fdc7c6822
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline nested subject</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style type="text/css">
+ #container {
+ overflow-y: scroll;
+ height: 300px;
+ width: 300px;
+ }
+ .big-spacer {
+ height: 800px;
+ }
+ .small-spacer {
+ height: 100px;
+ }
+ #block {
+ background-color: #ddd;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="big-spacer"></div>
+ <div id="block">
+ <div class="small-spacer"></div>
+ <div id="target"></div>
+ </div>
+ <div class="big-spacer"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // start offset = 800 + 100 - 300 = 600
+ // end offset = 800 + 100 + 100 = 1000
+ // scroll limit = L = 800 + 200 + 800 - 300 = 1500
+ // progress = P = (current - start) / (end - start)
+ // P(0) = -600 / 400 = -1.5
+ // P(L) = 900 / 400 = 2.5
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollTop = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 225,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with subject that is not a direct descendant of the ' +
+ 'scroll container');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html b/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html
new file mode 100644
index 0000000000..ee01070a53
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/change-animation-range-updates-play-state.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Animation range updates play state</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { background-color: blue; }
+ to { background-color: white; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-top: 800px;
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto both linear;
+ animation-timeline: --t1;
+ view-timeline: --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ anim = target.getAnimations()[0];
+ await anim.ready;
+
+ // Cover range = 600px to 900px
+
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+
+ // Animation is running in the active phase.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'contain 0%'; // 700px
+ anim.rangeEnd = 'contain 100%'; // 800px
+ });
+ assert_equals(anim.playState, 'running');
+ assert_percents_equal(anim.startTime, 100/3);
+ assert_percents_equal(anim.currentTime, 100/6);
+
+ // Animation in the after phase and switches to the finished state.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'entry 0%'; // 600px
+ anim.rangeEnd = 'entry 100%'; // 700px
+ });
+ assert_equals(anim.playState, 'finished');
+ assert_percents_equal(anim.startTime, 0);
+ // In the after phase, so current time is clamped.
+ assert_percents_equal(anim.currentTime, 100/3);
+
+ // Animation in the before phase and switches back to the running state.
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = 'exit 0%'; // 800px
+ anim.rangeEnd = 'exit 100%'; // 900px
+ });
+ assert_equals(anim.playState, 'running');
+ assert_percents_equal(anim.startTime, 200/3);
+ assert_percents_equal(anim.currentTime, -100/6);
+
+ }, 'Changing the animation range updates the play state');
+ }
+
+ window.onload = runTest;
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html b/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html
new file mode 100644
index 0000000000..8b61a9ab81
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/contain-alignment.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<html>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/">
+<style>
+
+@keyframes bg {
+ from { background-color: rgb(254, 0, 0); }
+ to { background-color: rgb(0 254, 0); }
+}
+.item {
+ flex-grow: 1;
+ width: 2em;
+ height: 2em;
+ background: #888;
+ animation: bg linear;
+ animation-timeline: view();
+ animation-range: contain;
+}
+
+.inline .item {
+ animation-timeline: view(inline);
+}
+
+.scroller {
+ width: 10em;
+ height: 10em;
+ outline: 1px solid;
+ margin: 1em;
+ overflow: auto;
+ display: inline-flex;
+ vertical-align: top;
+ flex-direction: column;
+ gap: 1em;
+ resize: vertical;
+}
+
+.inline {
+ resize: horizontal;
+ flex-direction: row;
+}
+
+.block .spacer {
+ height: 20em;
+ width: 1em;
+}
+
+.inline .spacer {
+ width: 20em;
+ height: 1em;
+}
+</style>
+<body>
+<div class="scroller block">
+ <div class="item" id="a"></div>
+ <div class="item" id="b"></div>
+ <div class="item" id="c"></div>
+</div>
+
+<div class="scroller inline">
+ <div class="item" id="d"></div>
+ <div class="item" id="e"></div>
+ <div class="item" id="f"></div>
+</div>
+
+<br>
+
+<div class="scroller block">
+ <div class="item" id="g"></div>
+ <div class="item" id="h"></div>
+</div>
+
+<div class="scroller inline">
+ <div class="item" id="i"></div>
+ <div class="item" id="j"></div>
+</div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script type="text/javascript">
+ promise_test(async t => {
+ let anims = document.getAnimations();
+ await Promise.all(anims.map(anim => anim.ready));
+ await waitForNextFrame();
+
+ const expected_results = [
+ { id: "a", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "b", progress: 0.5, bg: 'rgb(127, 127, 0)' },
+ { id: "c", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "d", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "e", progress: 0.5, bg: 'rgb(127, 127, 0)' },
+ { id: "f", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "g", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "h", progress: 0.0, bg: 'rgb(254, 0, 0)' },
+ { id: "i", progress: 1.0, bg: 'rgb(0, 254, 0)' },
+ { id: "j", progress: 0.0, bg: 'rgb(254, 0, 0)' }
+ ];
+
+ expected_results.forEach(result => {
+ const element = document.getElementById(result.id);
+ const anim = element.getAnimations()[0];
+ assert_approx_equals(anim.effect.getComputedTiming().progress,
+ result.progress, 1e-3,
+ `${result.id}: Unexpected progress`);
+ assert_equals(getComputedStyle(element).backgroundColor,
+ result.bg, `${result.id}: Mismatched background color`);
+ });
+
+ }, 'Stability of animated elements aligned to the bounds of a contain region');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html b/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html
new file mode 100644
index 0000000000..d75f30e664
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/fieldset-source.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html>
+<meta charset="utf-8">
+<title>View timeline with fieldset as source</title>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#dom-viewtimeline-viewtimeline">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+ @keyframes colorize {
+ from { background-color: #ccf; }
+ to { background-color: white; }
+ }
+
+ .input {
+ background-color: white;
+ view-timeline: --timeline;
+ animation: colorize;
+ animation-timeline: --timeline;
+ margin-top: 0px;
+ margin-bottom: 3px;
+ margin-left: 8px;
+ height: 20px;
+ width: 150px;
+ }
+
+ .input:last-child {
+ margin-bottom: 0px;
+ }
+
+ fieldset {
+ display: inline-block;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ height: 80px;
+ }
+
+ div {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ }
+</style>
+<body>
+ <fieldset id="fieldset">
+ <legend id="legend">Reservation Details</legend>
+ <div>
+ <label for="name">Name: </label>
+ <input type="text" class="input" id="input1" value="Jane Doe" />
+ </div>
+ <div>
+ <label for="date">Date: </label>
+ <input type="date" class="input" id="input2" value="2024-01-16"/>
+ </div>
+ <div>
+ <label for="time">Time: </label>
+ <input type="time" class="input" id="input3" value="18:30"/>
+ </div>
+ <div>
+ <label for="name">Number of guests: </label>
+ <input type="number" class="input" id="input4" value="5" />
+ </div>
+ <div>
+ <label for="name">Contact info: </label>
+ <input type="text" class="input" id="input5" value="(555) 555-5555" />
+ </div>
+ </fieldset>
+</body>
+<script>
+ async function runTest() {
+ promise_test(async t => {
+ const anims = document.getAnimations();
+ assert_equals(anims.length, 5);
+ await Promise.all(anims.map(anim => anim.ready));
+
+ // The bottom of the legend aligns with the top of the fieldset's
+ // scrollable area.
+ const fieldset = document.getElementById('fieldset');
+ const legend = document.getElementById('legend');
+ const fieldsetContentTop =
+ legend.getBoundingClientRect().bottom;
+
+ // The bottom of the scroll container aligns with the bottom of the
+ // fieldset's content box.
+ const fieldsetContentBottom =
+ fieldset.getBoundingClientRect().bottom -
+ parseFloat(getComputedStyle(fieldset).borderBottom);
+
+ // Validate the start and end offsets for each view timeline.
+ anims.forEach(async (anim) => {
+ assert_equals(anim.timeline.source.id, 'fieldset');
+ assert_equals(anim.timeline.subject.tagName, 'INPUT');
+ const bounds = anim.effect.target.getBoundingClientRect();
+
+ const expectedStartOffset = bounds.top - fieldsetContentBottom;
+ const expectedEndOffset = bounds.bottom - fieldsetContentTop;
+ assert_approx_equals(
+ parseFloat(anim.timeline.startOffset),
+ expectedStartOffset, 0.1,
+ `Unexpected start offset for ${anim.effect.target.id}`);
+ assert_approx_equals(
+ parseFloat(anim.timeline.endOffset),
+ expectedEndOffset, 0.1,
+ `Unexpected end offset for ${anim.effect.target.id}`);
+ });
+ }, 'Fieldset is a valid source for a view timeline');
+ }
+
+ window.onload = runTest();
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html b/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html
new file mode 100644
index 0000000000..02f910d04e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html
@@ -0,0 +1,203 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<!-- TODO(kevers): Insert link once resolutions present in spec -->
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Reported keyframes containing timeline offset</title>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function createAnimation(t, keyframes, use_view_timeline = true) {
+ const options = {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto',
+ fill: 'both'
+ };
+ if (use_view_timeline) {
+ options.timeline = new ViewTimeline( { subject: target });
+ }
+ const anim = target.animate(keyframes, options);
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ return anim;
+ }
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "contain 25%", marginLeft: "0px", opacity: "0" },
+ { offset: "contain 75%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'contain', offset: CSS.percent(25) },
+ computedOffset: 0.25, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'contain', offset: CSS.percent(75) },
+ computedOffset: 0.75, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Report specified timeline offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: "0" },
+ { offset: "cover 100%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Computed offsets can be outside [0,1] for keyframes with timeline ' +
+ 'offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "contain 75%", marginLeft: "0px", opacity: "0" },
+ { offset: "contain 25%", marginRight: "0px", opacity: "1" }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'contain', offset: CSS.percent(75) },
+ computedOffset: 0.75, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'contain', offset: CSS.percent(25) },
+ computedOffset: 0.25, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Retain specified ordering of keyframes with timeline offsets');
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: "0" },
+ { offset: "cover 100%", marginRight: "0px", opacity: "1" }
+ ], /* use_view_timeline */ false);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: null, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Include unreachable keyframes');
+
+
+ promise_test(async t => {
+ let anim = createAnimation(t, [
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 0 },
+ { opacity: 0.5 },
+ { opacity: 1.0 }
+ ]);
+ let frames = anim.effect.getKeyframes();
+ let expected = [
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: null, computedOffset: 0, easing: "linear", composite: "auto",
+ opacity: "0" },
+ { offset: null, computedOffset: 0.5, easing: "linear",
+ composite: "auto", opacity: "0.5" },
+ { offset: null, computedOffset: 1.0, easing: "linear",
+ composite: "auto", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ anim = createAnimation(t, [
+ { opacity: 0 },
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { opacity: 0.5 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 1.0 }
+ ]);
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: null, computedOffset: 0, easing: "linear", composite: "auto",
+ opacity: "0" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: null, computedOffset: 0.5, easing: "linear",
+ composite: "auto", opacity: "0.5" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: null, computedOffset: 1.0, easing: "linear",
+ composite: "auto", opacity: "1" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+
+ anim = createAnimation(t, [
+ { opacity: 0.2, offset: 0.2 },
+ { offset: "cover 0%", marginLeft: "0px", opacity: 0 },
+ { opacity: 0.4 },
+ { opacity: 0.6 },
+ { offset: "cover 100%", marginRight: "0px", opacity: 1 },
+ { opacity: 0.8, offset: 0.8 }
+ ]);
+ frames = anim.effect.getKeyframes();
+ expected = [
+ { offset: 0.2, computedOffset: 0.2, easing: "linear", composite: "auto",
+ opacity: "0.2" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ computedOffset: -1, easing: "linear", composite: "auto",
+ marginLeft: "0px", opacity: "0" },
+ { offset: null, computedOffset: 0.4, easing: "linear",
+ composite: "auto", opacity: "0.4" },
+ { offset: null, computedOffset: 0.6, easing: "linear",
+ composite: "auto", opacity: "0.6" },
+ { offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ computedOffset: 2, easing: "linear", composite: "auto",
+ marginRight: "0px", opacity: "1" },
+ { offset: 0.8, computedOffset: 0.8, easing: "linear", composite: "auto",
+ opacity: "0.8" }
+ ];
+ assert_frame_lists_equal(frames, expected);
+ }, 'Mix of computed and timeline offsets.');
+ }
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html
new file mode 100644
index 0000000000..6b1d216dea
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/inline-subject.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element</title>
+</head>
+<style type="text/css">
+ @keyframes bg {
+ from { background-color: blue; }
+ to { background-color: green; }
+ }
+
+ #colorize {
+ animation: bg steps(2, jump-none) both;
+ animation-timeline: view();
+ animation-range: contain;
+ background-color: red;
+ color: white;
+ }
+
+ .spacer {
+ height: 80vh;
+ }
+</style>
+<body>
+<div class="spacer"></div>
+<div id="content">
+ <p>Hello <span id="colorize">world</span></p>
+</div>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const anim = document.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(anim.effect.target)
+ .backgroundColor, 'rgb(0, 0, 255)');
+ scroller.scrollTop =
+ scroller.scrollHeight - scroller.clientHeight;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(anim.effect.target)
+ .backgroundColor, 'rgb(0, 128, 0)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html
new file mode 100644
index 0000000000..59d73d0cdf
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/inline-view-timeline-current-time.tentative.html
@@ -0,0 +1,302 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline current-time</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 200px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = 1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with start and end scroll offsets that do not align with ' +
+ 'the scroll boundaries' );
+
+ promise_test(async t => {
+ const leading = document.getElementById('leading-space');
+ leading.style = 'display: none';
+ content.style = 'width: 1000px';
+ t.add_cleanup(() => {
+ leading.style = null;
+ content.style = null;
+ });
+
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect enters active phase at container start boundary');
+
+
+ // Advance to midpoint
+ container.scrollLeft = 100;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 75,
+ "Timeline's current time at the midpoint");
+ assert_percents_equal(anim.currentTime, 75,
+ "Animation's current time at the midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.6',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end-offset
+ container.scrollLeft = 200;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's current time at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect at the end of the active phase');
+
+ // Advance to scroll limit.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's current time at the scroll limit");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's current time at the scroll limit");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect at the scroll limit');
+
+ }, 'View timeline does not clamp starting scroll offset at 0');
+
+ promise_test(async t => {
+ const trailing = document.getElementById('trailing-space');
+ trailing.style = 'display: none';
+ content.style = 'width: 1000px';
+ t.add_cleanup(() => {
+ trailing.style = null;
+ content.style = null;
+ });
+
+ container.scrollLeft = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially in before phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect enters active phase at container start boundary');
+
+ // Advance to start offset.
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to midpoint
+ container.scrollLeft = 700;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 25,
+ "Timeline's current time at midpoint");
+ assert_percents_equal(anim.currentTime, 25,
+ "Animation's current time at midpoint");
+ assert_equals(getComputedStyle(target).opacity, '0.4',
+ 'Effect at the midpoint of the active phase');
+
+ // Advance to end offset.
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at max scroll offset");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at max scroll offset");
+ // The active-after boundary is inclusive since at the scroll-limit.
+ assert_equals(getComputedStyle(target).opacity, "0.5",
+ 'Effect at end of active phase');
+ }, 'View timeline does not clamp end scroll offset at max scroll');
+
+
+ promise_test(async t => {
+ container.style = "direction: rtl";
+ container.scrollLeft = 0;
+ t.add_cleanup(() => {
+ content.style = null;
+ });
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline:
+ {axis: 'inline'}
+ });
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Initially before start-offset and animation effect is in the before
+ // phase.
+ assert_percents_equal(timeline.currentTime, -150,
+ "Timeline's currentTime at container start boundary");
+ assert_percents_equal(anim.currentTime, -150,
+ "Animation's currentTime at container start boundary");
+ assert_equals(getComputedStyle(target).opacity, "1",
+ 'Effect is inactive in the before phase');
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollLeft = -600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 0,
+ "Timeline's current time at start offset");
+ assert_percents_equal(anim.currentTime, 0,
+ "Animation's current time at start offset");
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ 'Effect at the start of the active phase');
+
+ // Advance to the midpoint of the animation.
+ container.scrollLeft = -800;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 50,
+ "Timeline's currentTime at midpoint");
+ assert_percents_equal(anim.currentTime, 50,
+ "Animation's currentTime at midpoint");
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ 'Effect at the midpoint of the active range');
+
+ // Advance to the end of the animation.
+ container.scrollLeft = -1000;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 100,
+ "Timeline's currentTime at end offset");
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at end offset");
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'Effect is in the after phase at effect end time');
+
+ // Advance to the scroll limit.
+ container.scrollLeft = -1600;
+ await waitForNextFrame();
+ assert_percents_equal(timeline.currentTime, 250,
+ "Timeline's currentTime at scroll limit");
+ // Hold time set when the animation finishes, which clamps the value of
+ // the animation's currentTime.
+ assert_percents_equal(anim.currentTime, 100,
+ "Animation's currentTime at scroll limit");
+ // In the after phase, so the effect should not be applied.
+ assert_equals(getComputedStyle(target).opacity, '1',
+ 'After phase at scroll limit');
+ }, 'View timeline with container having RTL layout' );
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html
new file mode 100644
index 0000000000..057d0afabc
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary-ref.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title></title>
+</head>
+<style type="text/css">
+ .scroller {
+ display: inline-block;
+ border: 2px solid black;
+ height: 100px;
+ width: 100px;
+ overflow: hidden;
+ }
+ .box {
+ background: gray;
+ height: 50px;
+ width: 50px;
+ margin: 0;
+ }
+ .half-shift {
+ transform: translateX(25px);
+ }
+ .full-shift {
+ transform: translateX(50px);
+ }
+ .blue {
+ background-color: #99f;
+ }
+ .green {
+ background-color: #9f9;
+ }
+</style>
+<body>
+ <div id="scroller-1" class="scroller">
+ <div class="box green"></div>
+ <div class="box blue full-shift"></div>
+ </div>
+ <div id="scroller-2" class="scroller">
+ <div class="box"></div>
+ <div class="box blue"></div>
+ </div>
+ <br>
+ <div id="scroller-3" class="scroller">
+ <div class="box"></div>
+ <div class="box blue"></div>
+ </div>
+ <div id="scroller-4" class="scroller">
+ <div class="box"></div>
+ <div class="box green"></div>
+ </div>
+ <br>
+ <div id="scroller-5" class="scroller">
+ <div class="box blue"></div>
+ <div class="box half-shift green"></div>
+ </div>
+ <div id="scroller-6" class="scroller">
+ <div class="box"></div>
+ <div class="box green"></div>
+ </div>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html
new file mode 100644
index 0000000000..e2ca394ec0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/range-boundary.html
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="match" href="range-boundary-ref.html">
+ <title></title>
+</head>
+<style type="text/css">
+ @keyframes transform {
+ 0% { transform: translateX(25px); }
+ 100% { transform: translateX(50px); }
+ }
+
+ @keyframes background {
+ 0% { background-color: #99f; }
+ 100% { background-color: #9f9; }
+ }
+
+ .scroller {
+ display: inline-block;
+ border: 2px solid black;
+ height: 100px;
+ width: 100px;
+ overflow: hidden;
+ }
+ .spacer {
+ height: 300px;
+ margin: 0;
+ }
+ .box {
+ background: gray;
+ height: 50px;
+ width: 50px;
+ margin: 0;
+ animation: transform auto, background auto;
+ animation-timeline: view(), view();
+ animation-range: entry 0% entry 100%, contain 0% contain 100%;
+ }
+</style>
+<body>
+ <!-- scroll to bottom
+ top-box:
+ transform: none (after phase)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ bottom-box:
+ transform: 100px (at active-after boundary with inclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-1" class="scroller">
+ <div class="spacer"></div>
+ <div class="box"></div>
+ <div class="box"></div>
+ </div>
+ <!-- scroll to top
+ top-box:
+ transform: none (after phase)
+ bg-color: gray (at active-after boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-after boundary with exclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-2" class="scroller">
+ <div class="box"></div>
+ <div class="box"></div>
+ <div class="spacer"></div>
+ </div>
+ <br>
+ <!-- scroll to midpoint
+ top-box:
+ transform: none (after phase)
+ bg-color: gray (at active-after boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-after boundary with exclusive endpoint)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-3" class="scroller">
+ <div class="spacer"></div>
+ <div class="box"></div>
+ <div class="box"></div>
+ <div class="spacer"></div>
+ </div>
+ <!-- scroll to bottom + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: gray (at active-before boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-before boundary with exclusive endpoint)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ -->
+ <div id="scroller-4" class="scroller">
+ <div class="spacer"></div>
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ </div>
+ <br>
+ <!-- scroll to top + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: #99f (at active-before boundary with inclusive endpoint)
+ bottom-box:
+ transform: 25px (at active-before boundary with inclusive endpoint)
+ bg-color: #9f9 (at active-after boundary with inclusive endpoint)
+ -->
+ <div id="scroller-5" class="scroller">
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ <div class="spacer"></div>
+ </div>
+ <!-- scroll to midpoint + reverse
+ top-box:
+ transform: none (before phase)
+ bg-color: gray (at active-before boundary with exclusive endpoint)
+ bottom-box:
+ transform: none (at active-before boundary with exclusive endpoint)
+ bg-color: #9f9 (at active-before boundary with inclusive endpoint)
+ -->
+ <div id="scroller-6" class="scroller">
+ <div class="spacer"></div>
+ <div class="box reverse"></div>
+ <div class="box reverse"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+<script src="/common/reftest-wait.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ function scrollTo(scroller_id, relative_offset) {
+ const scroller = document.getElementById(scroller_id);
+ const max_scroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = relative_offset * max_scroll;
+ }
+
+ window.onload = async () => {
+ await waitForCompositorReady();
+ document.querySelectorAll('.reverse').forEach(elem => {
+ elem.getAnimations().forEach(anim => {
+ anim.reverse();
+ });
+ });
+ // Playing forward
+ scrollTo('scroller-1', 1);
+ scrollTo('scroller-2', 0);
+ scrollTo('scroller-3', 0.5);
+ // Playing reverse
+ scrollTo('scroller-4', 1);
+ scrollTo('scroller-5', 0);
+ scrollTo('scroller-6', 0.5);
+ await waitForNextFrame();
+ takeScreenshot();
+ };
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html
new file mode 100644
index 0000000000..d8756769c5
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-1.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky during entry */
+.stickycase1 {
+ background: yellow;
+ position: sticky;
+ top: 400px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase1">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky during entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html
new file mode 100644
index 0000000000..2d098dcbe3
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-2.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* bottom-sticky during entry and top-sticky during exit */
+.stickycase2 {
+ background: yellow;
+ position: sticky;
+ top: -100px;
+ bottom: -100px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase2">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + TARGET_HEIGHT,
+ endOffset: STATIC_END - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline bottom-sticky during entry and top-sticky during exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html
new file mode 100644
index 0000000000..c87dfc4dcb
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-3.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky and bottom-sticky during entry */
+.stickycase3 {
+ background: yellow;
+ position: sticky;
+ top: 375px;
+ bottom: -125px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase3">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky and bottom-sticky during entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html
new file mode 100644
index 0000000000..f6b02ffb2e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-4.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* top-sticky before entry */
+.stickycase4 {
+ background: yellow;
+ position: sticky;
+ top: 600px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase4">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START + ROOM_BELOW,
+ endOffset: STATIC_START + ROOM_BELOW + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END + ROOM_BELOW - TARGET_HEIGHT,
+ endOffset: STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline top-sticky before entry.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html
new file mode 100644
index 0000000000..380c01297e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-5.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* bottom-sticky before entry and top-sticky after exit */
+.stickycase5 {
+ background: yellow;
+ position: sticky;
+ top: -200px;
+ bottom: -200px;
+ height: 200px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 100px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase5">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const STATIC_END = 850;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const TARGET_HEIGHT = 100;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + TARGET_HEIGHT,
+ endOffset: STATIC_END - TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START,
+ endOffset: STATIC_START + TARGET_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_END - TARGET_HEIGHT,
+ endOffset: STATIC_END,
+ axis: 'block'
+ });
+}, 'View timeline bottom-sticky before entry and top-sticky after exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html
new file mode 100644
index 0000000000..94f0abc9b1
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-6.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* target > viewport, bottom-sticky during entry and top-sticky during exit */
+.stickycase6 {
+ background: yellow;
+ position: sticky;
+ top: -200px;
+ bottom: -200px;
+ height: 700px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 600px;
+}
+
+.space:has(.stickycase6),
+.space:has(.stickycase7) {
+ height: 1050px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase6">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const BIG_TARGET_STATIC_END = 1350;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const BIG_TARGET_HEIGHT = 600;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START + VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: BIG_TARGET_STATIC_END - VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline target > viewport, ' +
+ 'bottom-sticky during entry and top-sticky during exit.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html
new file mode 100644
index 0000000000..83115249fa
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/sticky/view-timeline-sticky-offscreen-7.html
@@ -0,0 +1,128 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky during entry/exit</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 550px;
+}
+
+/* target > viewport, bottom-sticky and top-sticky during contain */
+.stickycase7 {
+ background: yellow;
+ position: sticky;
+ top: -100px;
+ bottom: -100px;
+ height: 700px;
+}
+
+#target {
+ position: relative;
+ top: 50px;
+ background: orange;
+ height: 600px;
+}
+
+.space:has(.stickycase6),
+.space:has(.stickycase7) {
+ height: 1050px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 150px"></div>
+ <div id="sticky" class="stickycase7">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+// The "cover" range would be [STATIC_START, STATIC_END] if we ignored
+// stickiness (i.e., considered only static position).
+//
+// STATIC_START = scroll distance to second spacer (50px)
+// + position of sticky element within its container (150px)
+// + position of target within sticky element (50px)
+// STATIC_END = STATIC_START
+// + viewport height (500px)
+// + target height (100px)
+const STATIC_START = 250;
+const BIG_TARGET_STATIC_END = 1350;
+
+// This is how far the sticky element can move upwards when bottom-stuck.
+const ROOM_ABOVE = 150;
+
+// This is how far the sticky element can move downwards when top-stuck.
+const ROOM_BELOW = 200;
+
+const BIG_TARGET_HEIGHT = 600;
+const VIEWPORT_HEIGHT = 500;
+
+promise_test(async t => {
+ sticky.className = "stickycase7";
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: BIG_TARGET_STATIC_END + ROOM_BELOW - VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: STATIC_START - ROOM_ABOVE + VIEWPORT_HEIGHT,
+ endOffset: BIG_TARGET_STATIC_END + ROOM_BELOW,
+ axis: 'block'
+ });
+}, 'View timeline target > viewport, ' +
+ 'bottom-sticky and top-sticky during contain.');
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html b/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html
new file mode 100644
index 0000000000..36627dbea6
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/subject-br-crash.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#viewtimeline-interface">
+<html>
+<!-- crbug.com/1470522 --->
+<script>
+ function main() {
+ var b = document.createElement("br");
+ document.body.append(b);
+ new ViewTimeline({ subject: b });
+ }
+</script>
+<body onload=main()>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html
new file mode 100644
index 0000000000..9b100a0b64
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-001.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element</title>
+</head>
+<style type="text/css">
+ @keyframes stroke {
+ from { stroke: rgb(0, 0, 254); }
+ to { stroke: rgb(0, 128, 0); }
+ }
+
+ #line {
+ animation: stroke auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" stroke="red" stroke-width="5">
+ <path id="line" d="M 50 0 V 3000"></path>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('line');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html
new file mode 100644
index 0000000000..e173a649ef
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-002.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element in a nested &lt;svg></title>
+</head>
+<style type="text/css">
+ @keyframes stroke {
+ from { stroke: rgb(0, 0, 254); }
+ to { stroke: rgb(0, 128, 0); }
+ }
+
+ #line {
+ animation: stroke auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" stroke="red" stroke-width="5">
+ <svg>
+ <path id="line" d="M 50 0 V 3000"></path>
+ </svg>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('line');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).stroke, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html
new file mode 100644
index 0000000000..48e238c8ed
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/svg-graphics-element-003.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>View Timeline attached to an SVG graphics element (&lt;foreignObject>)</title>
+</head>
+<style type="text/css">
+ @keyframes color {
+ from { color: rgb(0, 0, 254); }
+ to { color: rgb(0, 128, 0); }
+ }
+
+ #fo {
+ animation: color auto linear both;
+ animation-timeline: view();
+ animation-range: exit-crossing;
+ }
+ .spacer {
+ height: 100vh;
+ }
+</style>
+<body>
+<svg width="100" height="3000" color="red">
+ <foreignObject id="fo" x="47.5" width="3000" height="5"
+ transform="rotate(90, 47.5, 0)">
+ <div style="width: 100%; height: 200%; background-color: currentcolor"></div>
+ </foreignObject>
+</svg>
+<div class="spacer"></div>
+</body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script>
+ promise_test(async t => {
+ const scroller = document.scrollingElement;
+ const target = document.getElementById('fo');
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+ assert_equals(getComputedStyle(target).color, 'rgb(0, 0, 254)');
+ scroller.scrollTop =
+ 0.5*(scroller.scrollHeight - scroller.clientHeight);
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).color, 'rgb(0, 64, 127)');
+ }, 'View timeline attached to SVG graphics element');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js b/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js
new file mode 100644
index 0000000000..a798fe918d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/testcommon.js
@@ -0,0 +1,146 @@
+'use strict';
+
+function assert_px_equals(observed, expected, description) {
+ assert_equals(observed.unit, 'px',
+ `Unexpected unit type for '${description}'`);
+ assert_approx_equals(observed.value, expected, 0.0001,
+ `Unexpected value for ${description}`);
+}
+
+function CreateViewTimelineOpacityAnimation(test, target, options) {
+ const timeline_options = {
+ subject: target,
+ axis: 'block'
+ };
+ if (options && 'timeline' in options) {
+ for (let key in options.timeline) {
+ timeline_options[key] = options.timeline[key];
+ }
+ }
+ const animation_options = {
+ timeline: new ViewTimeline(timeline_options)
+ };
+ if (options && 'animation' in options) {
+ for (let key in options.animation) {
+ animation_options[key] = options.animation[key];
+ }
+ }
+
+ const anim =
+ target.animate({ opacity: [0.3, 0.7] }, animation_options);
+ test.add_cleanup(() => {
+ anim.cancel();
+ });
+ return anim;
+}
+
+// Verify that range specified in the options aligns with the active range of
+// the animation.
+//
+// Sample call:
+// await runTimelineBoundsTest(t, {
+// timeline: { inset: [ CSS.percent(0), CSS.percent(20)] },
+// timing: { fill: 'both' }
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineBoundsTest(t, options, message) {
+ const scrollOffsetProp = options.axis == 'block' ? 'scrollTop' : 'scrollLeft';
+ container[scrollOffsetProp] = 0;
+ await waitForNextFrame();
+
+ const anim =
+ options.anim ||
+ CreateViewTimelineOpacityAnimation(t, target, options);
+ if (options.timing)
+ anim.effect.updateTiming(options.timing);
+
+ const timeline = anim.timeline;
+ await anim.ready;
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container[scrollOffsetProp] = options.startOffset;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ `Effect at the start of the active phase: ${message}`);
+
+ // Advance to the midpoint of the animation.
+ container[scrollOffsetProp] = (options.startOffset + options.endOffset) / 2;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range: ${message}`);
+
+ // Advance to the end of the animation.
+ container[scrollOffsetProp] = options.endOffset;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ `Effect is in the active phase at effect end time: ${message}`);
+
+ // Return the animation so that we can continue testing with the same object.
+ return anim;
+}
+
+// Sets the start and end range for a view timeline and ensures that the
+// range aligns with expected values.
+//
+// Sample call:
+// await runTimelineRangeTest(t, {
+// rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+// rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineRangeTest(t, options) {
+ const rangeToString = range => {
+ const parts = [];
+ if (range.rangeName)
+ parts.push(range.rangeName);
+ if (range.offset)
+ parts.push(`${range.offset.value}%`);
+ return parts.join(' ');
+ };
+ const range =
+ `${rangeToString(options.rangeStart)} to ` +
+ `${rangeToString(options.rangeEnd)}`;
+
+ options.timeline = {
+ axis: options.axis || 'inline'
+ };
+ options.animation = {
+ rangeStart: options.rangeStart,
+ rangeEnd: options.rangeEnd,
+ };
+ options.timing = {
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ };
+
+ return runTimelineBoundsTest(t, options, range);
+}
+
+// Sets the Inset for a view timeline and ensures that the range aligns with
+// expected values.
+//
+// Sample call:
+// await runTimelineInsetTest(t, {
+// inset: [ CSS.px(20), CSS.px(40) ]
+// startOffset: 600,
+// endOffset: 900
+// });
+async function runTimelineInsetTest(t, options) {
+ options.timeline = {
+ axis: 'inline',
+ inset: options.inset
+ };
+ options.timing = {
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ }
+ const length = options.inset.length;
+ const range =
+ (options.inset instanceof Array) ? options.inset.join(' ')
+ : options.inset;
+ return runTimelineBoundsTest(t, options, range);
+}
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html b/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html
new file mode 100644
index 0000000000..1168893854
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/timeline-offset-in-keyframe.html
@@ -0,0 +1,264 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<title>Animation range and delay</title>
+</head>
+<style type="text/css">
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin: 800px 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ }
+</style>
+<body>
+ <div id=scroller>
+ <div id=target></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ function assert_progress_equals(anim, expected, errorMessage) {
+ assert_approx_equals(
+ anim.effect.getComputedTiming().progress,
+ expected, 1e-6, errorMessage);
+ }
+
+ function assert_opacity_equals(expected, errorMessage) {
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), expected, 1e-6,
+ errorMessage);
+ }
+
+ async function runTimelineOffsetsInKeyframesTest(keyframes) {
+ const testcase = JSON.stringify(keyframes);
+ const anim = target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+ await anim.ready;
+ await waitForNextFrame();
+
+ // @ contain 0%
+ scroller.scrollTop = 700;
+ await waitForNextFrame();
+
+ assert_progress_equals(
+ anim, 0, `Testcase '${testcase}': progress at contain 0%`);
+ assert_opacity_equals(
+ 1/3, `Testcase '${testcase}': opacity at contain 0%`);
+
+ // @ contain 50%
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Testcase '${testcase}': progress at contain 50%`);
+ assert_opacity_equals(
+ 0.5, `Testcase '${testcase}': opacity at contain 50%`);
+
+ // @ contain 100%
+ scroller.scrollTop = 800;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 1, `Testcase '${testcase}': progress at contain 100%`);
+ assert_opacity_equals(
+ 2/3, `Testcase '${testcase}': opacity at contain 100%`);
+ anim.cancel();
+ }
+
+ async function runParseNumberOrPercentInKeyframesTest(keyframes) {
+ const anim = target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+ await anim.ready;
+ await waitForNextFrame();
+
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
+ scroller.scrollTop = maxScroll / 2;
+ await waitForNextFrame();
+
+ const testcase = JSON.stringify(keyframes);
+ assert_progress_equals(anim, 0.5, testcase);
+ assert_opacity_equals(0.5, testcase);
+ anim.cancel();
+ }
+
+ async function runInvalidKeyframesTest(keyframes) {
+ assert_throws_js(TypeError, () => {
+ target.animate(keyframes, {
+ timeline: new ViewTimeline( { subject: target }),
+ });
+ }, `Invalid keyframes test case "${JSON.stringify(keyframes)}"`);
+ }
+
+ promise_test(async t => {
+ // Test equivalent typed-OM and CSS representations of timeline offsets.
+ // Test array and object form for keyframes.
+ const keyframeTests = [
+ // BaseKeyframe form with offsets expressed as typed-OM.
+ [
+ {
+ offset: { rangeName: 'cover', offset: CSS.percent(0) },
+ opacity: 0
+ },
+ {
+ offset: { rangeName: 'cover', offset: CSS.percent(100) },
+ opacity: 1
+ }
+ ],
+ // BaseKeyframe form with offsets expressed as CSS text.
+ [
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ],
+ // BasePropertyIndexedKeyframe form with offsets expressed as typed-OM.
+ {
+ opacity: [0, 1],
+ offset: [
+ { rangeName: 'cover', offset: CSS.percent(0) },
+ { rangeName: 'cover', offset: CSS.percent(100) }
+ ]
+ },
+ // BasePropertyIndexedKeyframe form with offsets expressed as CSS text.
+ { opacity: [0, 1], offset: [ "cover 0%", "cover 100%" ]}
+ ];
+
+ for (let i = 0; i < keyframeTests.length; i++) {
+ await runTimelineOffsetsInKeyframesTest(keyframeTests[i]);
+ }
+
+ }, 'Timeline offsets in programmatic keyframes');
+
+ promise_test(async t => {
+ const keyframeTests = [
+ [{offset: "0.5", opacity: 0.5 }],
+ [{offset: "50%", opacity: 0.5 }],
+ [{offset: "calc(20% + 30%)", opacity: 0.5 }]
+ ];
+
+ for (let i = 0; i < keyframeTests.length; i++) {
+ await runParseNumberOrPercentInKeyframesTest(keyframeTests[i]);
+ }
+
+ }, 'String offsets in programmatic keyframes');
+
+ promise_test(async t => {
+ const invalidKeyframeTests = [
+ // BasePropertyKefyrame:
+ [{ offset: { rangeName: 'somewhere', offset: CSS.percent(0) }}],
+ [{ offset: { rangeName: 'entry', offset: CSS.px(0) }}],
+ [{ offset: "here 0%" }],
+ [{ offset: "entry 3px" }],
+ // BasePropertyIndexedKeyframe with sequence:
+ { offset: [{ rangeName: 'somewhere', offset: CSS.percent(0) }]},
+ { offset: [{ rangeName: 'entry', offset: CSS.px(0) }]},
+ { offset: ["here 0%"] },
+ { offset: ["entry 3px" ]},
+ // BasePropertyIndexedKeyframe without sequence:
+ { offset: { rangeName: 'somewhere', offset: CSS.percent(0) }},
+ { offset: { rangeName: 'entry', offset: CSS.px(0) }},
+ { offset: "here 0%" },
+ { offset: "entry 3px" },
+ // <number> or <percent> as string:
+ [{ offset: "-1" }],
+ [{ offset: "2" }],
+ [{ offset: "-10%" }],
+ [{ offset: "110%" }],
+ { offset: ["-1"], opacity: [0.5] },
+ { offset: ["2"], opacity: [0.5] },
+ { offset: "-1", opacity: 0.5 },
+ { offset: "2", opacity: 0.5 },
+ // Extra stuff at the end.
+ [{ offset: "0.5 trailing nonsense" }],
+ [{ offset: "cover 50% eureka" }]
+ ];
+ for( let i = 0; i < invalidKeyframeTests.length; i++) {
+ await runInvalidKeyframesTest(invalidKeyframeTests[i]);
+ }
+ }, 'Invalid timeline offset in programmatic keyframe throws');
+
+
+ promise_test(async t => {
+ const anim = target.animate([
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ], {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 10000, fill: 'both'
+ });
+
+ scroller.scrollTop = 750;
+
+ await anim.ready;
+ assert_opacity_equals(1, `Opacity with document timeline`);
+
+ anim.timeline = new ViewTimeline( { subject: target });
+ await anim.ready;
+
+ assert_progress_equals(anim, 0.5, `Progress at contain 50%`);
+ assert_opacity_equals(0.5, `Opacity at contain 50%`);
+
+ anim.timeline = document.timeline;
+ assert_false(anim.pending);
+ await waitForNextFrame();
+ assert_opacity_equals(1, `Opacity after resetting timeline`);
+
+ anim.cancel();
+ }, 'Timeline offsets in programmatic keyframes adjust for change in ' +
+ 'timeline');
+
+ promise_test(async t => {
+ const anim = target.animate([], {
+ timeline: new ViewTimeline( { subject: target }),
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ duration: 'auto', fill: 'both'
+ });
+
+ await anim.ready;
+ await waitForNextFrame();
+
+ scroller.scrollTop = 750;
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Progress at contain 50% before effect change`);
+ assert_opacity_equals(1, `Opacity at contain 50% before effect change`);
+
+ anim.effect = new KeyframeEffect(target, [
+ { offset: "cover 0%", opacity: 0 },
+ { offset: "cover 100%", opacity: 1 }
+ ], { duration: 'auto', fill: 'both' });
+ await waitForNextFrame();
+ assert_progress_equals(
+ anim, 0.5, `Progress at contain 50% after effect change`);
+ assert_opacity_equals(0.5, `Opacity at contain 50% after effect change`);
+ }, 'Timeline offsets in programmatic keyframes resolved when updating ' +
+ 'the animation effect');
+ }
+
+ // TODO(kevers): Add tests for getKeyframes once
+ // https://github.com/w3c/csswg-drafts/issues/8507 is resolved.
+
+ window.onload = runTest;
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html b/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html
new file mode 100644
index 0000000000..86262db8f8
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/unattached-subject-inset.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Test construction of a view timeline with a detached subject</title>
+</head>
+<style type="text/css">
+ #container {
+ overflow: hidden;
+ height: 200px;
+ width: 200px;
+ }
+
+ #block {
+ background: green;
+ height: 100px;
+ width: 100px;
+ }
+
+ .filler {
+ height: 200px;
+ }
+</style>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<body>
+ <div id="container">
+ <div class="filler"></div>
+ </div>
+</body>
+<script>
+ promise_test(async t => {
+ const element = document.createElement('div');
+ element.id = 'block';
+ const timeline = new ViewTimeline({
+ subject: element,
+ inset: new CSSMathNegate(CSS.px(144))
+ });
+ assert_equals(timeline.source, null, 'Null source while detached');
+ await waitForNextFrame();
+ const scroller = document.getElementById('container');
+ scroller.appendChild(element);
+ assert_equals(timeline.source, scroller, 'Source resolved once attached');
+ await waitForNextFrame();
+
+ // Start offset = cover 0%
+ // = target offset - viewport height + end side inset
+ // = 200 - 200 + (-144) = -144
+ assert_equals(timeline.startOffset.toString(), CSS.px(-144).toString());
+ // End offset = cover 100%
+ // = target offset + target height - start side inset
+ // = 200 + 100 - (-144) = 444
+ assert_equals(timeline.endOffset.toString(), CSS.px(444).toString());
+ }, 'Creating a view timeline with a subject that is not attached to the ' +
+ 'document works as expected');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html
new file mode 100644
index 0000000000..25e477e1a9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-current-time-range-name.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ const MAX_SCROLL = 1600;
+
+ promise_test(async t => {
+ // Points of interest along view timeline:
+ // 600 px cover start, entry start
+ // 700 px contain start, entry end
+ // 800 px contain end, exit start
+ // 900 px cover end, exit end
+ const anim =
+ CreateViewTimelineOpacityAnimation(t, target,
+ {
+ timeline: { axis: 'inline' },
+ animation: { fill: 'both' }
+ });
+ let timeline = anim.timeline;
+
+ container.scrollLeft = 600;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('cover'), 0,
+ MAX_SCROLL, 'Scroll aligned with cover start');
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 0,
+ MAX_SCROLL, 'Scroll aligned with entry start');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 0,
+ MAX_SCROLL,
+ 'Scroll aligned with timeline start offset');
+
+ container.scrollLeft = 650;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 50,
+ MAX_SCROLL, 'Scroll at entry midpoint');
+
+ container.scrollLeft = 700;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('entry'), 100,
+ MAX_SCROLL, 'Scroll at entry end');
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 0,
+ MAX_SCROLL, 'Scroll at contain start');
+
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 50,
+ MAX_SCROLL, 'Scroll at contain midpoint');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 50,
+ MAX_SCROLL, 'Scroll at timeline midpoint');
+
+ container.scrollLeft = 800;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 0,
+ MAX_SCROLL, 'Scroll at exit start');
+ assert_percents_approx_equal(timeline.getCurrentTime('contain'), 100,
+ MAX_SCROLL, 'Scroll at contain end');
+
+ container.scrollLeft = 850;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 50,
+ MAX_SCROLL, 'Scroll at exit midpoint');
+
+ container.scrollLeft = 900;
+ await waitForNextFrame();
+
+ assert_percents_approx_equal(timeline.getCurrentTime('exit'), 100,
+ MAX_SCROLL, 'Scroll at exit end');
+ assert_percents_approx_equal(timeline.getCurrentTime('cover'), 100,
+ MAX_SCROLL, 'Scroll at cover end');
+ assert_percents_approx_equal(timeline.getCurrentTime(), 100,
+ MAX_SCROLL, 'Scroll at end of timeline');
+
+ assert_equals(timeline.getCurrentTime('gibberish'), null,
+ 'No current time for unknown named range');
+
+ // Add insets to force the start and end offsets to align. This forces
+ // the timeline to become inactive.
+ // start_offset = target_offset - viewport_size + end_side_inset
+ // = 600 + end_side_inset
+ // end_offset = target_offset + target_size - start_side_inset
+ // = 900 - start_side_inset
+ // Equating start_offset and end_offset:
+ // end_side_inset = 300 - start_side_inset;
+ timeline =
+ new ViewTimeline ({
+ subject: target,
+ axis: 'inline',
+ inset: [ CSS.px(150), CSS.px(150) ]
+ });
+ anim.timeline = timeline;
+ await waitForNextFrame();
+
+ assert_equals(timeline.currentTime, null,
+ 'Current time is null when scroll-range is zero');
+ assert_equals(timeline.getCurrentTime(), null,
+ 'getCurrentTime with an inactive timeline.');
+ assert_equals(timeline.getCurrentTime('contain'), null,
+ 'getCurrentTime on a ranged name with an inactive timeline.');
+
+ }, 'View timeline current time for named range');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html
new file mode 100644
index 0000000000..94660abcf2
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-get-set-range.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<script src="/css/css-typed-om/resources/testhelper.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 10px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ function assert_timeline_offset(actual, expected, errorMessage) {
+ assert_equals(actual.rangeName, expected.rangeName, errorMessage);
+ assert_style_value_equals(actual.offset, expected.offset);
+ }
+
+ promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target, axis: 'inline' });
+ const anim = target.animate({ opacity: [0, 1 ] }, { timeline: timeline });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ await anim.ready;
+
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+
+ // normal ==> cover 0% to cover 100%
+ // cover 0% @ 600px
+ // cover 100% @ 900px
+ // expected opacity = (750 - 600) / (900 - 600) = 0.5
+ assert_equals(anim.rangeStart, 'normal', 'Initial value for rangeStart');
+ assert_equals(anim.rangeEnd, 'normal', 'Initial value for rangeEnd');
+ assert_equals(getComputedStyle(target).opacity, '0.5',
+ 'Opacity with range set to [normal, normal]');
+
+ // contain 0% @ 700px
+ // cover 100% @ 900px
+ // expected opacity = (750 - 700) / (900 - 700) = 0.25
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "contain 0%";
+ anim.rangeEnd = "cover 100%";
+ });
+
+ assert_timeline_offset(
+ anim.rangeStart,
+ { rangeName: 'contain', offset: CSS.percent(0) },
+ 'rangeStart set to contain 0%');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'cover', offset: CSS.percent(100) },
+ 'rangeEnd set to cover 100%');
+ assert_equals(getComputedStyle(target).opacity, '0.25',
+ 'opacity with range set to [contain 0%, cover 100%]');
+
+ // entry -20px @ 580px
+ // exit-crossing 10% @ 810px
+ // expected opacity = (750 - 580) / (810 - 580) = 0.739130
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = { rangeName: 'entry', offset: CSS.px(-20), };
+ anim.rangeEnd = { rangeName: 'exit-crossing', offset: CSS.percent(10) };
+ });
+ assert_timeline_offset(
+ anim.rangeStart,
+ { rangeName: 'entry', offset: CSS.px(-20) },
+ 'rangeStart set to entry -20px');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'exit-crossing', offset: CSS.percent(10) },
+ 'rangeEnd set to exit-crossing 10%');
+ assert_approx_equals(
+ parseFloat(getComputedStyle(target).opacity), 0.739130, 1e-6,
+ 'opacity with range set to [entry -20px, exit-crossing 10%]');
+
+ // normal [start] @ 600px
+ // contain 100% @ 800px
+ // expected opacity = (750 - 600) / (800 - 600) = 0.75
+ await runAndWaitForFrameUpdate(() => {
+ anim.rangeStart = "normal";
+ anim.rangeEnd = "contain calc(60% + 40%)";
+ });
+ assert_equals(anim.rangeStart, 'normal','rangeStart set to normal');
+ assert_timeline_offset(
+ anim.rangeEnd,
+ { rangeName: 'contain', offset: CSS.percent(100) },
+ 'rangeEnd set to contain 100%');
+ assert_equals(getComputedStyle(target).opacity, '0.75',
+ 'opacity with range set to [normal, contain 100%]');
+ }, 'Getting and setting the animation range');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html
new file mode 100644
index 0000000000..357d8558f9
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-inset.html
@@ -0,0 +1,226 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 16px;
+ }
+ #target.big-font {
+ font-size: 20px;
+ }
+ #container.scroll-padded {
+ scroll-padding-inline: 10px 20px;
+ }
+</style>
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+
+ function verifyTimelineOffsets(anim, start, end) {
+ const timeline = anim.timeline;
+ assert_px_equals(timeline.startOffset, start, 'startOffset');
+ assert_px_equals(timeline.endOffset, end, 'endOffset');
+ };
+
+ promise_test(async t => {
+ // These tests are all based on the cover range, which has bounds
+ // [600, 900] if there are no insets.
+ // startOffset = target_pos - viewport_size + end_side_inset
+ // = 600 + end_side_inset
+ // endOffset = target_pos + target_size - start_side_inset
+ // = 900 - start_side_inset
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(0), CSS.px(0) ],
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => verifyTimelineOffsets(anim, 600, 900));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(10), CSS.px(20) ],
+ startOffset: 620,
+ endOffset: 890
+ }).then(anim => verifyTimelineOffsets(anim, 620, 890));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.px(10) ],
+ startOffset: 610,
+ endOffset: 890
+ }).then(anim => verifyTimelineOffsets(anim, 610, 890));
+ }, 'View timeline with px based inset.');
+
+ promise_test(async t => {
+ // These tests are all based on the cover range, which has bounds
+ // [600, 900].
+ // Percentages are relative to the viewport size, which is 200 for this
+ // test.
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(0), CSS.percent(0) ],
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => verifyTimelineOffsets(anim, 600, 900));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(10), CSS.percent(20) ],
+ startOffset: 640,
+ endOffset: 880
+ }).then(anim => verifyTimelineOffsets(anim, 640, 880));
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.percent(10) ],
+ startOffset: 620,
+ endOffset: 880
+ }).then(anim => verifyTimelineOffsets(anim, 620, 880));
+ }, 'View timeline with percent based inset.');
+
+ promise_test(async t => {
+ t.add_cleanup(() => {
+ container.classList.remove('scroll-padded');
+ });
+ const anim = await runTimelineInsetTest(t, {
+ inset: [ "auto", "auto" ],
+ startOffset: 600,
+ endOffset: 900
+ });
+ verifyTimelineOffsets(anim, 600, 900);
+ container.classList.add('scroll-padded');
+ await runTimelineBoundsTest(t, {
+ anim: anim,
+ startOffset: 620,
+ endOffset: 890,
+ }, 'Adjust for scroll-padding')
+ .then(anim => verifyTimelineOffsets(anim, 620, 890));
+ }, 'view timeline with inset auto.');
+
+promise_test(async t => {
+ t.add_cleanup(() => {
+ target.classList.remove('big-font');
+ });
+ const anim = await runTimelineInsetTest(t, {
+ inset: [ CSS.em(1), CSS.em(2) ],
+ startOffset: 632,
+ endOffset: 884
+ });
+ verifyTimelineOffsets(anim, 632, 884);
+ target.classList.add('big-font');
+ await runTimelineBoundsTest(t, {
+ anim: anim,
+ startOffset: 640,
+ endOffset: 880,
+ }, 'Adjust for font size increase')
+ .then(anim => verifyTimelineOffsets(anim, 640, 880));
+}, 'view timeline with font relative inset.');
+
+promise_test(async t => {
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const vmin = Math.min(vw, vh);
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.vw(10), CSS.vw(20) ],
+ startOffset: 600 + 0.2 * vw,
+ endOffset: 900 - 0.1 * vw
+ });
+ await runTimelineInsetTest(t, {
+ inset: [ CSS.vmin(10), CSS.vmin(20) ],
+ startOffset: 600 + 0.2 * vmin,
+ endOffset: 900 - 0.1 * vmin
+ });
+}, 'view timeline with viewport relative insets.');
+
+promise_test(async t => {
+ await runTimelineInsetTest(t, {
+ inset: "10px",
+ startOffset: 610,
+ endOffset: 890
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10px 20px",
+ startOffset: 620,
+ endOffset: 890
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10%",
+ startOffset: 620,
+ endOffset: 880
+ });
+ await runTimelineInsetTest(t, {
+ inset: "10% 20%",
+ startOffset: 640,
+ endOffset: 880
+ });
+ await runTimelineInsetTest(t, {
+ inset: "auto",
+ startOffset: 600,
+ endOffset: 900
+ });
+ await runTimelineInsetTest(t, {
+ inset: "1em 2em",
+ startOffset: 632,
+ endOffset: 884
+ });
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: "go fish"
+ });
+ });
+
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: "1 2"
+ });
+ });
+
+}, 'view timeline inset as string');
+
+promise_test(async t => {
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: [ CSS.rad(1) ]
+ });
+ });
+
+ assert_throws_js(TypeError, () => {
+ new ViewTimeline({
+ subject: target,
+ inset: [ CSS.px(10), CSS.px(10), CSS.px(10) ]
+ });
+ });
+
+
+}, 'view timeline with invalid inset');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html
new file mode 100644
index 0000000000..01ca021524
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-missing-subject.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+<title>ViewTimeline with missing subject</title>
+<link rel="help" href="https://www.w3.org/TR/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+</head>
+<style type="text/css">
+ #target {
+ background: blue;
+ height: 100px;
+ width: 100px;
+ }
+ #scroller {
+ overflow: scroll;
+ }
+ #filler {
+ height: 300vh;
+ }
+</style>
+<body onload="runTests()">
+ <div id="scroller">
+ <div id="target"></div>
+ <div id="filler"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ function raf() {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ })
+ });
+ }
+ function runTests() {
+ promise_test(async t => {
+ const timeline = new ViewTimeline();
+ const anim =
+ target.animate(
+ { backgroundColor: ['green', 'red' ] },
+ { duration: 100,
+ timeline: timeline });
+ await raf();
+ scroller.scrollTop = 50;
+ await raf();
+ assert_equals(timeline.currentTime, null,
+ 'ViewTimeline with missing subject is inactive');
+ });
+ }
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html
new file mode 100644
index 0000000000..1cc23fe626
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-on-display-none-element.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>View timeline on element with display:none</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#scroll-timelines">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<script src="/css/css-typed-om/resources/testhelper.js"></script>
+
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: none;
+ }
+</style>
+
+<div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</div>
+
+<script>
+promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target });
+ const anim = target.animate({ opacity: [0, 0.5] }, { timeline: timeline });
+ t.add_cleanup(() => {
+ anim.cancel();
+ });
+ anim.rangeStart = "1em";
+ container.scrollLeft = 750;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, "1",
+ "Opacity with inactive timeline");
+}, "element with display: none should have inactive viewtimeline");
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html
new file mode 100644
index 0000000000..f87a57584e
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range-large-subject.html
@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 2100px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ /* target size > viewport size, which changes interpretation of the
+ contain range */
+ width: 400px;
+ display: inline-block;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 800
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 1000,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(-50) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(200) },
+ startOffset: 700,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry' },
+ rangeEnd: { rangeName: 'exit' },
+ startOffset: 600,
+ endOffset: 1200
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { offset: CSS.percent(0) },
+ rangeEnd: { offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 1200
+ });
+
+ }, 'View timeline with range set via delays.' );
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html
new file mode 100644
index 0000000000..5042c6c2a0
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-range.html
@@ -0,0 +1,198 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-x: scroll;
+ height: 200px;
+ width: 200px;
+ }
+ #content {
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ width: 1800px;
+ margin: 0;
+ }
+ .spacer {
+ width: 800px;
+ display: inline-block;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ display: inline-block;
+ font-size: 10px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="content">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+ </div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ // Delays are associated with the animation and not with the timeline.
+ // Thus adjusting the delays has no effect on the timeline offsets. The
+ // offsets always correspond to the 'cover' range.
+ const verifyTimelineOffsets = anim => {
+ const timeline = anim.timeline;
+ assert_px_equals(timeline.startOffset, 600, 'startOffset');
+ assert_px_equals(timeline.endOffset, 900, 'endOffset');
+ };
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 900
+ }).then(anim => {
+ verifyTimelineOffsets(anim);
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 700,
+ endOffset: 800
+ }).then(anim => {
+ verifyTimelineOffsets(anim);
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 700
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 700
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 800,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(-50) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(200) },
+ startOffset: 650,
+ endOffset: 800
+ });
+ }, 'View timeline with range as <name> <percent> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry' },
+ rangeEnd: { rangeName: 'exit' },
+ startOffset: 600,
+ endOffset: 900
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { offset: CSS.percent(0) },
+ rangeEnd: { offset: CSS.percent(100) },
+ startOffset: 600,
+ endOffset: 900
+ });
+ }, 'View timeline with range and inferred name or offset.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.px(100) },
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.px(100) },
+ startOffset: 720,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'entry', offset: CSS.px(100) },
+ startOffset: 620,
+ endOffset: 700
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.px(20) } ,
+ rangeEnd: { rangeName: 'exit', offset: CSS.px(80) },
+ startOffset: 820,
+ endOffset: 880
+ });
+
+ }, 'View timeline with range as <name> <px> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: {
+ rangeName: 'contain',
+ offset: new CSSMathSum(CSS.percent(0), CSS.px(20))
+ },
+ rangeEnd: {
+ rangeName: 'contain',
+ offset: new CSSMathSum(CSS.percent(100), CSS.px(-10))
+ },
+ startOffset: 720,
+ endOffset: 790
+ });
+
+ }, 'View timeline with range as <name> <percent+px> pair.' );
+
+ promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain -50%",
+ rangeEnd: "entry 200%",
+ startOffset: 650,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain 20px",
+ rangeEnd: "contain 100px",
+ startOffset: 720,
+ endOffset: 800
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "contain calc(0% + 20px)",
+ rangeEnd: "contain calc(100% - 10px)",
+ startOffset: 720,
+ endOffset: 790
+ });
+
+ await runTimelineRangeTest(t, {
+ rangeStart: "exit 2em",
+ rangeEnd: "exit 8em",
+ startOffset: 820,
+ endOffset: 880
+ });
+
+
+ }, 'View timeline with range as strings.');
+
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html
new file mode 100644
index 0000000000..20ac9c5464
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-root-source.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline delay</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #target {
+ margin: 200vh;
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="target"></div>
+</body>
+<script type="text/javascript">
+ promise_test(async t => {
+ const timeline = new ViewTimeline({ subject: target });
+ const anim = target.animate({ opacity: [0, 1 ] },
+ { timeline: timeline,
+ rangeStart: "entry 0%",
+ rangeEnd: "entry 100%",
+ fill: "both" });
+ const scroller = document.scrollingElement;
+ const scrollRange = scroller.scrollHeight - scroller.clientHeight;
+
+ await anim.ready;
+
+ await waitForNextFrame();
+ scroller.scrollTop = scrollRange / 2;
+ await waitForNextFrame();
+
+ assert_equals(getComputedStyle(target).opacity, "1");
+ }, 'Test view-timeline with document scrolling element.');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html
new file mode 100644
index 0000000000..5d68d37037
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-snapport.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<title>ViewTimeline vs. scroll-padding-*</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-timelines">
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#view-progress-visibility-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 200px;
+ width: 200px;
+ scroll-padding: 40px;
+ }
+ .spacer {
+ height: 800px;
+ }
+ #target {
+ background-color: green;
+ height: 200px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div id="leading-space" class="spacer"></div>
+ <div id="target"></div>
+ <div id="trailing-space" class="spacer"></div>
+ </div>
+</body>
+<script>
+ promise_test(async t => {
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target);
+ await anim.ready;
+
+ // 0%
+ container.scrollTop = 600;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 0);
+
+ // 50%
+ container.scrollTop = 800;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 50);
+
+ // 100%
+ container.scrollTop = 1000;
+ await waitForNextFrame();
+ assert_percents_equal(anim.currentTime, 100);
+ }, 'Default ViewTimeline is not affected by scroll-padding');
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html
new file mode 100644
index 0000000000..f8aabc8bdd
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-source.tentative.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline source</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<style>
+#outer {
+ height: 400px;
+ width: 400px;
+ overflow: clip;
+}
+
+#inner {
+ height: 300px;
+ width: 300px;
+ overflow: clip;
+}
+
+#outer.scroller,
+#inner.scroller {
+ overflow: scroll;
+}
+
+#spacer {
+ height: 1000px;
+}
+
+#target {
+ background: green;
+ height: 40px;
+ width: 40px;
+}
+</style>
+<body>
+ <div id="outer" class="scroller">
+ <div id="inner" class="scroller">
+ <div id="target"></div>
+ <div id="spacer"></div>
+ </div>
+ </div>
+</body>
+<script>
+'use strict';
+
+function resetScrollers() {
+ inner.classList.add('scroller');
+ outer.classList.add('scroller');
+}
+
+function assert_source_id(viewTimeline, expected) {
+ const source = viewTimeline.source;
+ assert_true(!!source, 'No source');
+ assert_equals(source.id, expected);
+}
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline = new ViewTimeline({ subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+
+ inner.classList.remove('scroller');
+ assert_source_id(viewTimeline, 'outer');
+
+ outer.classList.remove('scroller');
+ assert_source_id(viewTimeline, 'top');
+}, 'Default source for a View timeline is the nearest scroll ' +
+ 'ancestor to the subject');
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline =
+ new ViewTimeline({ source: outer, subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+}, 'View timeline ignores explicitly set source');
+
+promise_test(async t => {
+ t.add_cleanup(resetScrollers);
+ const viewTimeline =
+ new ViewTimeline({ subject: target });
+ assert_equals(viewTimeline.subject, target);
+ assert_source_id(viewTimeline, 'inner');
+
+ target.style = "display: none";
+ assert_equals(viewTimeline.source, null);
+
+}, 'View timeline source is null when display:none');
+
+</script>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html
new file mode 100644
index 0000000000..43b717560d
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-block.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ height: 500px;
+ overflow: auto;
+}
+.space {
+ height: 500px;
+}
+#targetp {
+ background: yellow;
+ position: sticky;
+ top: 0px;
+ bottom: 0px;
+ height: 50px;
+}
+#target {
+ height: 50px;
+}
+
+</style>
+</head>
+<body>
+<div id="container">
+ <div class="space"></div>
+ <div class="space">
+ <div style="height: 200px"></div>
+ <div id="targetp">
+ <div id="target">Subject</div>
+ </div>
+ </div>
+ <div class="space"></div>
+</div>
+<script type="text/javascript">
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 1000,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 50,
+ endOffset: 950,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000,
+ axis: 'block'
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000,
+ axis: 'block'
+ });
+}, 'View timeline with sticky target, block axis.' );
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html
new file mode 100644
index 0000000000..4dc8331d9f
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-sticky-inline.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html id="top">
+<head>
+<meta charset="utf-8">
+<title>View timeline with sticky</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+
+#container {
+ width: 500px;
+ height: 500px;
+ overflow: auto;
+ white-space: nowrap;
+}
+.space {
+ display: inline-block;
+ width: 500px;
+ height: 400px;
+ white-space: nowrap;
+}
+#target {
+ display: inline-block;
+ background: yellow;
+ position: sticky;
+ left: 0px;
+ right: 0px;
+ width: 50px;
+ height: 400px;
+}
+
+</style>
+</head>
+<body>
+<div id="container"><!--
+ --><div class="space"></div><!--
+ --><div class="space"><!--
+ --><div style="display:inline-block; width:200px"></div><!--
+ --><div id="target"></div><!--
+ --></div><!--
+ --><div class="space"></div><!--
+--></div>
+<script type="text/javascript">
+
+promise_test(async t => {
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'cover', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'cover', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'contain', offset: CSS.percent(0) } ,
+ rangeEnd: { rangeName: 'contain', offset: CSS.percent(100) },
+ startOffset: 50,
+ endOffset: 950
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'entry-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry-crossing', offset: CSS.percent(100) },
+ startOffset: 0,
+ endOffset: 50
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000
+ });
+ await runTimelineRangeTest(t, {
+ rangeStart: { rangeName: 'exit-crossing', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'exit-crossing', offset: CSS.percent(100) },
+ startOffset: 950,
+ endOffset: 1000
+ });
+}, 'View timeline with sticky target, block axis.' );
+
+</script>
+</body>
+</html>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html
new file mode 100644
index 0000000000..ee7ce90678
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/view-timeline-subject-size-changes.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html id="top">
+<meta charset="utf-8">
+<title>View timeline Subject size changes after creation of Animation</title>
+<link rel="help" href="https://drafts.csswg.org/scroll-animations-1/#viewtimeline-interface">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<script src="/scroll-animations/view-timelines/testcommon.js"></script>
+<style>
+ #container {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ height: 400px;
+ width: 400px;
+ }
+ .spacer {
+ height: 500px;
+ }
+ #target {
+ background-color: green;
+ height: 100px;
+ width: 100px;
+ }
+</style>
+<body>
+ <div id="container">
+ <div class="spacer"></div>
+ <div id="target"></div>
+ <div class="spacer"></div>
+ </div>
+</body>
+
+<script type="text/javascript">
+promise_test(async t => {
+ const options = {
+ timeline: { axis: 'y' },
+ animation: {
+ rangeStart: { rangeName: 'entry', offset: CSS.percent(0) },
+ rangeEnd: { rangeName: 'entry', offset: CSS.percent(100) },
+ // Set fill to accommodate floating point precision errors at the
+ // endpoints.
+ fill: 'both'
+ }
+ };
+
+ container.scrollTop = 0;
+ await waitForNextFrame();
+
+ const anim = CreateViewTimelineOpacityAnimation(t, target, options);
+ const timeline = anim.timeline;
+ anim.effect.updateTiming(options.timing);
+ await anim.ready;
+
+ // Advance to the start offset, which triggers entry to the active phase.
+ container.scrollTop = 100;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.3',
+ `Effect at the start of the active phase`);
+
+ // Advance to the midpoint of the animation.
+ container.scrollTop = 150;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range`);
+
+ // Since the height of the target is cut in half, the animation should be at the end now.
+ target.style.height = '50px';
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity, '0.7',
+ `Effect at the end of the active range`);
+
+ // Advance to the midpoint of the animation again.
+ container.scrollTop = 125;
+ await waitForNextFrame();
+ assert_equals(getComputedStyle(target).opacity,'0.5',
+ `Effect at the midpoint of the active range again`);
+
+ }, 'View timeline with subject size change after the creation of the animation');
+</script>
diff --git a/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html b/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html
new file mode 100644
index 0000000000..4eec5d8f13
--- /dev/null
+++ b/testing/web-platform/tests/scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#named-timeline-range">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/web-animations/testcommon.js"></script>
+<script src="support/testcommon.js"></script>
+<script src="/web-animations/resources/keyframe-utils.js"></script>
+<script src="/scroll-animations/scroll-timelines/testcommon.js"></script>
+<title>Animation range updates play state</title>
+</head>
+<style type="text/css">
+ @keyframes anim {
+ from { background-color: blue; }
+ to { background-color: white; }
+ }
+ #scroller {
+ border: 10px solid lightgray;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ width: 300px;
+ height: 200px;
+ }
+ #target {
+ margin-top: 800px;
+ margin-bottom: 800px;
+ margin-left: 10px;
+ margin-right: 10px;
+ width: 100px;
+ height: 100px;
+ z-index: -1;
+ background-color: green;
+ animation: anim auto linear;
+ animation-timeline: --t1;
+ view-timeline: --t1;
+ }
+</style>
+<body>
+ <div id="scroller">
+ <div id="target"></div>
+ </div>
+</body>
+<script type="text/javascript">
+ async function runTest() {
+ promise_test(async t => {
+ const anim = target.getAnimations()[0];
+ await anim.ready;
+
+ let duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(100),
+ 'Default duration is 100%');
+
+ // Start and end boundaries coincide.
+ anim.rangeStart = "entry 100%";
+ anim.rangeEnd = "contain 0%";
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is zero when boundaries coincide");
+
+ // Start > end, clamp at zero duration.
+ anim.rangeEnd = "entry 0%"
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is zero when start > end");
+
+ anim.rangeStart = "normal";
+ anim.rangeEnd = "normal";
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(100),
+ "Duration is 100% after range reset");
+
+ // Consumed 100% of timeline duration with delays
+ anim.effect.updateTiming({
+ delay: CSS.percent(60),
+ endDelay: CSS.percent(40)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is 0% after delays sum to 100%");
+
+ // Delays sum to > 100%
+ anim.effect.updateTiming({
+ delay: CSS.percent(60),
+ endDelay: CSS.percent(60)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(duration, CSS.percent(0),
+ "Duration is 0% after delays sum to > 100%");
+
+ anim.effect.updateTiming({
+ delay: CSS.percent(40),
+ endDelay: CSS.percent(40)
+ });
+ duration = anim.effect.getComputedTiming().duration;
+ assert_percents_equal(
+ duration, CSS.percent(20),
+ "Duration is 20% if normal range and delays sum to 80%");
+
+ }, 'Intrinsic iteration duration is non-negative');
+ }
+
+
+ window.onload = runTest;
+</script>