Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/validation/css-animation-fill-mode-661-242.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ clear the override through the established runtime lifecycle.
- Existing keyframe-stop and compatible filter-list limits are unchanged.
- No timer, queue, visual-tree node, selector pass, or per-frame allocation is
added. Host timestamps and visibility/clip frame-demand gates are preserved.
- `animation-play-state`, zero-duration and zero-iteration fill, multiple
animations, translate/scale keyframes, pseudo-element animation, and the Web
Animations API remain deferred.
- Translate/scale keyframes, pseudo-element animation, animation
start/iteration/cancel events, and the Web Animations API remain deferred.
Play state, multiple lists, and zero-time fill are covered by #665, #672,
and #674.
- Unchanged Code OSS uses `both` for delayed Quick Input, action-widget, and Chat
entry animations. This slice prevents their supported opacity before-phase
flash. Their translate/scale keyframes remain outside the rotate-only bound.
Expand Down
7 changes: 4 additions & 3 deletions docs/validation/css-animation-multiple-lists-672-242.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ detach, and frame demand. The dedicated browser contract provides a WPT-aligned
observable reduction for coordination, ordering, serialization, and cleanup.

Additive/accumulate composition, `animation-composition`, Web Animations API
objects, more than eight live tracks, zero-duration event expansion, additional
animated properties, transition/animation compositing changes, and ABI or
AppScene changes remain deferred. Under the fast implementation policy, only
objects, more than eight live tracks, animation start/iteration/cancel event
expansion, additional animated properties, transition/animation compositing
changes, and ABI or AppScene changes remain deferred. Zero-duration/count end
semantics are covered by #674. Under the fast implementation policy, only
`git diff --check` is executed. Native, browser, package, and unchanged Code OSS
acceptance remain unexecuted.
8 changes: 4 additions & 4 deletions docs/validation/css-animation-play-state-665-242.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ node-owned hold with the established animation runtime lifecycle.
- Resume uses the existing visibility and paint-area gates. Opacity, rotation,
and filters remain paint-only and do not add selector, cascade, layout, scene
node, queue, or per-frame allocation work.
- The runtime still configures one animation and the first coordinated-list item.
- The runtime now configures up to eight coordinated tracks through #672.
Existing duration, iteration, keyframe-stop, and filter-list bounds remain.
- Zero-duration/zero-count behavior, multiple animations, translate/scale,
pseudo-elements, additional animation events, and Web Animations playback APIs
remain deferred.
- Translate/scale, pseudo-elements, animation start/iteration/cancel events, and
Web Animations playback APIs remain deferred. Zero-duration/count end
semantics are covered by #674.

## Evidence

Expand Down
63 changes: 63 additions & 0 deletions docs/validation/css-animation-zero-time-674-242.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Zero-time CSS animation source validation

Issue #674 completes the zero-duration and zero-iteration-count boundary for
WebScene's retained multi-track CSS animation runtime under #242. Both values are
valid CSS. Either makes active duration zero, including `0s × infinite`, while
the animation still has before and after phases and can dispatch completion.
The eight-track cap and coordinated-list rules from #672 remain unchanged.

## Phase and sampling contract

A positive delay retains the before phase. Backwards or both fill samples the
directed start of the first iteration; none or forwards contributes nothing.
Once the delay boundary is reached, the track settles immediately in its after
phase. A zero or negative delay reaches that phase in the same bounded host
update.

For zero iteration count, forwards or both fill also samples the directed start
of the first iteration because no iteration ran. For zero duration with a
positive finite or fractional count, forwards fill uses the terminal progress
for that count and direction. A zero-duration infinite count has zero active
duration; its terminal simple progress follows the Web Animations infinity rule,
with reverse mapped backwards and infinite alternate directions treated as
forwards. Timing is applied only when the resolved progress falls between
retained keyframe stops.

A paused positive delay remains held in the before phase and requests no frame.
Resuming preserves the unconsumed delay, then performs one instantaneous
settlement. A paused zero-time track whose delay boundary is already reached has
no active interval to hold and settles once. Filled results remain retained
without frame demand.

## Events, lifecycle, and bounds

