From 0ebe4ef619c3e48c86a85faf1c52a7e3756dbdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 19 Sep 2026 16:54:49 +0200 Subject: [PATCH] Animate generated pseudo elements (#676) --- .../css-animation-pseudo-elements-676-242.md | 54 +++ .../native/webscene_css_native_cascade.h | 8 + .../native/webscene_css_pseudo_application.h | 8 + .../native/webscene_css_transitions.h | 41 +- .../native/webscene_native_dom.h | 45 ++ .../native/webscene_native_dom_animations.inc | 396 +++++++++++++++++- .../native/webscene_native_dom_metrics.inc | 31 ++ .../native/webscene_native_dom_scene.inc | 97 ++++- .../webscene_v8_runtime_browser_apis.inc | 7 +- .../webscene_v8_runtime_css_cascade.inc | 18 + ...ative_v8_runtime_animation_cssom_tests.inc | 75 ++++ .../tests/native_v8_runtime_tests.cpp | 2 + tests/WebPlatformSubset/capabilities.json | 4 +- .../css-animation-pseudo-elements.html | 40 ++ .../webscene-component-profile.json | 7 + ...css-animation-pseudo-elements-profile.json | 27 ++ 16 files changed, 845 insertions(+), 15 deletions(-) create mode 100644 docs/validation/css-animation-pseudo-elements-676-242.md create mode 100644 tests/WebPlatformSubset/contracts/css-animation-pseudo-elements.html create mode 100644 tests/WebPlatformSubset/webscene-css-animation-pseudo-elements-profile.json diff --git a/docs/validation/css-animation-pseudo-elements-676-242.md b/docs/validation/css-animation-pseudo-elements-676-242.md new file mode 100644 index 000000000..d9f33210a --- /dev/null +++ b/docs/validation/css-animation-pseudo-elements-676-242.md @@ -0,0 +1,54 @@ +# CSS generated-pseudo animation source validation + +Issue #676 extends WebScene's retained, eight-track CSS animation runtime under +#242 to generated `::before` and `::after` boxes. The originating element owns +each bounded pseudo timeline and receives its animation event with the standard +`pseudoElement` identity. No pseudo node or public ABI surface is introduced. + +## Cascade and retained state + +Animation shorthand and longhands cascade into the existing generated-pseudo +style record. Each eligible generated box compiles its maximum eight tracks +once after cascade, using the same bounded opacity, two-dimensional translate, +scale, rotation, and filter keyframes as an ordinary element. The retained +signature includes name, list-coordinated timing, delay, iteration, direction, +fill, play state, and compiled stops. A changed signature restarts only that +pseudo track; removing its rule, `content`, generated box, or originating +element clears its contribution and queued completion. + +Sampling uses the host animation clock and the existing direction, fill, +pause, finite, fractional, infinite, and zero-time phase rules. Retained scene +commands bracket only that pseudo box for scale, rotation, and filters; +translation adjusts its retained box coordinates and animated opacity applies +to its generated paint. The principal element and the sibling pseudo box are +not included in those effect brackets. + +## Ownership, lifecycle, and bounds + +`animationend` dispatches exactly once on the originating element with +`pseudoElement` set to `::before` or `::after`. Identity mutation removes stale +queued pseudo events. Disconnected owners do not advance or request frames; +navigation and document teardown discard nodes, runtime tracks, and events. +Paused, completed, and fill-only pseudo tracks remain static without host-frame +demand. + +Cascade and keyframe compilation occur only when style changes. A frame scans +at most eight retained tracks for each of the two generated boxes. Sampling +does no selector matching, cascade, keyframe parsing, DOM construction, or +visual-tree mutation. Allocation metrics include pseudo track and filter +storage. + +## Evidence and deferred scope + +`test_generated_pseudo_animations_share_owner_lifecycle` covers a running +`::before`, paused `::after`, retained midpoint transform paint, exact owner and +`pseudoElement` completion data, static fill, rule/content removal, detach, and +settled frame demand. The browser contract checks the same before/after +midpoint and owner-event shape against standard browser semantics. + +Transitions on generated pseudo boxes, animationstart/animationiteration/ +animationcancel expansion, CSS Animations on other pseudo types, Web Animations +API pseudo targets, additive composition, and properties outside the existing +bounded retained keyframe set remain deferred. Under the fast implementation +policy, only `git diff --check` is executed. Native, browser, package, and +unchanged Code OSS acceptance remain unexecuted. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h index 8ec90dece..f17d4ef20 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h @@ -81,6 +81,14 @@ bool apply_native_cascade(native_document& document,dom_node& node, if(node.style.after_pseudo().generated && previous.after_pseudo().generated) node.style.mutable_after_pseudo().layout=previous.after_pseudo().layout; configure_keyframes(node.style,sheets.state().opacity_keyframes); + if(node.style.has_pseudo_elements()) { + auto& before=node.style.mutable_before_pseudo(); + auto& after=node.style.mutable_after_pseudo(); + if(before.has_animation_data()) + configure_keyframes(before.mutable_animations(),sheets.state().opacity_keyframes); + if(after.has_animation_data()) + configure_keyframes(after.mutable_animations(),sheets.state().opacity_keyframes); + } document.update_style_animations(node); } const bool layout_changed=!computed_layout_style_equal(previous,node.style); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_pseudo_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_pseudo_application.h index ed15bd968..61fbbb853 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_pseudo_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_pseudo_application.h @@ -1,6 +1,7 @@ #pragma once #include "webscene_css_pseudo_values.h" #include "webscene_css_text_values.h" +#include "webscene_css_transitions.h" #include "webscene_css_variables.h" namespace webscene_native::css { @@ -124,6 +125,13 @@ void apply_pseudo_declaration(dom_node& node,node_style::pseudo_element& pseudo, decision.classification = "supported"; return; } + if (name.starts_with("animation") + && apply_animation_property(pseudo.mutable_animations(), name, value)) { + decision.classification = "partially-supported"; + decision.semantic_slice = + "bounded generated-pseudo keyframes on the originating element"; + return; + } const auto result = css::apply_pseudo_value( pseudo, node.style.foreground_rgba, node.style.border_box, name, value); if (name == "font-size" && result.classification == "supported") { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h index d185c97b3..ee3c9371a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h @@ -420,7 +420,9 @@ inline bool apply_compiled_single_transition_shorthand( return true; } -inline void apply_animation_shorthand(node_style& style, const std::string& value) +inline void apply_animation_shorthand( + node_style::animation_data& animations, + const std::string& value) { std::vector names, durations, delays, timings, iterations; std::vector directions, fill_modes, play_states; @@ -477,7 +479,6 @@ inline void apply_animation_shorthand(node_style& style, const std::string& valu } return result; }; - auto& animations = style.mutable_animations(); animations.animation_name_value = names.empty() ? "none" : join(names); animations.animation_duration_value = durations.empty() ? "0s" : join(durations); animations.animation_delay_value = delays.empty() ? "0s" : join(delays); @@ -488,6 +489,31 @@ inline void apply_animation_shorthand(node_style& style, const std::string& valu animations.animation_play_state_value = play_states.empty() ? "running" : join(play_states); } +inline void apply_animation_shorthand(node_style& style, const std::string& value) +{ + apply_animation_shorthand(style.mutable_animations(), value); +} + +inline bool apply_animation_property( + node_style::animation_data& animations, + std::string_view name, + const std::string& value) +{ + if (name == "animation") apply_animation_shorthand(animations, value); + else if (name == "animation-name") animations.animation_name_value = value; + else if (name == "animation-duration") animations.animation_duration_value = value; + else if (name == "animation-delay") animations.animation_delay_value = value; + else if (name == "animation-timing-function") { + animations.animation_timing_function_value = value; + } else if (name == "animation-iteration-count") { + animations.animation_iteration_count_value = value; + } else if (name == "animation-direction") animations.animation_direction_value = value; + else if (name == "animation-fill-mode") animations.animation_fill_mode_value = value; + else if (name == "animation-play-state") animations.animation_play_state_value = value; + else return false; + return true; +} + inline std::string serialize_animation_shorthand( const node_style::animation_data& animations) { @@ -523,11 +549,9 @@ inline std::string serialize_animation_shorthand( return result; } -inline void configure_keyframes(node_style& style, +inline void configure_keyframes(node_style::animation_data& animations, const std::unordered_map& definitions) { - if (!style.has_animation_data()) return; - auto& animations = style.mutable_animations(); animations.keyframe_animations.clear(); const auto names = split_css_component_list(animations.animation_name_value, ','); if (names.empty()) return; @@ -638,6 +662,13 @@ inline void configure_keyframes(node_style& style, } } +inline void configure_keyframes(node_style& style, + const std::unordered_map& definitions) +{ + if (!style.has_animation_data()) return; + configure_keyframes(style.mutable_animations(), definitions); +} + inline void append_keyframe( css_opacity_keyframes& definition, std::string selector, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 9d9baf3b0..159c6ecc9 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -539,6 +539,35 @@ struct node_style final { bool align_self_specified{false}; bool border_box{false}; bool elliptical_border_radius{false}; + + const animation_data& animations() const noexcept + { + static const animation_data defaults; + return animation_state == nullptr ? defaults : *animation_state; + } + + animation_data& mutable_animations() + { + if (animation_state == nullptr) { + animation_state = std::make_shared(); + } else if (animation_state.use_count() != 1) { + animation_state = std::make_shared(*animation_state); + } + return *animation_state; + } + + bool has_animation_data() const noexcept + { + return animation_state != nullptr; + } + + const animation_data* animation_data_identity() const noexcept + { + return animation_state.get(); + } + + private: + std::shared_ptr animation_state; }; struct pseudo_element_pair final { @@ -1493,6 +1522,19 @@ struct dom_node final { bool filter_valid{false}; bool end_event_sent{false}; }; + struct pseudo_animation_runtime final { + std::vector keyframe_animations; + std::vector painted_filter_functions; + css_length painted_translation_x{0, length_unit::pixels}; + css_length painted_translation_y{0, length_unit::pixels}; + float painted_scale_x{1}; + float painted_scale_y{1}; + float painted_rotation_degrees{0}; + float painted_opacity{1}; + bool opacity_override{false}; + bool transform_override{false}; + bool filter_override{false}; + }; css_length painted_transform_translate_x{}; css_length painted_transform_translate_y{}; css_length transform_animation_from_translate_x{}; @@ -1558,6 +1600,8 @@ struct dom_node final { bool opacity_animation_active{false}; bool opacity_animation_start_event_sent{false}; std::vector keyframe_animations; + pseudo_animation_runtime before_pseudo; + pseudo_animation_runtime after_pseudo; bool keyframe_opacity_override{false}; bool keyframe_translation_override{false}; bool keyframe_scale_override{false}; @@ -2024,6 +2068,7 @@ class native_document final { std::string type; std::string animation_name; float elapsed_time_seconds{0}; + std::string pseudo_element; }; explicit native_document( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc index f635cd809..d9f08554c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc @@ -82,7 +82,13 @@ void native_document::signal_animation_frame(double timestamp_ms) noexcept void native_document::update_style_animations(dom_node& node) { - if (!node.has_animation_runtime() && !node.style.has_animation_data()) { + const auto has_pseudo_animation = node.style.has_pseudo_elements() + && ((node.style.before_pseudo().generated + && node.style.before_pseudo().has_animation_data()) + || (node.style.after_pseudo().generated + && node.style.after_pseudo().has_animation_data())); + if (!node.has_animation_runtime() && !node.style.has_animation_data() + && !has_pseudo_animation) { return; } active_animation_demand_cache_valid_ = false; @@ -298,7 +304,7 @@ void native_document::update_style_animations(dom_node& node) } if (keyframe_set_changed && animation_events_) { std::erase_if(*animation_events_, [&](const auto& event) { - return event.node_id == node.id; + return event.node_id == node.id && event.pseudo_element.empty(); }); } if (animation.keyframe_animations.size() > configured_keyframes.size()) { @@ -404,6 +410,132 @@ void native_document::update_style_animations(dom_node& node) } } + const auto update_pseudo_tracks = [&] ( + const node_style::pseudo_element& pseudo, + dom_node::animation_runtime_data::pseudo_animation_runtime& pseudo_runtime, + std::string_view pseudo_name) { + static const std::vector empty; + const auto& configured = pseudo.generated && !pseudo.display_none + && pseudo.has_animation_data() + ? pseudo.animations().keyframe_animations : empty; + auto set_changed = pseudo_runtime.keyframe_animations.size() != configured.size(); + auto phase_changed = set_changed; + for (size_t index = 0U; !set_changed && index < configured.size(); ++index) { + set_changed = pseudo_runtime.keyframe_animations[index].signature + != configured[index].signature; + } + for (size_t index = 0U; !phase_changed && index < configured.size(); ++index) { + const auto desired_pause = !configured[index].signature.empty() + && configured[index].play_state + == node_style::animation_data::play_kind::paused; + phase_changed = pseudo_runtime.keyframe_animations[index].fill_mode + != configured[index].fill_mode + || pseudo_runtime.keyframe_animations[index].paused != desired_pause; + } + if (phase_changed) { + last_animation_advance_timestamp_ms_ = + std::numeric_limits::quiet_NaN(); + } + if (set_changed && animation_events_) { + std::erase_if(*animation_events_, [&](const auto& event) { + return event.node_id == node.id + && event.pseudo_element == pseudo_name; + }); + } + pseudo_runtime.keyframe_animations.resize(configured.size()); + if (configured.empty()) { + pseudo_runtime.opacity_override = false; + pseudo_runtime.transform_override = false; + pseudo_runtime.filter_override = false; + pseudo_runtime.painted_filter_functions.clear(); + return; + } + for (size_t index = 0U; index < configured.size(); ++index) { + const auto& source = configured[index]; + auto& runtime = pseudo_runtime.keyframe_animations[index]; + const auto identity_changed = runtime.signature != source.signature; + const auto fill_changed = runtime.fill_mode != source.fill_mode; + const auto was_paused = runtime.paused; + const auto previous_pause_timestamp = runtime.paused_at_ms; + const auto desired_pause = !source.signature.empty() + && source.play_state == node_style::animation_data::play_kind::paused; + runtime.name = source.name; + runtime.fill_mode = source.fill_mode; + if (identity_changed) { + runtime.signature = source.signature; + runtime.started_ms = animation_frame_timestamp_ms_; + runtime.active = !source.signature.empty(); + runtime.filled = false; + runtime.end_event_sent = false; + runtime.filter_valid = false; + runtime.filter_keyframes.clear(); + runtime.filter_underlying.clear(); + runtime.painted_filter_functions.clear(); + runtime.painted_opacity = pseudo.opacity; + runtime.painted_translation_x = {0, length_unit::pixels}; + runtime.painted_translation_y = {0, length_unit::pixels}; + runtime.painted_scale_x = 1; + runtime.painted_scale_y = 1; + runtime.painted_rotation_degrees = 0; + if (!source.filter_keyframes.empty()) { + auto valid = source.filter_keyframes.size() >= 2U; + size_t longest = 0U; + for (const auto& filter_source : source.filter_keyframes) { + dom_node::animation_runtime_data::retained_filter_keyframe stop; + stop.offset = filter_source.offset; + valid = valid && parse_foreground_filters( + node, filter_source.value, stop.functions); + stop.authored_none = stop.functions.empty(); + runtime.filter_keyframes.push_back(std::move(stop)); + if (runtime.filter_keyframes.back().functions.size() + > runtime.filter_keyframes[longest].functions.size()) { + longest = runtime.filter_keyframes.size() - 1U; + } + } + if (valid) { + const auto canonical = + runtime.filter_keyframes[longest].functions; + for (auto& stop : runtime.filter_keyframes) { + auto expected = canonical; + if (!make_retained_filter_lists_compatible( + stop.functions, expected)) { + valid = false; + break; + } + } + } + runtime.filter_valid = valid; + if (source.opacity_keyframes.size() < 2U + && source.translation_keyframes.size() < 2U + && source.scale_keyframes.size() < 2U + && source.rotation_keyframes.size() < 2U && !valid) { + runtime.active = false; + } + } + } else if (fill_changed && !runtime.signature.empty()) { + runtime.active = true; + runtime.filled = false; + } + if (was_paused && (!desired_pause || identity_changed)) { + const auto held_duration = std::max( + 0.0, animation_frame_timestamp_ms_ - previous_pause_timestamp); + if (!identity_changed && runtime.active) { + runtime.started_ms += held_duration; + } + } + runtime.paused = desired_pause; + if (desired_pause && (!was_paused || identity_changed)) { + runtime.paused_at_ms = animation_frame_timestamp_ms_; + } else if (source.signature.empty()) { + runtime.paused_at_ms = 0; + } + } + }; + update_pseudo_tracks( + node.style.before_pseudo(), animation.before_pseudo, "::before"); + update_pseudo_tracks( + node.style.after_pseudo(), animation.after_pseudo, "::after"); + if (animation.opacity_animation_active && node.style.animations().transition_property_value == "none") { transition_events_.push_back({node.id,"transitioncancel","opacity", std::clamp(static_cast(animation_frame_timestamp_ms_- @@ -1369,6 +1501,249 @@ bool native_document::advance_animations() noexcept animation.painted_filter_functions.clear(); } } + + const auto advance_pseudo = [&] ( + const node_style::pseudo_element& pseudo, + dom_node::animation_runtime_data::pseudo_animation_runtime& pseudo_runtime, + std::string_view pseudo_name) { + if (!pseudo.generated || pseudo.display_none) { + pseudo_runtime.opacity_override = false; + pseudo_runtime.transform_override = false; + pseudo_runtime.filter_override = false; + pseudo_runtime.painted_filter_functions.clear(); + return; + } + const auto& configured_tracks = pseudo.animations().keyframe_animations; + auto opacity_override = false; + auto transform_override = false; + auto filter_override = false; + auto winning_opacity = pseudo.opacity; + auto winning_translation_x = css_length{0, length_unit::pixels}; + auto winning_translation_y = css_length{0, length_unit::pixels}; + auto winning_scale_x = 1.0F; + auto winning_scale_y = 1.0F; + auto winning_rotation = 0.0F; + const std::vector* winning_filter = nullptr; + for (size_t index = 0U; + index < pseudo_runtime.keyframe_animations.size() + && index < configured_tracks.size(); ++index) { + auto& runtime = pseudo_runtime.keyframe_animations[index]; + const auto& configured = configured_tracks[index]; + if (runtime.signature.empty()) continue; + auto contributes = false; + auto progress = 0.0F; + auto completed = false; + if (!runtime.active) { + contributes = runtime.filled; + } else { + const auto timestamp_ms = runtime.paused + ? runtime.paused_at_ms : animation_frame_timestamp_ms_; + const auto elapsed_ms = static_cast( + timestamp_ms - runtime.started_ms - configured.delay_ms); + if (elapsed_ms < 0) { + contributes = fills_before(configured.fill_mode); + if (contributes) { + progress = directed_keyframe_progress( + 0.0F, 0.0, configured.direction); + } + } else { + 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 || instantaneous) { + runtime.active = false; + runtime.filled = contributes; + completed = true; + } + } else { + contributes = true; + runtime.filled = false; + progress = active_keyframe_progress( + elapsed_ms, configured.duration_ms, + configured.direction); + } + } + } + const auto eased_segment = [&](float segment_progress) { + return timing_value( + segment_progress, + configured.x1, configured.y1, + configured.x2, configured.y2, + configured.timing_kind, + configured.step_count, + configured.step_position); + }; + if (contributes && configured.opacity_keyframes.size() >= 2U) { + const auto& stops = configured.opacity_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const node_style::opacity_keyframe& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) runtime.painted_opacity = right->opacity; + else if (right == stops.end()) { + runtime.painted_opacity = stops.back().opacity; + } else { + const auto& from = *(right - 1); + const auto span = std::max(0.0001F, right->offset - from.offset); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) / span, 0.0F, 1.0F)); + runtime.painted_opacity = from.opacity + + (right->opacity - from.opacity) * eased; + } + winning_opacity = runtime.painted_opacity; + opacity_override = true; + } + if (contributes && configured.translation_keyframes.size() >= 2U) { + const auto& stops = configured.translation_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const node_style::translation_keyframe& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_translation_x = right->x; + runtime.painted_translation_y = right->y; + } else if (right == stops.end()) { + runtime.painted_translation_x = stops.back().x; + runtime.painted_translation_y = stops.back().y; + } else { + const auto& from = *(right - 1); + const auto span = std::max(0.0001F, right->offset - from.offset); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) / span, 0.0F, 1.0F)); + runtime.painted_translation_x = interpolate_length( + from.x, right->x, eased); + runtime.painted_translation_y = interpolate_length( + from.y, right->y, eased); + } + winning_translation_x = runtime.painted_translation_x; + winning_translation_y = runtime.painted_translation_y; + transform_override = true; + } + if (contributes && configured.scale_keyframes.size() >= 2U) { + const auto& stops = configured.scale_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const node_style::scale_keyframe& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_scale_x = right->x; + runtime.painted_scale_y = right->y; + } else if (right == stops.end()) { + runtime.painted_scale_x = stops.back().x; + runtime.painted_scale_y = stops.back().y; + } else { + const auto& from = *(right - 1); + const auto span = std::max(0.0001F, right->offset - from.offset); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) / span, 0.0F, 1.0F)); + runtime.painted_scale_x = from.x + (right->x - from.x) * eased; + runtime.painted_scale_y = from.y + (right->y - from.y) * eased; + } + winning_scale_x = runtime.painted_scale_x; + winning_scale_y = runtime.painted_scale_y; + transform_override = true; + } + if (contributes && configured.rotation_keyframes.size() >= 2U) { + const auto& stops = configured.rotation_keyframes; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const node_style::rotation_keyframe& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) { + runtime.painted_rotation_degrees = right->degrees; + } else if (right == stops.end()) { + runtime.painted_rotation_degrees = stops.back().degrees; + } else { + const auto& from = *(right - 1); + const auto span = std::max(0.0001F, right->offset - from.offset); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) / span, 0.0F, 1.0F)); + runtime.painted_rotation_degrees = from.degrees + + (right->degrees - from.degrees) * eased; + } + winning_rotation = runtime.painted_rotation_degrees; + transform_override = true; + } + if (contributes && runtime.filter_valid + && runtime.filter_keyframes.size() >= 2U) { + const auto& stops = runtime.filter_keyframes; + const auto assign_stop = [&](const auto& stop) { + if (stop.authored_none) { + runtime.painted_filter_functions.clear(); + } else { + runtime.painted_filter_functions = stop.functions; + } + }; + auto right = std::lower_bound( + stops.begin(), stops.end(), progress, + [](const auto& stop, float value) { + return stop.offset < value; + }); + if (right == stops.begin()) assign_stop(*right); + else if (right == stops.end()) assign_stop(stops.back()); + else if (std::abs(progress - right->offset) < 0.000001F) { + assign_stop(*right); + } else { + const auto& from = *(right - 1); + const auto span = std::max(0.0001F, right->offset - from.offset); + const auto eased = eased_segment(std::clamp( + (progress - from.offset) / span, 0.0F, 1.0F)); + runtime.painted_filter_functions = from.functions; + for (size_t filter_index = 0U; + filter_index < runtime.painted_filter_functions.size(); + ++filter_index) { + const auto initial = from.functions[filter_index].amount; + runtime.painted_filter_functions[filter_index].amount = initial + + (right->functions[filter_index].amount - initial) * eased; + } + } + winning_filter = &runtime.painted_filter_functions; + filter_override = true; + } + advanced = advanced || (contributes && keyframe_paint_visible); + if (completed && !runtime.end_event_sent) { + if (!animation_events_) { + animation_events_ = + std::make_unique>(); + } + animation_events_->push_back({ + node.id, + "animationend", + runtime.name, + keyframe_active_duration_ms(configured) / 1000.0F, + std::string(pseudo_name)}); + runtime.end_event_sent = true; + } + } + pseudo_runtime.opacity_override = opacity_override; + pseudo_runtime.transform_override = transform_override; + pseudo_runtime.filter_override = filter_override; + pseudo_runtime.painted_opacity = winning_opacity; + pseudo_runtime.painted_translation_x = winning_translation_x; + pseudo_runtime.painted_translation_y = winning_translation_y; + pseudo_runtime.painted_scale_x = winning_scale_x; + pseudo_runtime.painted_scale_y = winning_scale_y; + pseudo_runtime.painted_rotation_degrees = winning_rotation; + if (filter_override && winning_filter != nullptr) { + pseudo_runtime.painted_filter_functions = *winning_filter; + } else { + pseudo_runtime.painted_filter_functions.clear(); + } + }; + advance_pseudo( + node.style.before_pseudo(), animation.before_pseudo, "::before"); + advance_pseudo( + node.style.after_pseudo(), animation.after_pseudo, "::after"); } // Opacity, colors, filters, scale, and rotation are paint-only. // Their interpolated values are consumed directly by build_scene(), so they @@ -1404,8 +1779,23 @@ bool native_document::has_active_animations() const noexcept animation->keyframe_animations.end(), [](const auto& track) { return track.active && !track.paused; }); + const auto pseudo_active = [](const auto& pseudo) { + return std::any_of( + pseudo.keyframe_animations.begin(), + pseudo.keyframe_animations.end(), [](const auto& track) { + return track.active && !track.paused; + }); + }; + const auto generated_pseudo_active = + (node->style.before_pseudo().generated + && !node->style.before_pseudo().display_none + && pseudo_active(animation->before_pseudo)) + || (node->style.after_pseudo().generated + && !node->style.after_pseudo().display_none + && pseudo_active(animation->after_pseudo)); return transition_active - || (keyframe_active && intersects_visible_paint_area(*node)); + || ((keyframe_active || generated_pseudo_active) + && intersects_visible_paint_area(*node)); }); active_animation_demand_cache_valid_ = true; return active_animation_demand_cache_; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index 41b4a3c4b..d43bbd47f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -76,6 +76,14 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c } if (node != nullptr && node->style.has_pseudo_elements()) { ++result.pseudo_element_pair_count; + if (node->style.before_pseudo().has_animation_data()) { + animation_allocations.insert( + node->style.before_pseudo().animation_data_identity()); + } + if (node->style.after_pseudo().has_animation_data()) { + animation_allocations.insert( + node->style.after_pseudo().animation_data_identity()); + } } if (node != nullptr && node->style.has_animation_data()) { animation_allocations.insert(node->style.animation_data_identity()); @@ -165,6 +173,29 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c * sizeof(retained_filter_function); } } + const auto count_pseudo_runtime = [&](const auto& pseudo) { + result.animation_runtime_storage_bytes += + pseudo.keyframe_animations.capacity() + * sizeof(dom_node::animation_runtime_data::keyframe_animation_runtime) + + pseudo.painted_filter_functions.capacity() + * sizeof(retained_filter_function); + for (const auto& track : pseudo.keyframe_animations) { + result.animation_runtime_storage_bytes += + track.name.capacity() + track.signature.capacity() + 2U + + track.filter_keyframes.capacity() + * sizeof(dom_node::animation_runtime_data::retained_filter_keyframe) + + track.filter_underlying.capacity() + * sizeof(retained_filter_function) + + track.painted_filter_functions.capacity() + * sizeof(retained_filter_function); + for (const auto& stop : track.filter_keyframes) { + result.animation_runtime_storage_bytes += stop.functions.capacity() + * sizeof(retained_filter_function); + } + } + }; + count_pseudo_runtime(animation->before_pseudo); + count_pseudo_runtime(animation->after_pseudo); } if (node == nullptr || !node->has_canvas_data()) continue; ++result.canvas_node_count; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc index 41cb72bc0..79645371e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -1568,7 +1568,20 @@ void native_document::append_scene( const auto pseudo_paints_in_foreground = paint_box_in_foreground || after_retained_canvas || (pseudo.z_index > 0 && pseudo.position != position_mode::normal); - const auto pseudo_opacity = std::clamp(pseudo.opacity, 0.0F, 1.0F); + const dom_node::animation_runtime_data::pseudo_animation_runtime* + pseudo_animation = nullptr; + if (animation != nullptr) { + if (&pseudo == &node.style.before_pseudo()) { + pseudo_animation = &animation->before_pseudo; + } else if (&pseudo == &node.style.after_pseudo()) { + pseudo_animation = &animation->after_pseudo; + } + } + const auto pseudo_opacity = std::clamp( + pseudo_animation != nullptr && pseudo_animation->opacity_override + ? pseudo_animation->painted_opacity : pseudo.opacity, + 0.0F, + 1.0F); if (pseudo_opacity <= 0.001F) return; const auto with_pseudo_opacity = [&](uint32_t rgba) { const auto alpha = static_cast(std::clamp( @@ -1586,10 +1599,75 @@ void native_document::append_scene( : pseudo.line_height >= 0 ? pseudo.line_height : resolved_line_height(node, font_size); - const auto x = pseudo.layout.x; - const auto y = pseudo.layout.y; + auto x = pseudo.layout.x; + auto y = pseudo.layout.y; const auto width = pseudo.layout.width; const auto height = pseudo.layout.height; + if (pseudo_animation != nullptr && pseudo_animation->transform_override) { + x += resolve_length( + node, pseudo_animation->painted_translation_x, width, 0.0F); + y += resolve_length( + node, pseudo_animation->painted_translation_y, height, 0.0F); + } + const auto animated_filters = pseudo_animation != nullptr + && pseudo_animation->filter_override + ? std::span{ + pseudo_animation->painted_filter_functions} + : std::span{}; + for (size_t filter_index = animated_filters.size(); filter_index > 0U; + --filter_index) { + const auto& filter = animated_filters[filter_index - 1U]; + commands.push_back(webscene_scene_command{ + 30U, + filter.flags, + x, + y, + width, + height, + 255U, + node.id, + 0, + 0, + 0, + 0, + filter.amount}); + } + const auto animated_scale = pseudo_animation != nullptr + && pseudo_animation->transform_override + && (std::abs(pseudo_animation->painted_scale_x - 1.0F) > 0.0001F + || std::abs(pseudo_animation->painted_scale_y - 1.0F) > 0.0001F); + const auto animated_rotation = pseudo_animation != nullptr + && pseudo_animation->transform_override + && std::abs(pseudo_animation->painted_rotation_degrees) > 0.0001F; + const auto transform_origin_x = x + width * 0.5F; + const auto transform_origin_y = y + height * 0.5F; + if (animated_scale) { + commands.push_back(webscene_scene_command{ + 15U, + 0U, + transform_origin_x, + transform_origin_y, + pseudo_animation->painted_scale_x, + pseudo_animation->painted_scale_y, + 0U, + node.id}); + } + if (animated_rotation) { + commands.push_back(webscene_scene_command{ + 19U, + 0U, + transform_origin_x, + transform_origin_y, + 0, + 0, + 0U, + node.id, + 0, + 0, + 0, + 0, + pseudo_animation->painted_rotation_degrees}); + } const auto pseudo_background = pseudo.background_current_color ? (pseudo.foreground_rgba & 0xFFU) != 0U ? pseudo.foreground_rgba @@ -1882,6 +1960,19 @@ void native_document::append_scene( 0, 0}); } + if (animated_rotation) { + commands.push_back(webscene_scene_command{ + 20U, 0U, 0, 0, 0, 0, 0U, node.id}); + } + if (animated_scale) { + commands.push_back(webscene_scene_command{ + 16U, 0U, 0, 0, 0, 0, 0U, node.id}); + } + for (size_t filter_index = 0U; filter_index < animated_filters.size(); + ++filter_index) { + commands.push_back(webscene_scene_command{ + 31U, 0U, x, y, width, height, 0U, node.id}); + } }; const auto radii = resolve_radii( node.style.border_top_left_radius, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc index 53d0c37e1..878e7839c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc @@ -1821,7 +1821,10 @@ } for (const auto& animation : animation_events) { auto* node = document.find_by_native_id(animation.node_id); - if (node == nullptr) continue; + if (node == nullptr + || (!animation.pseudo_element.empty() && !is_connected(*node))) { + continue; + } auto target = wrap_node(*node); auto event = v8::Object::New(isolate); event->Set(local_context, js_string(isolate, "type"), @@ -1835,7 +1838,7 @@ event->Set(local_context, js_string(isolate, "elapsedTime"), v8::Number::New(isolate, animation.elapsed_time_seconds)).Check(); event->Set(local_context, js_string(isolate, "pseudoElement"), - js_string(isolate, "")).Check(); + js_string(isolate, animation.pseudo_element.c_str())).Check(); v8::Local dispatcher; if (!target->Get(local_context, js_string(isolate, "dispatchEvent")) .ToLocal(&dispatcher) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc index bf3d892c7..bf38d5bed 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc @@ -158,6 +158,24 @@ if (track.filter_keyframes.size() >= 2U) record_composition("css-filter-keyframe-animation",node.id); } + const auto configure_pseudo = [&](node_style::pseudo_element& pseudo) { + if (!pseudo.has_animation_data()) return; + css::configure_keyframes(pseudo.mutable_animations(), opacity_keyframes); + for (const auto& track : pseudo.animations().keyframe_animations) { + if (track.opacity_keyframes.size() >= 2U) + record_composition("css-pseudo-opacity-keyframe-animation",node.id); + if (track.translation_keyframes.size() >= 2U + || track.scale_keyframes.size() >= 2U + || track.rotation_keyframes.size() >= 2U) + record_composition("css-pseudo-transform-keyframe-animation",node.id); + if (track.filter_keyframes.size() >= 2U) + record_composition("css-pseudo-filter-keyframe-animation",node.id); + } + }; + if (node.style.has_pseudo_elements()) { + configure_pseudo(node.style.mutable_before_pseudo()); + configure_pseudo(node.style.mutable_after_pseudo()); + } } void recompute_cascaded_line_height(dom_node& node,const std::vector& rules) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc index 85d96b237..b72d66561 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_animation_cssom_tests.inc @@ -1305,6 +1305,81 @@ void test_transform_keyframes_compose_retained_geometry_and_paint( engine, false, "detached transform keyframes retained frame demand"); } +void test_generated_pseudo_animations_share_owner_lifecycle(webscene_engine* engine) +{ + animation_frame_and_wait(engine, 16000.0, 7160U); + execute_and_wait(engine, R"JS( + (() => { + document.body.innerHTML = ` +
`; + globalThis.__pseudoEnds = []; + const owner = document.getElementById('pseudo-owner'); + owner.addEventListener('animationend', event => __pseudoEnds.push([ + event.animationName, event.pseudoElement, event.target === owner])); + void document.body.offsetHeight; + })() + )JS", "pseudo-animation-setup.js"); + + animation_frame_and_wait(engine, 16500.0, 7161U); + webscene_engine_request_scene_checkpoint(engine); + auto retained_midpoint = false; + for (auto attempt = 0; attempt < 100 && !retained_midpoint; ++attempt) { + const auto* scene = webscene_engine_acquire_latest_scene(engine); + if (scene != nullptr) { + if (scene->header.consumed_input_sequence >= 7161U) { + for (uint32_t index = 0U; index < scene->header.command_count; ++index) { + const auto& command = scene->commands[index]; + retained_midpoint = retained_midpoint || (command.kind == 15U + && std::abs(command.width - 1.5F) < 0.001F + && std::abs(command.height - 1.5F) < 0.001F); + } + } + webscene_scene_acknowledge(scene); + webscene_scene_release(scene); + } + if (!retained_midpoint) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + require(retained_midpoint, + "generated pseudo animation did not reach retained scene paint"); + + animation_frame_and_wait(engine, 17000.0, 7162U); + const auto completion = evaluate(engine, R"JS( + JSON.stringify(globalThis.__pseudoEnds) + )JS", "pseudo-animation-completion.js"); + require(completion == R"JSON([["pseudo-life","::before",true]])JSON", + "generated pseudo animationend did not belong to its originating element: " + + completion); + wait_for_animation_frame_demand( + engine, false, "filled and paused pseudo animations retained frame demand"); + + execute_and_wait(engine, R"JS( + (() => { + const owner = document.getElementById('pseudo-owner'); + owner.classList.add('suppressed'); + owner.remove(); + })() + )JS", "pseudo-animation-detach.js"); + animation_frame_and_wait(engine, 17100.0, 7163U); + wait_for_animation_frame_demand( + engine, false, "detached pseudo animations retained frame demand"); +} + void test_filter_keyframes_use_host_clock_and_retained_paint( webscene_engine* engine) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 2d91ad9a4..70ffd54e4 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -1143,6 +1143,7 @@ int main() test_multiple_animation_lists_coordinate_bounded_tracks(focused_engine); test_zero_time_animation_tracks_settle_without_frame_demand(focused_engine); test_transform_keyframes_compose_retained_geometry_and_paint(focused_engine); + test_generated_pseudo_animations_share_owner_lifecycle(focused_engine); webscene_engine_destroy(focused_engine); return 0; } @@ -1643,6 +1644,7 @@ int main() test_multiple_animation_lists_coordinate_bounded_tracks(engine); test_zero_time_animation_tracks_settle_without_frame_demand(engine); test_transform_keyframes_compose_retained_geometry_and_paint(engine); + test_generated_pseudo_animations_share_owner_lifecycle(engine); // This regression uses fixed host timestamps beginning at 2700 ms. Give it // a fresh timeline so earlier animation tests cannot make those frames stale. webscene_engine_destroy(engine); diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index b291d7fb7..cdbb955e8 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -97,10 +97,10 @@ }, { "family": "transitions-and-animation", - "required": ["host-frame-driven requestAnimationFrame", "transition longhands and delay", "four-number cubic-bezier timing", "translate, scale, rotate, opacity, foreground color, background color, supported compound filters, left and top interpolation", "background shorthand transition aliasing", "filter none identity interpolation", "logical inset transition aliasing", "intermediate computed style, paint and client geometry", "transition run/start/cancel/end events", "cancellation and reversal", "bounded paint samples", "dense bounded rotation cadence", "native opacity, translate, scale, rotate, and bounded filter keyframes", "native animation shorthand and longhands", "native staggered delay and infinite iteration", "single-animation normal, reverse, alternate, and alternate-reverse direction", "single-animation none, forwards, backwards, and both fill", "single-animation running and paused play state", "bounded comma-separated animation lists", "zero-duration and zero-iteration-count animation phases"], + "required": ["host-frame-driven requestAnimationFrame", "transition longhands and delay", "four-number cubic-bezier timing", "translate, scale, rotate, opacity, foreground color, background color, supported compound filters, left and top interpolation", "background shorthand transition aliasing", "filter none identity interpolation", "logical inset transition aliasing", "intermediate computed style, paint and client geometry", "transition run/start/cancel/end events", "cancellation and reversal", "bounded paint samples", "dense bounded rotation cadence", "native opacity, translate, scale, rotate, and bounded filter keyframes", "native animation shorthand and longhands", "native staggered delay and infinite iteration", "single-animation normal, reverse, alternate, and alternate-reverse direction", "single-animation none, forwards, backwards, and both fill", "single-animation running and paused play state", "bounded comma-separated animation lists", "zero-duration and zero-iteration-count animation phases", "bounded generated ::before and ::after animation lifecycle"], "candidate": ["innerHTML transition-longhand retention", "inline transition recascade and per-longhand importance", "computed transition times serialized in seconds", "synthetic click timer task ordering"], "productEvidence": ["calendar segmented-tab underline", "Code OSS Chat and session filter transitions", "Code OSS dictation brightness and incremental Chat blur keyframes", "automatically discovered three-dot loader animation", "automatically discovered rotating spinner", "animated toolbar and dialog state changes", "continuous live-resize chart layout"], - "coverage": ["contracts/segmented-transform-transition.html", "contracts/transform-none-transition.html", "contracts/paint-transitions-temporal.html", "contracts/css-filter-transition-temporal.html", "contracts/css-filter-transition-midpoint.html", "contracts/logical-inset-transition-cadence.html", "contracts/rotation-transition-cadence.html", "contracts/dynamic-transition-style-task-order.html", "contracts/css-opacity-keyframes-temporal.html", "contracts/css-animation-fill-mode-forwards.html", "contracts/css-animation-fill-mode-boundaries.html", "contracts/css-animation-direction.html", "contracts/css-animation-play-state.html", "contracts/css-animation-multiple-lists.html", "contracts/css-animation-zero-time.html", "contracts/css-animation-transform-components.html", "contracts/css-rotation-keyframes-cadence.html", "contracts/css-filter-keyframes-temporal.html", "contracts/css-filter-keyframes-midpoint.html", "CssTransformTransitionTests", "test_inline_transition_longhands_survive_dynamic_parse_and_recascade", "test_logical_inset_transition_smooths_throttled_pointer_updates", "test_filter_keyframes_use_host_clock_and_retained_paint", "test_animation_direction_maps_cycles_and_terminal_boundaries", "test_animation_fill_maps_before_after_and_preserves_timeline", "test_animation_play_state_holds_and_resumes_host_time", "test_multiple_animation_lists_coordinate_bounded_tracks", "test_zero_time_animation_tracks_settle_without_frame_demand", "test_transform_keyframes_compose_retained_geometry_and_paint", "native host-clock transition, retained filter paint, and keyframe regressions", "hosted component automatically discovered temporal checkpoints", "hosted component intermediate viewport-publication gate"], + "coverage": ["contracts/segmented-transform-transition.html", "contracts/transform-none-transition.html", "contracts/paint-transitions-temporal.html", "contracts/css-filter-transition-temporal.html", "contracts/css-filter-transition-midpoint.html", "contracts/logical-inset-transition-cadence.html", "contracts/rotation-transition-cadence.html", "contracts/dynamic-transition-style-task-order.html", "contracts/css-opacity-keyframes-temporal.html", "contracts/css-animation-fill-mode-forwards.html", "contracts/css-animation-fill-mode-boundaries.html", "contracts/css-animation-direction.html", "contracts/css-animation-play-state.html", "contracts/css-animation-multiple-lists.html", "contracts/css-animation-zero-time.html", "contracts/css-animation-transform-components.html", "contracts/css-animation-pseudo-elements.html", "contracts/css-rotation-keyframes-cadence.html", "contracts/css-filter-keyframes-temporal.html", "contracts/css-filter-keyframes-midpoint.html", "CssTransformTransitionTests", "test_inline_transition_longhands_survive_dynamic_parse_and_recascade", "test_logical_inset_transition_smooths_throttled_pointer_updates", "test_filter_keyframes_use_host_clock_and_retained_paint", "test_animation_direction_maps_cycles_and_terminal_boundaries", "test_animation_fill_maps_before_after_and_preserves_timeline", "test_animation_play_state_holds_and_resumes_host_time", "test_multiple_animation_lists_coordinate_bounded_tracks", "test_zero_time_animation_tracks_settle_without_frame_demand", "test_transform_keyframes_compose_retained_geometry_and_paint", "test_generated_pseudo_animations_share_owner_lifecycle", "native host-clock transition, retained filter paint, and keyframe regressions", "hosted component automatically discovered temporal checkpoints", "hosted component intermediate viewport-publication gate"], "gap": "Native is required to pass the bounded transform/opacity/foreground-color/background-color/filter transition slice, including events, cancellation, reversal, authored compound order, identity interpolation, paint, and cadence. Filter transitions and keyframes are limited to the retained blur, brightness, contrast, grayscale, and saturate functions and their existing list/value bounds. Bounded filter keyframes cover Code OSS dictation brightness and incremental Chat blur, including compatible compound order, none identities, finite fill, animationend, retained paint, and host-clock cleanup. The keyframe slice maps normal, reverse, alternate, and alternate-reverse through negative delays and finite, fractional, infinite, or zero iteration counts before opacity, bounded two-dimensional translate/scale/rotation, and bounded-filter sampling. Transform keyframes accept finite px/percentage translation, numeric scale, and the existing rotation grammar; compile each declaration into a replace-composed retained tuple, and publish its computed matrix, geometry, and scene paint without frame-time parsing. For positive-duration, nonzero-count animations, none, forwards, backwards, and both apply direction-aware fill boundaries to those supported properties without extending frame demand; positive-delay backwards fill samples its boundary without applying the between-keyframe timing function. Running and paused play state now hold and resume the same bounded host-clock timeline, including delay, direction, fill, supported-property paint, exactly-once completion, and zero paused frame demand. Up to eight comma-separated animations coordinate repeated longhand lists with independent clocks, fill, pause, ordered completion, and last-contributing replace composition for the supported properties. Zero duration or zero iteration count resolves a zero active duration with directed before/after fill, bounded positive or negative delay, ordered exactly-once completion, same-update mutation settlement, and no sustained frame demand. The candidate dynamic-style reduction passes Chrome and native and closes Bootstrap Modal's static-backdrop callback guard by retaining inline transition longhands through parser construction and recascade, honoring per-longhand importance, and exposing computed times in seconds. Arbitrary transform-list matrix multiplication, 3D and additive animation composition, other animated properties, animation start/iteration/cancel event expansion, more than eight live tracks per element, and the complete Web Animations API are not claimed." }, { diff --git a/tests/WebPlatformSubset/contracts/css-animation-pseudo-elements.html b/tests/WebPlatformSubset/contracts/css-animation-pseudo-elements.html new file mode 100644 index 000000000..fda7c2153 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-animation-pseudo-elements.html @@ -0,0 +1,40 @@ + + +Generated pseudo animations retain paint and dispatch on their originating element + +
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index d8cbdd7ae..daebdd6c3 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -1917,6 +1917,13 @@ "evidence": ["css-animation-transform-components-675-242"], "reason": "WPT-aligned bounded pixel and geometry reduction for a stable paused midpoint composed from retained translate, scale, and rotation keyframe components." }, + { + "path": "contracts/css-animation-pseudo-elements.html", + "type": "contract", + "capabilities": ["generated-before-after-animation", "pseudo-animation-retained-paint", "pseudo-animation-owner-event", "pseudo-animation-identity-cleanup", "pseudo-animation-settled-frame-demand"], + "evidence": ["css-animation-pseudo-elements-676-242"], + "reason": "WPT-aligned bounded reduction for generated ::before/::after keyframes, originating-element event ownership, retained paint, identity mutation, detach cleanup, and demand-free paused or filled state." + }, { "path": "contracts/css-filter-keyframes-temporal.html", "type": "contract", diff --git a/tests/WebPlatformSubset/webscene-css-animation-pseudo-elements-profile.json b/tests/WebPlatformSubset/webscene-css-animation-pseudo-elements-profile.json new file mode 100644 index 000000000..ed54dcb4f --- /dev/null +++ b/tests/WebPlatformSubset/webscene-css-animation-pseudo-elements-profile.json @@ -0,0 +1,27 @@ +{ + "profile": "webscene-css-animation-pseudo-elements-1", + "wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853", + "runtime": "v8", + "viewport": { + "width": 800, + "height": 600, + "deviceScaleFactor": 1 + }, + "required": [ + { + "path": "contracts/css-animation-pseudo-elements.html", + "type": "contract", + "capabilities": [ + "generated-before-after-animation", + "pseudo-animation-retained-paint", + "pseudo-animation-owner-event", + "pseudo-animation-identity-cleanup", + "pseudo-animation-settled-frame-demand" + ], + "evidence": ["css-animation-pseudo-elements-676-242"], + "reason": "WPT-aligned bounded reduction for generated ::before/::after keyframes, originating-element event ownership, retained paint, identity mutation, detach cleanup, and demand-free paused or filled state." + } + ], + "harnessBlocked": [], + "excluded": [] +}