- Every resolved supported track appends at most one `animationend`, with
`elapsedTime` equal to the zero active duration.
- Tracks completing in the same host update append records in animation-list
order. Fill or play-state mutations do not duplicate an already emitted end.
- Identity mutation rearms only that track. Removal, detach, navigation, and
teardown discard retained paint and queued stale records.
- Initial configuration and a zero-time CSSOM phase change invalidate the
duplicate-timestamp guard once, allowing settlement in the current host
update. Completed, filled, or paused tracks do not sustain frame demand.
- Sampling remains an eight-track retained scan. No selector matching, cascade,
layout, timer spin, or visual-tree reconstruction is added to the frame path.

The phase and progress rules align with CSS Animations Level 1 and the Web
Animations active-duration, overall-progress, simple-progress, and direction
algorithms.

## Evidence and deferred scope

`test_zero_time_animation_tracks_settle_without_frame_demand` covers zero
values in shorthand and longhand CSSOM, positive and fractional counts, zero
count, normal/reverse/alternate mapping, all relevant fill phases, a positive
delay, pause/resume, same-update mutation, ordered exactly-once completion,
removal, detach, and demand settlement. The dedicated browser contract provides
the corresponding WPT-aligned observable reduction.

Animation start/iteration/cancel event expansion, additive composition, Web
Animations API objects, more than eight live tracks, additional animated
properties, pseudo-elements, and ABI or AppScene changes remain deferred. Under
the fast implementation policy, only `git diff --check` is executed. Native,
browser, package, and unchanged Code OSS acceptance remain unexecuted.
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,11 @@ inline void configure_keyframes(node_style& style,
track.rotation_keyframes = definition->second.rotation_stops;
track.filter_keyframes = definition->second.filter_stops;
}
const auto runnable = track.duration_ms > 0 && track.iterations != 0
&& (track.opacity_keyframes.size() >= 2U
const auto has_supported_effect =
track.opacity_keyframes.size() >= 2U
|| track.rotation_keyframes.size() >= 2U
|| track.filter_keyframes.size() >= 2U);
if (runnable) {
|| track.filter_keyframes.size() >= 2U;
if (has_supported_effect) {
std::ostringstream signature;
signature << normalized_name << '|' << track.duration_ms << '|'
<< track.delay_ms << '|' << track.iterations << '|'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ float terminal_keyframe_progress(
node_style::animation_data::direction_kind direction) noexcept
{
const auto count = std::max(0.0, static_cast<double>(iterations));
if (count == 0.0) {
// A zero-count effect never enters its first iteration. Both backwards
// and forwards fill therefore use the start of that first iteration.
return directed_keyframe_progress(0.0F, 0.0, direction);
}
if (!std::isfinite(count)) {
// Zero-duration infinite effects have zero active duration. Web
// Animations resolves their after-phase simple progress to one; an
// infinite alternate iteration index is treated as forwards.
return direction == node_style::animation_data::direction_kind::reverse
? 0.0F : 1.0F;
}
const auto whole = std::floor(count);
const auto fractional = count - whole;
const auto progress = fractional > 0.0
Expand All @@ -38,6 +50,16 @@ float terminal_keyframe_progress(
return directed_keyframe_progress(progress, iteration, direction);
}

float keyframe_active_duration_ms(
const node_style::animation_data::keyframe_animation& animation) noexcept
{
if (animation.duration_ms <= 0 || animation.iterations <= 0) return 0;
if (!std::isfinite(animation.iterations)) {
return std::numeric_limits<float>::infinity();
}
return animation.duration_ms * animation.iterations;
}

bool fills_before(node_style::animation_data::fill_kind fill) noexcept
{
return fill == node_style::animation_data::fill_kind::backwards
Expand Down Expand Up @@ -251,11 +273,29 @@ void native_document::update_style_animations(dom_node& node)
const auto& configured_keyframes = node.style.animations().keyframe_animations;
auto keyframe_set_changed = animation.keyframe_animations.size()
!= configured_keyframes.size();
auto keyframe_phase_changed = keyframe_set_changed;
for (size_t index = 0U;
!keyframe_set_changed && index < configured_keyframes.size(); ++index) {
keyframe_set_changed = animation.keyframe_animations[index].signature
!= configured_keyframes[index].signature;
}
for (size_t index = 0U;
!keyframe_phase_changed && index < configured_keyframes.size(); ++index) {
const auto desired_pause = !configured_keyframes[index].signature.empty()
&& configured_keyframes[index].play_state
== node_style::animation_data::play_kind::paused;
keyframe_phase_changed =
animation.keyframe_animations[index].fill_mode
!= configured_keyframes[index].fill_mode
|| animation.keyframe_animations[index].paused != desired_pause;
}
if (keyframe_phase_changed) {
// Permit the host to settle a zero-length phase change again at the
// current timestamp; the ordinary duplicate-frame guard still applies
// once the updated state has been sampled.
last_animation_advance_timestamp_ms_ =
std::numeric_limits<double>::quiet_NaN();
}
if (keyframe_set_changed && animation_events_) {
std::erase_if(*animation_events_, [&](const auto& event) {
return event.node_id == node.id;
Expand Down Expand Up @@ -1083,18 +1123,19 @@ bool native_document::advance_animations() noexcept
0.0F, 0.0, configured.direction);
}
} else {
const auto duration = std::max(0.001F, configured.duration_ms);
const auto finite = std::isfinite(configured.iterations);
const auto total_duration = finite
? duration * std::max(0.0F, configured.iterations)
: 0.0F;
if (finite && elapsed_ms >= total_duration) {
const auto active_duration =
keyframe_active_duration_ms(configured);
const auto instantaneous = active_duration == 0;
if (instantaneous || elapsed_ms >= active_duration) {
contributes = fills_after(configured.fill_mode);
if (contributes) {
progress = terminal_keyframe_progress(
configured.iterations, configured.direction);
}
if (!runtime.paused) {
// A paused positive delay remains in its before phase.
// Once the zero-length active interval is reached,
// however, there is no interval left to hold.
if (!runtime.paused || instantaneous) {
runtime.active = false;
runtime.filled = contributes;
completed = true;
Expand All @@ -1103,7 +1144,8 @@ bool native_document::advance_animations() noexcept
contributes = true;
runtime.filled = false;
progress = active_keyframe_progress(
elapsed_ms, duration, configured.direction);
elapsed_ms, configured.duration_ms,
configured.direction);
}
}
}
Expand Down Expand Up @@ -1210,7 +1252,7 @@ bool native_document::advance_animations() noexcept
node.id,
"animationend",
runtime.name,
configured.duration_ms * configured.iterations / 1000.0F});
keyframe_active_duration_ms(configured) / 1000.0F});
runtime.end_event_sent = true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,140 @@ void test_multiple_animation_lists_coordinate_bounded_tracks(
engine, false, "detached animation lists retained host-frame demand");
}

void test_zero_time_animation_tracks_settle_without_frame_demand(
webscene_engine* engine)
{
animation_frame_and_wait(engine, 11000.0, 7130U);
execute_and_wait(engine, R"JS(
(() => {
document.body.innerHTML = `
<style>
@keyframes zero-fade { from { opacity: 0; transform: rotate(0);
filter: brightness(.5); }
to { opacity: 1; transform: rotate(180deg);
filter: brightness(1.5); } }
#delayed { animation: zero-fade 0s linear 500ms 2 reverse both; }
#zero-count { animation: zero-fade 1s linear 0s 0 reverse both; }
#fraction { animation: zero-fade 0s linear 0s 1.5 alternate both; }
#unfilled { opacity: .3; animation: zero-fade 0s linear; }
#negative { opacity: .3;
animation: zero-fade 0s linear -500ms 1 reverse both; }
#ordered { animation: zero-fade 0s linear both,
zero-fade 0s linear reverse both; }
#paused-now { animation: zero-fade 0s linear both paused; }
#paused-delay {
animation: zero-fade 0s linear 500ms 1 normal both paused; }
</style>
<div id="delayed"></div><div id="zero-count"></div>
<div id="fraction"></div><div id="unfilled"></div>
<div id="negative"></div>
<div id="ordered"></div><div id="paused-now"></div>
<div id="paused-delay"></div>`;
globalThis.__zeroEnds = [];
for (const node of document.querySelectorAll('div')) {
node.addEventListener('animationend', event => __zeroEnds.push({
id: node.id, name: event.animationName, elapsed: event.elapsedTime
}));
}
void document.body.offsetHeight;
})()
)JS", "animation-zero-setup.js");

animation_frame_and_wait(engine, 11250.0, 7131U);
const auto before = evaluate(engine, R"JS(
(() => {
const opacity = id => Number(getComputedStyle(
document.getElementById(id)).opacity);
const zeroCountStyle = getComputedStyle(
document.getElementById('zero-count'));
const matrix = zeroCountStyle.transform.slice(7, -1).split(',').map(Number);
const angle = Math.abs(Math.atan2(matrix[1], matrix[0]) * 180 / Math.PI);
const ordered = __zeroEnds.filter(event => event.id === 'ordered');
return Math.abs(opacity('delayed') - 1) < .001
&& Math.abs(opacity('zero-count') - 1) < .001
&& Math.abs(opacity('fraction') - .5) < .001
&& Math.abs(opacity('unfilled') - .3) < .001
&& Math.abs(opacity('negative')) < .001
&& Math.abs(opacity('paused-now') - 1) < .001
&& Math.abs(opacity('paused-delay') - 0) < .001
&& Math.abs(angle - 180) < .001
&& zeroCountStyle.filter === 'brightness(1.5)'
&& ordered.length === 2
&& ordered[0].name === 'zero-fade'
&& ordered[1].name === 'zero-fade'
&& __zeroEnds.every(event => event.elapsed === 0)
&& getComputedStyle(document.getElementById('zero-count'))
.animationIterationCount === '0'
&& getComputedStyle(document.getElementById('fraction'))
.animation.includes('0s');
})()
)JS", "animation-zero-before.js");
require(before == "true",
"zero duration/count did not settle directed fill and ordered completion");
wait_for_animation_frame_demand(
engine, true, "running zero-duration delay lost host-frame demand");

animation_frame_and_wait(engine, 11500.0, 7132U);
const auto delayed_complete = evaluate(engine, R"JS(
(() => {
const delayed = document.getElementById('delayed');
return Math.abs(Number(getComputedStyle(delayed).opacity)) < .001
&& __zeroEnds.filter(event => event.id === 'delayed').length === 1
&& __zeroEnds.filter(event => event.id === 'paused-delay').length === 0;
})()
)JS", "animation-zero-delay-complete.js");
require(delayed_complete == "true",
"zero-duration positive delay missed its instantaneous terminal phase");
wait_for_animation_frame_demand(
engine, false, "settled or paused zero-time tracks retained frame demand");

execute_and_wait(engine, R"JS(
(() => {
const paused = document.getElementById('paused-delay');
paused.style.animationPlayState = 'running';
return paused.style.animationPlayState;
})()
)JS", "animation-zero-resume-delay.js");
animation_frame_and_wait(engine, 11750.0, 7133U);
wait_for_animation_frame_demand(
engine, true, "resumed positive delay did not request its bounded frame");
animation_frame_and_wait(engine, 12000.0, 7134U);
const auto resumed = evaluate(engine, R"JS(
(() => {
const paused = document.getElementById('paused-delay');
return Math.abs(Number(getComputedStyle(paused).opacity) - 1) < .001
&& __zeroEnds.filter(event => event.id === 'paused-delay').length === 1;
})()
)JS", "animation-zero-resumed-result.js");
require(resumed == "true",
"resumed zero-duration delay did not settle exactly once");
wait_for_animation_frame_demand(
engine, false, "completed resumed zero-time track retained frame demand");

execute_and_wait(engine, R"JS(
(() => {
const node = document.getElementById('fraction');
node.style.animationIterationCount = '0';
document.getElementById('ordered').style.animation = 'none';
document.getElementById('zero-count').remove();
})()
)JS", "animation-zero-mutate-remove.js");
animation_frame_and_wait(engine, 12000.0, 7135U);
const auto mutation = evaluate(engine, R"JS(
(() => Math.abs(Number(getComputedStyle(
document.getElementById('fraction')).opacity)) < .001
&& getComputedStyle(document.getElementById('ordered')).opacity === '1')()
)JS", "animation-zero-mutate-result.js");
require(mutation == "true",
"same-timestamp zero-count mutation or removal left stale retained paint");

execute_and_wait(engine,
"document.body.innerHTML = '<div>zero-time animations detached</div>'",
"animation-zero-detach.js");
wait_for_animation_frame_demand(
engine, false, "detached zero-time animations retained frame demand");
}

void test_filter_keyframes_use_host_clock_and_retained_paint(
webscene_engine* engine)
{
Expand Down
Loading
Loading