From c73860823d5e0eaca8ef0fb7cc574ea815f7b2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 20 Sep 2026 00:20:43 +0200 Subject: [PATCH] Implement starting-style entry transitions --- ...css-starting-style-entry-transition-749.md | 45 +++++ .../native/html_parser/src/lib.rs | 1 + .../native/webscene_css_decoration_values.h | 4 + .../native/webscene_css_native_cascade.h | 53 +++++ .../native/webscene_css_property_mask.h | 1 + .../native/webscene_css_reset.h | 4 +- .../native/webscene_css_state.h | 8 + .../native/webscene_css_stylesheet_owner.h | 11 ++ .../native/webscene_css_stylesheet_sink.h | 15 ++ .../native/webscene_css_transitions.h | 45 +++-- .../native/webscene_native_dom.h | 26 +++ .../native/webscene_native_dom_animations.inc | 185 +++++++++++++++++- .../native/webscene_native_dom_metrics.inc | 1 + .../webscene_stylesheet_cssom_compatibility.h | 20 +- .../webscene_v8_runtime_css_cascade.inc | 48 +++++ .../webscene_v8_runtime_css_parsing.inc | 11 ++ .../native/webscene_v8_runtime_dom_core.inc | 2 +- .../native/webscene_v8_runtime_style.inc | 26 ++- .../tests/css_parser_tests.cpp | 18 ++ ...ative_v8_runtime_animation_cssom_tests.inc | 123 ++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + tests/NativeWeb/css_service.cpp | 103 ++++++++++ .../stylesheet-cssom.test.mjs | 16 ++ tests/WebPlatformSubset/capabilities.json | 8 + .../css-starting-style-entry-transition.html | 45 +++++ .../webscene-component-profile.json | 7 + 26 files changed, 802 insertions(+), 25 deletions(-) create mode 100644 docs/validation/css-starting-style-entry-transition-749.md create mode 100644 tests/WebPlatformSubset/contracts/css-starting-style-entry-transition.html diff --git a/docs/validation/css-starting-style-entry-transition-749.md b/docs/validation/css-starting-style-entry-transition-749.md new file mode 100644 index 000000000..c69707304 --- /dev/null +++ b/docs/validation/css-starting-style-entry-transition-749.md @@ -0,0 +1,45 @@ +# Starting-style entry transition contract (#749) + +WebScene retains document-level `@starting-style` as ordinary selector rules +marked with an internal, always-inactive media token. This reuses the existing +selector indexes, specificity, cascade layers, stylesheet ownership, mutation +invalidation, and teardown paths without maintaining a parallel rule engine. +Real media conditions surrounding a starting-style group remain attached and +are evaluated when an element enters the rendered state. + +When an element is first rendered or returns from `display:none`, WebScene builds +one temporary initial cascade from the normal and matching starting rules. The +combined list is sorted by normal layer, specificity, source and important +precedence before declarations are replayed. Only +an admitted opacity, 2D transform, or background-color transition copies values +into the existing retained transition machine. The temporary style is released +before the normal computed style is exposed. Static and already-rendered nodes +pay two bits each and do not allocate snapshots or request frames. + +`transition-behavior: allow-discrete` is retained per transition-list item. A +transition from a rendered display value to `none` keeps the used box until the +host-clock duration boundary while computed style reports the target `none`. +Cancellation, replacement, or a changed transition policy releases the deferred +state. Completion removes the box, dirties layout once, and stops frame demand. +A true DOM removal clears the subtree's before-change lifecycle and animation +runtime so reinsertion can enter again without detached frame demand. Atomic +connected-tree reparenting keeps the lifecycle generation and does not invent a +new entry. + +Coverage: + +- `webscene_css_parser_tests` proves the Rust syntax stream emits qualified + children inside `@starting-style`; +- `native_web_css_service` proves preparation, nested-media retention, initial + opacity/translate sampling, cascade precedence, midpoint/end values, discrete + display deferral, completion, re-entry, and zero animation-runtime allocations + across 256 unrelated nodes; +- `test_starting_style_entry_and_discrete_display_transitions` covers live + `CSSStartingStyleRule` identity and mutation, host-clock entry/display behavior, + removal/reinsertion, stylesheet teardown, and zero detached frame demand; +- `css-starting-style-entry-transition.html` is the browser/native candidate. + +This slice does not add broad discrete-property interpolation, view transitions, +or new margin/border transition machines. Those declarations still participate +in the temporary cascade but only already-supported transition properties are +admitted. diff --git a/experiments/WebScene.NativeEngine.Probe/native/html_parser/src/lib.rs b/experiments/WebScene.NativeEngine.Probe/native/html_parser/src/lib.rs index 1b0a95e82..af662018a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/html_parser/src/lib.rs +++ b/experiments/WebScene.NativeEngine.Probe/native/html_parser/src/lib.rs @@ -977,6 +977,7 @@ mod css_syntax { || prelude.name.eq_ignore_ascii_case("supports") || prelude.name.eq_ignore_ascii_case("layer") || prelude.name.eq_ignore_ascii_case("container") + || prelude.name.eq_ignore_ascii_case("starting-style") || prelude.name.eq_ignore_ascii_case("keyframes") || prelude.name.eq_ignore_ascii_case("-webkit-keyframes") { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_decoration_values.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_decoration_values.h index c483471cb..93690c70e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_decoration_values.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_decoration_values.h @@ -152,6 +152,10 @@ bool apply_decoration_value(dom_node& node,const std::string& name,const std::st node.style.mutable_animations().transition_timing_function_value = value; if (!defer_transition_configuration) configure_style_transitions(node.style); return true; + } else if (name == "transition-behavior") { + node.style.mutable_animations().transition_behavior_value = value; + if (!defer_transition_configuration) configure_style_transitions(node.style); + return true; } else if (name == "animation") { apply_animation_shorthand(node.style, value); decision.classification = "partially-supported"; 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 b0c7e5122..8421e51a1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_native_cascade.h @@ -87,6 +87,57 @@ bool apply_native_cascade(native_document& document,dom_node& node, } recompute_cascaded_line_height(node,cascade_order,variables); recompute_inline_font_relative_metrics(node); + configure_style_transitions(node.style); + document.update_discrete_display_transition(node,previous.display); + std::optional starting_style; + const auto rendered_after_change=node.style.display!=display_mode::none; + const auto entering_rendered_state=rendered_after_change + && (!node.css_cascade_initialized || !node.css_was_rendered); + if(entering_rendered_state) { + std::vector starting_rules; + for(const auto index:indices) { + if(index>=sheets.state().rules.size()) continue; + const auto& rule=sheets.state().rules[index]; + if(!sheets.starting_rule_media_matches(rule) + || rule.payload->pseudo_kind!=0U + || !query.matches_prepared(node,rule.compiled_selector())) continue; + starting_rules.push_back(&rule); + } + if(!starting_rules.empty()) { + auto after_change_style=node.style; + reset_cascaded_style( + node,variables,&sheets.state().registered_custom_properties); + auto initial_rules=matched.ordinary; + initial_rules.insert( + initial_rules.end(),starting_rules.begin(),starting_rules.end()); + std::sort( + initial_rules.begin(),initial_rules.end(), + [](const css_rule* left,const css_rule* right) { + return cascade_rule_precedes(left,right,false); + }); + const cascaded_rule_order initial_order(initial_rules); + apply_matched_declarations( + node,initial_order,[&](const css_declaration& declaration,bool inline_origin) { + property_result result; + if(declaration.name.starts_with("--")) { + apply_custom_property( + node,declaration, + &sheets.state().registered_custom_properties); + } else { + apply_declaration( + document,node,declaration,variables,inline_origin, + result,load_svg,[](bool) {},true); + } + }); + configure_style_transitions(node.style); + recompute_cascaded_line_height(node,initial_order,variables); + recompute_inline_font_relative_metrics(node); + starting_style=node.style; + node.style=std::move(after_change_style); + } + } + node.css_cascade_initialized=true; + node.css_was_rendered=rendered_after_change; bool backdrop_important=false; for_each_cascaded_pseudo_declaration( matched.pseudo, @@ -139,6 +190,8 @@ bool apply_native_cascade(native_document& document,dom_node& node, sheets.state().opacity_keyframes, sheets.state().registered_custom_properties); } + if(starting_style.has_value()) + document.prime_starting_style(node,*starting_style); 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_property_mask.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h index 49ea69054..184ffdada 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_property_mask.h @@ -204,6 +204,7 @@ inline uint64_t property_mask(std::string_view name) if (name == "transition-duration") return inline_transition_duration; if (name == "transition-delay") return inline_transition_delay; if (name == "transition-timing-function") return inline_transition_timing; + if (name == "transition-behavior") return inline_transition_property; if (name == "background") return inline_background | inline_background_image; if (name == "background-color" || name == "backgroundColor") { return inline_background; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index bd99250a4..ccea8ba7f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -254,13 +254,15 @@ inline void apply_all_unset( reset.outline_rgba = previous.outline_rgba; } if (has_inline({"transition", "transition-property", "transition-duration", - "transition-delay", "transition-timing-function"})) { + "transition-delay", "transition-timing-function", "transition-behavior"})) { auto& reset_animations = reset.mutable_animations(); reset_animations.transition_property_value = previous.animations().transition_property_value; reset_animations.transition_duration_value = previous.animations().transition_duration_value; reset_animations.transition_delay_value = previous.animations().transition_delay_value; reset_animations.transition_timing_function_value = previous.animations().transition_timing_function_value; + reset_animations.transition_behavior_value = + previous.animations().transition_behavior_value; configure_style_transitions(reset); } if (has_inline({"z-index"})) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_state.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_state.h index a3c7aaf26..8c9bb542b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_state.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_state.h @@ -12,6 +12,8 @@ #include namespace webscene_native::css { + inline constexpr std::string_view starting_style_media_marker = + "-webscene-starting-style"; struct compiled_css_selector_list; // Native stylesheet/cascade storage shared by runtime adapters and build tools. // No JavaScript handles, V8 headers or parser ownership belong in this model. @@ -244,6 +246,12 @@ struct compiled_css_selector final { { return payload->declarations; } + bool is_starting_style() const noexcept + { + return std::find( + payload->media_queries.begin(), payload->media_queries.end(), + starting_style_media_marker) != payload->media_queries.end(); + } const std::vector& media_queries() const noexcept { return payload->media_queries; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_owner.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_owner.h index 4e472ef4d..3875fbf78 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_owner.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_owner.h @@ -17,6 +17,13 @@ class stylesheet_owner { return std::all_of(rule.media_queries().begin(),rule.media_queries().end(), [&](const auto& query) { return css::media_matches(query,environment_); }); } + bool starting_rule_media_matches_internal(const css_rule& rule) const { + return std::all_of(rule.media_queries().begin(),rule.media_queries().end(), + [&](const auto& query) { + return query == starting_style_media_marker + || css::media_matches(query,environment_); + }); + } void rebuild() { state_=css_cascade_state{}; for(const auto& entry:sheets_) { @@ -58,6 +65,10 @@ class stylesheet_owner { } } public: + bool starting_rule_media_matches(const css_rule& rule) const { + return rule.is_starting_style() + && starting_rule_media_matches_internal(rule); + } // Replacing preserves sheet source order; newly attached sheets append. void replace(uint32_t id,prepared_stylesheet sheet) { for(auto& entry:sheets_) if(entry.id==id) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_sink.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_sink.h index 31adb98da..1133db71c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_sink.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_stylesheet_sink.h @@ -89,6 +89,17 @@ class stylesheet_sink final : public css_syntax_sink { current.prelude = trim_value(prelude); current.declarations.reserve(3U); current.children_active = false; + } else if (name == "starting-style" && has_block + && trim_value(prelude).empty() + && nearest_style_frame() == nullptr + && std::none_of(stack_.begin(), stack_.end(), [](const frame& ancestor) { + return ancestor.starting_style; + })) { + current.starting_style = true; + owner_.record_feature( + "css", "at-rule:@starting-style", "supported", + "bounded entry-transition grouping rules", + "stylesheet-parser"); } else if (name == "font-face") { current.children_active = false; owner_.record_feature( @@ -222,6 +233,9 @@ class stylesheet_sink final : public css_syntax_sink { if (!ancestor.media_query.empty()) { inherited_media.push_back(ancestor.media_query); } + if (ancestor.starting_style) { + inherited_media.emplace_back(starting_style_media_marker); + } } std::vector completed; completed.reserve(1U + current.nested_rules.size()); @@ -342,6 +356,7 @@ class stylesheet_sink final : public css_syntax_sink { bool children_active{false}; bool keyframes{false}; bool property_rule{false}; + bool starting_style{false}; }; static std::optional parse_typed_number( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h index 894b4e27c..5e61b831d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_transitions.h @@ -301,8 +301,10 @@ inline void configure_style_transitions(node_style& style) const auto delays = split_css_component_list(animations.transition_delay_value, ','); const auto timings = split_css_component_list( animations.transition_timing_function_value, ','); + const auto behaviors = split_css_component_list( + animations.transition_behavior_value, ','); const auto resolve = [&](std::string_view property) { - node_style::transition_timing result; + std::pair result; for (size_t index = 0; index < properties.size(); ++index) { auto candidate = ascii_lower(trim_value(properties[index])); // Transition matching uses the canonical physical property. @@ -313,26 +315,33 @@ inline void configure_style_transitions(node_style& style) else if (candidate == "background") candidate = "background-color"; if (candidate != property && candidate != "all") continue; if (!durations.empty()) { - result.duration_ms = std::max( + result.first.duration_ms = std::max( 0.0F, parse_css_time_ms(durations[index % durations.size()])); } if (!delays.empty()) { - result.delay_ms = parse_css_time_ms(delays[index % delays.size()]); + result.first.delay_ms = parse_css_time_ms(delays[index % delays.size()]); } if (!timings.empty()) { - parse_transition_timing(timings[index % timings.size()], result); + parse_transition_timing(timings[index % timings.size()], result.first); + } + if (!behaviors.empty()) { + result.second = ascii_lower(trim_value( + behaviors[index % behaviors.size()])) == "allow-discrete"; } return result; } return result; }; - animations.transform_transition = resolve("transform"); - animations.left_transition = resolve("left"); - animations.top_transition = resolve("top"); - animations.opacity_transition = resolve("opacity"); - animations.color_transition = resolve("color"); - animations.background_color_transition = resolve("background-color"); - animations.filter_transition = resolve("filter"); + animations.transform_transition = resolve("transform").first; + animations.left_transition = resolve("left").first; + animations.top_transition = resolve("top").first; + animations.opacity_transition = resolve("opacity").first; + animations.color_transition = resolve("color").first; + animations.background_color_transition = resolve("background-color").first; + animations.filter_transition = resolve("filter").first; + const auto display = resolve("display"); + animations.display_transition = display.first; + animations.display_transition_allow_discrete = display.second; } inline void apply_transition_shorthand( @@ -344,11 +353,13 @@ inline void apply_transition_shorthand( std::vector durations; std::vector delays; std::vector timings; + std::vector behaviors; for (const auto& item : split_css_component_list(value, ',')) { auto property = std::string("all"); auto duration = std::string("0s"); auto delay = std::string("0s"); auto timing = std::string("ease"); + auto behavior = std::string("normal"); auto saw_time = false; for (const auto& token : split_value_tokens(item)) { const auto lower = ascii_lower(token); @@ -360,7 +371,9 @@ inline void apply_transition_shorthand( || lower == "ease-out" || lower == "ease-in-out" || lower.starts_with("cubic-bezier(")) { timing = lower; - } else if (lower != "normal") { + } else if (lower == "normal" || lower == "allow-discrete") { + behavior = lower; + } else { property = lower; } } @@ -368,6 +381,7 @@ inline void apply_transition_shorthand( durations.push_back(std::move(duration)); delays.push_back(std::move(delay)); timings.push_back(std::move(timing)); + behaviors.push_back(std::move(behavior)); } const auto join = [](const std::vector& values) { std::string result; @@ -382,6 +396,7 @@ inline void apply_transition_shorthand( animations.transition_duration_value = join(durations); animations.transition_delay_value = join(delays); animations.transition_timing_function_value = join(timings); + animations.transition_behavior_value = join(behaviors); if (configure) configure_style_transitions(style); } @@ -396,6 +411,7 @@ inline bool apply_compiled_single_transition_shorthand( auto duration = std::string("0s"); auto delay = std::string("0s"); auto timing = std::string("ease"); + auto behavior = std::string("normal"); auto saw_time = false; for (const auto& component : specified.components) { const auto lower = ascii_lower(component.name); @@ -407,7 +423,9 @@ inline bool apply_compiled_single_transition_shorthand( || lower == "ease-out" || lower == "ease-in-out" || lower.starts_with("cubic-bezier(")) { timing = lower; - } else if (lower != "normal") { + } else if (lower == "normal" || lower == "allow-discrete") { + behavior = lower; + } else { property = lower; } } @@ -416,6 +434,7 @@ inline bool apply_compiled_single_transition_shorthand( animations.transition_duration_value = std::move(duration); animations.transition_delay_value = std::move(delay); animations.transition_timing_function_value = std::move(timing); + animations.transition_behavior_value = std::move(behavior); if (configure) configure_style_transitions(style); return true; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index ee8fc65c0..5b2dd6e2d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -241,6 +241,7 @@ struct node_style final { std::string transition_duration_value{"0s"}; std::string transition_delay_value{"0s"}; std::string transition_timing_function_value{"ease"}; + std::string transition_behavior_value{"normal"}; transition_timing transform_transition{}; transition_timing left_transition{}; transition_timing top_transition{}; @@ -248,6 +249,8 @@ struct node_style final { transition_timing color_transition{}; transition_timing background_color_transition{}; transition_timing filter_transition{}; + transition_timing display_transition{}; + bool display_transition_allow_discrete{false}; std::string animation_name_value{"none"}; std::string animation_duration_value{"0s"}; std::string animation_delay_value{"0s"}; @@ -1720,6 +1723,12 @@ struct dom_node final { bool filter_animation_active{false}; bool filter_animation_start_event_sent{false}; bool filter_animation_target_is_none{true}; + display_mode display_animation_target{display_mode::none}; + float display_animation_duration_ms{0}; + float display_animation_delay_ms{0}; + double display_animation_started_ms{0}; + bool display_animation_active{false}; + bool display_animation_start_event_sent{false}; }; uint32_t id{0}; @@ -1741,6 +1750,11 @@ struct dom_node final { // XML documents preserve qualified/tag and attribute name case. HTML nodes // continue to apply the ASCII case-insensitive name rules at the binding. bool xml_mode : 1 {false}; + // Entry-transition state is two bits on the node instead of an allocation + // on every static style. `css_was_rendered` changes only when the used + // display state reaches or leaves `none`. + bool css_cascade_initialized : 1 {false}; + bool css_was_rendered : 1 {false}; std::string tag; std::string id_attribute; std::string class_name; @@ -2002,6 +2016,14 @@ struct dom_node final { : style.opacity; } + display_mode computed_display_value() const noexcept + { + return animation_runtime_state != nullptr + && animation_runtime_state->display_animation_active + ? animation_runtime_state->display_animation_target + : style.display; + } + uint32_t painted_foreground_value() const noexcept { return animation_runtime_state != nullptr @@ -2326,6 +2348,10 @@ class native_document final { void mark_out_of_flow_geometry_dirty(dom_node& node) noexcept; bool can_reuse_client_geometry(const dom_node& node) const noexcept; void signal_animation_frame(double timestamp_ms) noexcept; + void reset_detached_style_state(dom_node& root); + void update_discrete_display_transition( + dom_node& node, display_mode previous_display); + void prime_starting_style(dom_node& node, const node_style& starting_style); void update_style_animations(dom_node& node); bool advance_animations() noexcept; bool has_active_animations() const noexcept; 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 95826a16c..a88549924 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_animations.inc @@ -295,6 +295,164 @@ void native_document::cancel_web_animations( mark_dirty(); } +void native_document::reset_detached_style_state(dom_node& root) +{ + // A true removal ends CSS transitions/animations and removes the element's + // before-change style. Atomic moves do not call this path, so reparenting a + // connected node preserves its transition generation and does not create a + // false @starting-style entry. + cancel_web_animations(root, true); + auto released_runtime = false; + const auto visit = [&](const auto& self, dom_node& node) -> void { + if (auto* animation = node.animation_runtime(); animation != nullptr) { + if (animation->display_animation_active) { + node.style.display = animation->display_animation_target; + } + node.animation_runtime_state.reset(); + released_runtime = true; + } + node.css_cascade_initialized = false; + node.css_was_rendered = false; + for (auto* child : node.children) { + if (child != nullptr) self(self, *child); + } + }; + visit(visit, root); + if (!released_runtime) return; + active_animation_demand_cache_valid_ = false; + last_animation_advance_timestamp_ms_ = + std::numeric_limits::quiet_NaN(); + mark_dirty(); +} + +void native_document::update_discrete_display_transition( + dom_node& node, + display_mode previous_display) +{ + const auto target = node.style.display; + auto* existing = node.animation_runtime(); + if (existing != nullptr && existing->display_animation_active) { + const auto retain_existing = target == display_mode::none + && node.style.has_animation_data() + && node.style.animations().display_transition_allow_discrete + && std::abs(node.style.animations().display_transition.duration_ms + - existing->display_animation_duration_ms) < 0.001F + && std::abs(node.style.animations().display_transition.delay_ms + - existing->display_animation_delay_ms) < 0.001F; + if (retain_existing) { + node.style.display = previous_display; + return; + } + transition_events_.push_back({ + node.id, "transitioncancel", "display", + std::clamp( + static_cast(animation_frame_timestamp_ms_ + - existing->display_animation_started_ms + - existing->display_animation_delay_ms), + 0.0F, existing->display_animation_duration_ms) / 1000.0F}); + existing->display_animation_active = false; + active_animation_demand_cache_valid_ = false; + } + if (!node.css_cascade_initialized + || previous_display == display_mode::none + || target != display_mode::none + || !node.style.has_animation_data()) return; + const auto& animations = node.style.animations(); + if (!animations.display_transition_allow_discrete + || animations.display_transition.duration_ms <= 0) return; + auto& runtime = node.mutable_animation_runtime(); + runtime.display_animation_target = target; + runtime.display_animation_duration_ms = + animations.display_transition.duration_ms; + runtime.display_animation_delay_ms = animations.display_transition.delay_ms; + runtime.display_animation_started_ms = animation_frame_timestamp_ms_; + runtime.display_animation_active = true; + runtime.display_animation_start_event_sent = + runtime.display_animation_delay_ms <= 0; + node.style.display = previous_display; + active_animation_demand_cache_valid_ = false; + transition_events_.push_back({node.id, "transitionrun", "display", 0}); + if (runtime.display_animation_start_event_sent) { + transition_events_.push_back({ + node.id, "transitionstart", "display", + std::min(-runtime.display_animation_delay_ms, + runtime.display_animation_duration_ms) / 1000.0F}); + } +} + +void native_document::prime_starting_style( + dom_node& node, + const node_style& starting_style) +{ + if (!node.style.has_animation_data()) return; + const auto length_equal = [](css_length left, css_length right) { + return left.unit == right.unit + && std::abs(left.value - right.value) < 0.001F + && std::abs(left.pixel_offset - right.pixel_offset) < 0.001F; + }; + const auto transform_differs = + !length_equal(starting_style.transform_translate_x, + node.style.transform_translate_x) + || !length_equal(starting_style.transform_translate_y, + node.style.transform_translate_y) + || std::abs(starting_style.transform_scale_x + - node.style.transform_scale_x) >= 0.001F + || std::abs(starting_style.transform_scale_y + - node.style.transform_scale_y) >= 0.001F + || std::abs(starting_style.transform_rotate_degrees + - node.style.transform_rotate_degrees) >= 0.001F; + const auto opacity_differs = std::abs( + starting_style.opacity - node.style.opacity) >= 0.0001F; + const auto starting_background = starting_style.background_current_color + ? starting_style.foreground_rgba : starting_style.background_rgba; + const auto target_background = node.style.background_current_color + ? node.style.foreground_rgba : node.style.background_rgba; + const auto background_differs = starting_background != target_background; + const auto& timings = node.style.animations(); + if ((!transform_differs || timings.transform_transition.duration_ms <= 0) + && (!opacity_differs || timings.opacity_transition.duration_ms <= 0) + && (!background_differs + || timings.background_color_transition.duration_ms <= 0)) return; + + active_animation_demand_cache_valid_ = false; + auto& animation = node.mutable_animation_runtime(); + if (transform_differs && timings.transform_transition.duration_ms > 0) { + animation.transform_animation_initialized = true; + animation.transform_animation_active = false; + animation.painted_transform_translate_x = + starting_style.transform_translate_x; + animation.painted_transform_translate_y = + starting_style.transform_translate_y; + animation.painted_transform_scale_x = starting_style.transform_scale_x; + animation.painted_transform_scale_y = starting_style.transform_scale_y; + animation.painted_transform_rotate_degrees = + starting_style.transform_rotate_degrees; + animation.transform_animation_target_translate_x = + starting_style.transform_translate_x; + animation.transform_animation_target_translate_y = + starting_style.transform_translate_y; + animation.transform_animation_target_scale_x = + starting_style.transform_scale_x; + animation.transform_animation_target_scale_y = + starting_style.transform_scale_y; + animation.transform_animation_target_degrees = + starting_style.transform_rotate_degrees; + } + if (opacity_differs && timings.opacity_transition.duration_ms > 0) { + animation.opacity_animation_initialized = true; + animation.opacity_animation_active = false; + animation.painted_opacity = starting_style.opacity; + animation.opacity_animation_target = starting_style.opacity; + } + if (background_differs + && timings.background_color_transition.duration_ms > 0) { + animation.background_color_animation_initialized = true; + animation.background_color_animation_active = false; + animation.painted_background_rgba = starting_background; + animation.background_color_animation_target_rgba = starting_background; + } +} + void native_document::update_style_animations(dom_node& node) { const auto has_pseudo_animation = node.style.has_pseudo_elements() @@ -1247,6 +1405,30 @@ bool native_document::advance_animations() noexcept continue; } auto& animation = *animation_pointer; + if (animation.display_animation_active) { + const auto elapsed_ms = static_cast( + animation_frame_timestamp_ms_ + - animation.display_animation_started_ms + - animation.display_animation_delay_ms); + if (elapsed_ms >= 0 + && !animation.display_animation_start_event_sent) { + animation.display_animation_start_event_sent = true; + transition_events_.push_back({ + node.id, "transitionstart", "display", 0}); + } + if (elapsed_ms >= animation.display_animation_duration_ms) { + node.style.display = animation.display_animation_target; + node.css_was_rendered = node.style.display != display_mode::none; + animation.display_animation_active = false; + transition_events_.push_back({ + node.id, "transitionend", "display", + animation.display_animation_duration_ms / 1000.0F}); + layout_changed = true; + advanced = true; + } else if (elapsed_ms >= 0) { + advanced = true; + } + } const auto keyframe_paint_visible = intersects_visible_paint_area(node); const auto advance_inset_transition = [&] ( const char* property_name, @@ -2313,7 +2495,8 @@ bool native_document::has_active_animations() const noexcept || animation->opacity_animation_active || animation->color_animation_active || animation->background_color_animation_active - || animation->filter_animation_active; + || animation->filter_animation_active + || animation->display_animation_active; const auto keyframe_active = std::any_of( animation->keyframe_animations.begin(), animation->keyframe_animations.end(), [](const auto& track) { 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 8b21e109d..abb163280 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -261,6 +261,7 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + string_bytes(animations->transition_duration_value) + string_bytes(animations->transition_delay_value) + string_bytes(animations->transition_timing_function_value) + + string_bytes(animations->transition_behavior_value) + string_bytes(animations->animation_name_value) + string_bytes(animations->animation_duration_value) + string_bytes(animations->animation_delay_value) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h index c3b949d17..c2e6e1f15 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_stylesheet_cssom_compatibility.h @@ -36,6 +36,7 @@ inline constexpr std::array cssCompatibilityScriptParts{R"J const supportsRuleInstances = new WeakSet(); const containerRuleInstances = new WeakSet(); const layerBlockRuleInstances = new WeakSet(); + const startingStyleRuleInstances = new WeakSet(); const layerStatementRuleInstances = new WeakSet(); const keyframesRuleInstances = new WeakSet(); const keyframeRuleInstances = new WeakSet(); @@ -119,6 +120,8 @@ inline constexpr std::array cssCompatibilityScriptParts{R"J 'CSSContainerRule', containerRuleInstances, CSSConditionRuleInterface); const CSSLayerBlockRuleInterface = interfaceConstructor( 'CSSLayerBlockRule', layerBlockRuleInstances, CSSGroupingRuleInterface); + const CSSStartingStyleRuleInterface = interfaceConstructor( + 'CSSStartingStyleRule', startingStyleRuleInstances, CSSGroupingRuleInterface); const CSSLayerStatementRuleInterface = interfaceConstructor( 'CSSLayerStatementRule', layerStatementRuleInstances, CSSRuleInterface); const CSSKeyframesRuleInterface = interfaceConstructor( @@ -145,6 +148,7 @@ inline constexpr std::array cssCompatibilityScriptParts{R"J ['CSSSupportsRule', CSSSupportsRuleInterface], ['CSSContainerRule', CSSContainerRuleInterface], ['CSSLayerBlockRule', CSSLayerBlockRuleInterface], + ['CSSStartingStyleRule', CSSStartingStyleRuleInterface], ['CSSLayerStatementRule', CSSLayerStatementRuleInterface], ['CSSKeyframesRule', CSSKeyframesRuleInterface], ['CSSKeyframeRule', CSSKeyframeRuleInterface], @@ -983,11 +987,13 @@ R"JS( source = source.slice(identifier[0].length).trim(); const supportsMatch = /^@supports(?:\s+([^\{]*?))?\s*\{/i.exec(parsed.cssText); const containerMatch = /^@container(?:\s+([^\{]*?))?\s*\{/i.exec(parsed.cssText); const layerBlockMatch = /^@layer(?:\s+([^\{]*?))?\s*\{/i.exec(parsed.cssText); + const startingStyleMatch = /^@starting-style\s*\{/i.exec(parsed.cssText); const keyframesSpec = parseKeyframesPrelude(parsed); const propertyMatch = /^@property\s+(--[-_a-zA-Z0-9]+)\s*\{/i.exec(parsed.cssText); const layerStatementNames = parsed.body === undefined ? parseLayerStatementNames(parsed.cssText) : undefined; - const groupingMatch = mediaMatch || supportsMatch || containerMatch || layerBlockMatch; + const groupingMatch = mediaMatch || supportsMatch || containerMatch + || layerBlockMatch || startingStyleMatch; let keyframeChildren = null; const ruleTarget = {}; const rule = keyframesSpec ? new Proxy(ruleTarget, { @@ -1041,6 +1047,10 @@ R"JS( if (importSpec) { groupingRuleInstances.add(rule); layerBlockRuleInstances.add(rule); Object.setPrototypeOf(rule, CSSLayerBlockRuleInterface.prototype); + } else if (startingStyleMatch) { + groupingRuleInstances.add(rule); + startingStyleRuleInstances.add(rule); + Object.setPrototypeOf(rule, CSSStartingStyleRuleInterface.prototype); } else if (keyframesSpec) { keyframesRuleInstances.add(rule); Object.setPrototypeOf(rule, CSSKeyframesRuleInterface.prototype); @@ -1397,19 +1407,21 @@ R"JS( Object.defineProperties(rule, { const isMedia = Boolean(mediaMatch); const isSupports = Boolean(supportsMatch); const isContainer = Boolean(containerMatch); + const isStartingStyle = Boolean(startingStyleMatch); let preludeText = isMedia ? parseMediaList(mediaMatch[1] || '').join(', ') : isSupports ? (supportsMatch[1] || '').trim() : isContainer ? (containerMatch[1] || '').trim() - : (layerBlockMatch[1] || '').trim(); + : isStartingStyle ? '' : (layerBlockMatch[1] || '').trim(); const attached = () => parent && (containingRule ? containingRule.cssRules && Array.from(containingRule.cssRules).includes(rule) : state.rules.includes(rule)); const serialize = () => { const keyword = isMedia ? 'media' - : isSupports ? 'supports' : isContainer ? 'container' : 'layer'; + : isSupports ? 'supports' : isContainer ? 'container' + : isStartingStyle ? 'starting-style' : 'layer'; cssText = `@${keyword}${preludeText ? ' ' + preludeText : ''} {${children.map(child => child.cssText).join('')}}`; }; const commitCondition = value => { @@ -1499,7 +1511,7 @@ R"JS( deleteRule: { writable: true, value(index) { descriptors.conditions = { enumerable: true, get: () => conditions }; - } else { + } else if (!isStartingStyle) { descriptors.name = { enumerable: true, get: () => preludeText }; } Object.defineProperties(rule, descriptors); 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 2b42f4cf9..6f1350f02 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 @@ -429,11 +429,56 @@ // full cascade, configure it once after every winning declaration has // landed instead of reparsing its four lists after each longhand. css::configure_style_transitions(node.style); + document.update_discrete_display_transition( + node, previous_style.display); // Inline relative line-height is authored at a higher origin, but its // computed pixel value depends on the winning font-size. Re-resolve it // only after stylesheet declarations have completed their cascade. css::recompute_cascaded_line_height(node, cascade_order, css_variables); recompute_inline_font_relative_metrics(node); + std::optional starting_style; + const auto rendered_after_change = node.style.display != display_mode::none; + const auto entering_rendered_state = rendered_after_change + && (!node.css_cascade_initialized || !node.css_was_rendered); + if (entering_rendered_state) { + std::vector starting_rules; + for (const auto index : candidates) { + if (index >= css_rules.size()) continue; + const auto& rule = css_rules[index]; + if (!starting_style_rule_media_matches(rule) + || rule.payload->pseudo_kind != 0U + || !css_rule_matches(node, rule)) continue; + starting_rules.push_back(&rule); + } + if (!starting_rules.empty()) { + auto after_change_style = node.style; + css::reset_cascaded_style( + node, css_variables, ®istered_custom_properties); + auto initial_rules = matched_rules; + initial_rules.insert( + initial_rules.end(), starting_rules.begin(), starting_rules.end()); + std::sort( + initial_rules.begin(), initial_rules.end(), + [](const css_rule* left, const css_rule* right) { + return css::cascade_rule_precedes(left, right, false); + }); + const css::cascaded_rule_order initial_order(initial_rules); + css::apply_matched_declarations( + node, initial_order, + [&](const css_declaration& declaration, bool inline_origin) { + apply_css_declaration( + node, declaration, inline_origin, true); + }); + css::configure_style_transitions(node.style); + css::recompute_cascaded_line_height( + node, initial_order, css_variables); + recompute_inline_font_relative_metrics(node); + starting_style = node.style; + node.style = std::move(after_change_style); + } + } + node.css_cascade_initialized = true; + node.css_was_rendered = rendered_after_change; // Pseudo-elements inherit the originating element's completed // computed style. Apply them only after its custom and ordinary // declarations so `::before { background:var(--active-bg) }` resolves @@ -488,6 +533,9 @@ : std::chrono::steady_clock::time_point{}; #endif configure_style_keyframe_animation(node); + if (starting_style.has_value()) { + document.prime_starting_style(node, *starting_style); + } document.update_style_animations(node); detect_css_compositions(node); #if defined(WEBSCENE_NATIVE_ENGINE_CERTIFICATION) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc index 6540f901a..bb29d5fef 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_parsing.inc @@ -542,6 +542,17 @@ [this](const std::string& query) { return media_query_matches(query); }); } + bool starting_style_rule_media_matches(const css_rule& rule) const + { + return rule.is_starting_style() + && std::all_of( + rule.media_queries().begin(), rule.media_queries().end(), + [this](const std::string& query) { + return query == css::starting_style_media_marker + || media_query_matches(query); + }); + } + static std::shared_ptr intern_css_rule_payload( std::string selector, const std::vector& declarations, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 214506068..0b9aa976d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -1841,7 +1841,7 @@ void register_detached_subtree(dom_node& root) { if (&root == &document.body() || root.parent != nullptr) return; - document.cancel_web_animations(root, true); + document.reset_detached_style_state(root); // Detached leaves are the common text/topology case. A leaf cannot // contain an existing root and, after unlinking, cannot still be // contained by one. Index it directly so repeated split/normalize diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc index 2fc7cac72..fda2b41f8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -272,12 +272,14 @@ || name == "transition-property" || name == "transitionDuration" || name == "transition-duration" || name == "transitionDelay" || name == "transition-delay" || name == "transitionTimingFunction" - || name == "transition-timing-function") { + || name == "transition-timing-function" || name == "transitionBehavior" + || name == "transition-behavior") { auto& animations = node.style.mutable_animations(); animations.transition_property_value = "all"; animations.transition_duration_value = "0s"; animations.transition_delay_value = "0s"; animations.transition_timing_function_value = "ease"; + animations.transition_behavior_value = "normal"; configure_style_transitions(node.style); const auto has_authored_transition = std::any_of( node.authored_style().declarations.begin(), @@ -970,6 +972,10 @@ node->style.mutable_animations().transition_timing_function_value = value; configure_style_transitions(node->style); node->style.inline_property_mask |= inline_transition_timing; + } else if (name == "transition-behavior") { + node->style.mutable_animations().transition_behavior_value = value; + configure_style_transitions(node->style); + node->style.inline_property_mask |= inline_transition_property; } else if (name == "opacity") { node->style.opacity = std::clamp(std::strtof(value.c_str(), nullptr), 0.0F, 1.0F); node->style.inline_property_mask |= inline_opacity; @@ -1446,6 +1452,8 @@ : node->style.animations().transition_delay_value; } else if (name == "transition-timing-function") { value = node->style.animations().transition_timing_function_value; + } else if (name == "transition-behavior") { + value = node->style.animations().transition_behavior_value; } else if (name == "animation-fill-mode") { value = node->style.animations().animation_fill_mode_value; } else if (name == "animation-play-state") { @@ -1796,9 +1804,9 @@ ? "none" : format_computed_length(node->style.max_height, node->layout.height); } else if (name == "display") { - value = display_mode_name(computed - ? blockified_display(*node) - : node->style.display); + const auto display = node->computed_display_value(); + value = display_mode_name(computed && display != display_mode::none + ? blockified_display(*node) : display); } else if (name == "direction") { value = "ltr"; } else if (name == "position") { @@ -2038,7 +2046,8 @@ value = node->style.animations().transition_property_value + " " + node->style.animations().transition_duration_value + " " + node->style.animations().transition_timing_function_value + " " - + node->style.animations().transition_delay_value; + + node->style.animations().transition_delay_value + " " + + node->style.animations().transition_behavior_value; } else if (name == "transitionProperty" || name == "transition-property") { value = node->style.animations().transition_property_value; } else if (name == "transitionDuration" || name == "transition-duration") { @@ -2052,6 +2061,9 @@ } else if (name == "transitionTimingFunction" || name == "transition-timing-function") { value = node->style.animations().transition_timing_function_value; + } else if (name == "transitionBehavior" + || name == "transition-behavior") { + value = node->style.animations().transition_behavior_value; } else if (name == "animation") { value = css::serialize_animation_shorthand(node->style.animations()); } else if (name == "animationName" || name == "animation-name") { @@ -2569,6 +2581,10 @@ node->style.mutable_animations().transition_timing_function_value = value; configure_style_transitions(node->style); node->style.inline_property_mask |= inline_transition_timing; + } else if (name == "transitionBehavior") { + node->style.mutable_animations().transition_behavior_value = value; + configure_style_transitions(node->style); + node->style.inline_property_mask |= inline_transition_property; } else if (name == "animation") { apply_animation_shorthand(node->style, value); } else if (name == "animationName") { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/css_parser_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/css_parser_tests.cpp index f9f957fe7..70a9cf1ce 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/css_parser_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/css_parser_tests.cpp @@ -122,6 +122,23 @@ void property_descriptors_stream_as_declarations() "@property descriptor names preserve source order"); } +void starting_style_streams_nested_rules() +{ + const auto parsed = parse_css_syntax_stylesheet(R"CSS( + @starting-style { + .entry { opacity: 0; transform: translateX(-6px); } + } + )CSS"); + require(static_cast(parsed), parsed.error); + require(parsed.rules.size() == 2U + && parsed.rules[0].name == "starting-style" + && parsed.rules[1].kind == css_syntax_style_rule + && parsed.rules[1].parent_index == 0U, + "@starting-style streams its qualified-rule children"); + require(parsed.declarations.size() == 2U, + "@starting-style child declarations reach the stylesheet sink"); +} + class direct_sink final : public css_syntax_sink { public: bool begin_rule( @@ -212,6 +229,7 @@ int main() stylesheet_structure(); invalid_utf8_is_rejected(); property_descriptors_stream_as_declarations(); + starting_style_streams_nested_rules(); direct_streaming_sink(); nested_style_rule_stream(); std::cout << "CSS parser tests passed\n"; 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 f92c29d2e..bca9bfc7e 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 @@ -559,6 +559,129 @@ void test_registered_custom_property_keyframes_update_dependent_paint() webscene_engine_destroy(engine); } +void test_starting_style_entry_and_discrete_display_transitions() +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "starting-style engine creation failed"); + execute_and_wait(engine, R"JS( + (() => { + document.body.innerHTML = ` + +
`; + })() + )JS", "starting-style-setup.js"); + const auto reflected = evaluate(engine, R"JS( + (() => { + const rule = document.getElementById('entry-sheet').sheet.cssRules[2]; + const dynamic = new CSSStyleSheet(); + dynamic.replaceSync('@starting-style { .first { opacity: 0; } }'); + dynamic.cssRules[0].deleteRule(0); + dynamic.cssRules[0].insertRule('.replacement { opacity: .5; }'); + return [rule.constructor.name, rule instanceof CSSStartingStyleRule, + rule.cssRules.length, dynamic.cssRules[0].cssRules[0].selectorText]; + })() + )JS", "starting-style-cssom.js"); + require( + reflected == R"(["CSSStartingStyleRule",true,1,".replacement"])", + "CSSStartingStyleRule identity/mutation diverged: " + reflected); + + const auto initial = evaluate(engine, R"JS( + (() => { + const style = getComputedStyle(document.getElementById('entry')); + return [style.opacity, style.transform]; + })() + )JS", "starting-style-initial.js"); + require(initial.find(R"("0")") != std::string::npos + && initial.find("-6") != std::string::npos, + "entry transition did not begin at @starting-style: " + initial); + animation_frame_and_wait(engine, 125.0, 7491U); + const auto midpoint = evaluate(engine, R"JS( + (() => { + const style = getComputedStyle(document.getElementById('entry')); + return [style.opacity, style.transform]; + })() + )JS", "starting-style-midpoint.js"); + require(midpoint.find("0.5") != std::string::npos + && midpoint.find("-3") != std::string::npos, + "entry transition midpoint diverged: " + midpoint); + animation_frame_and_wait(engine, 250.0, 7492U); + execute_and_wait(engine, R"JS( + document.getElementById('entry').classList.add('hidden') + )JS", "starting-style-hide.js"); + const auto deferred = evaluate(engine, R"JS( + (() => { + const entry = document.getElementById('entry'); + return [getComputedStyle(entry).display, entry.offsetWidth > 0]; + })() + )JS", "starting-style-deferred-display.js"); + require(deferred == R"(["none",true])", + "allow-discrete did not defer used display: " + deferred); + animation_frame_and_wait(engine, 510.0, 7493U); + require(evaluate(engine, R"JS( + document.getElementById('entry').offsetWidth + )JS", "starting-style-display-end.js") == "0", + "display did not become none at the discrete transition boundary"); + execute_and_wait(engine, R"JS( + document.getElementById('entry').classList.remove('hidden') + )JS", "starting-style-restore.js"); + const auto restored = evaluate(engine, R"JS( + (() => { + const style = getComputedStyle(document.getElementById('entry')); + return [style.opacity, style.transform]; + })() + )JS", "starting-style-restored-entry.js"); + require(restored.find(R"("0")") != std::string::npos + && restored.find("-6") != std::string::npos, + "display:none restoration did not re-enter @starting-style: " + restored); + execute_and_wait(engine, R"JS( + globalThis.__startingStyleDetachedEntry = document.getElementById('entry'); + __startingStyleDetachedEntry.remove() + )JS", "starting-style-remove.js"); + require(webscene_engine_requires_animation_frame(engine) == 0U, + "removed entry transition retained host-frame demand"); + execute_and_wait(engine, R"JS( + document.body.appendChild(globalThis.__startingStyleDetachedEntry) + )JS", "starting-style-reinsert.js"); + const auto reinserted = evaluate(engine, R"JS( + (() => { + const style = getComputedStyle(globalThis.__startingStyleDetachedEntry); + return [style.opacity, style.transform]; + })() + )JS", "starting-style-reinserted-entry.js"); + require(reinserted.find(R"("0")") != std::string::npos + && reinserted.find("-6") != std::string::npos, + "removed entry did not restart from @starting-style: " + reinserted); + execute_and_wait(engine, R"JS( + document.getElementById('entry-sheet').remove() + )JS", "starting-style-sheet-remove.js"); + require(webscene_engine_requires_animation_frame(engine) == 0U, + "starting-style stylesheet teardown retained host-frame demand"); + const auto after_teardown = evaluate(engine, R"JS( + (() => { + const entry = globalThis.__startingStyleDetachedEntry; + entry.remove(); + document.body.appendChild(entry); + const style = getComputedStyle(entry); + return [style.opacity, style.transform]; + })() + )JS", "starting-style-after-teardown.js"); + require(after_teardown.find(R"("1")") != std::string::npos + && after_teardown.find("none") != std::string::npos, + "removed @starting-style stylesheet remained active: " + after_teardown); + webscene_engine_destroy(engine); +} + void test_rotation_keyframes_use_host_clock_and_wrap_continuously( 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 b0bda0d88..c9ac6c7bc 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -1655,6 +1655,7 @@ int main() test_inline_transition_longhands_survive_dynamic_parse_and_recascade(engine); test_opacity_keyframes_use_host_clock_with_staggered_infinite_delays(engine); test_registered_custom_property_keyframes_update_dependent_paint(); + test_starting_style_entry_and_discrete_display_transitions(); test_rotation_keyframes_use_host_clock_and_wrap_continuously(engine); test_filter_keyframes_use_host_clock_and_retained_paint(engine); test_clipped_offscreen_keyframes_do_not_keep_host_frame_clock_alive(engine); diff --git a/tests/NativeWeb/css_service.cpp b/tests/NativeWeb/css_service.cpp index c2a75429c..bb68ef6ed 100644 --- a/tests/NativeWeb/css_service.cpp +++ b/tests/NativeWeb/css_service.cpp @@ -1441,5 +1441,108 @@ int main(int argc,char** argv) { ==std::string::npos) return 181; if(!typed_sheets.remove(748) || !typed_sheets.state().registered_custom_properties.empty()) return 182; + auto starting_sheet=webscene_native::css::prepare_stylesheet(R"CSS( + .entry { + opacity: 1; + transform: translateX(0px); + transition: opacity 1000ms linear, transform 1000ms linear, + display 260ms allow-discrete; + } + .entry.hidden { display: none; transform: translateX(-10px); } + .precedence { opacity: 1; transition: opacity 1000ms linear; } + @starting-style { + .entry { + opacity: 0; + transform: translateX(-10px); + } + div { opacity: 0.25; } + } + @media (min-width: 1px) { + @starting-style { .nested-only { opacity: 0; } } + } + )CSS","asset://app/starting.css",[](const auto&) {return true;}); + const auto starting_rule_count=starting_sheet?std::count_if( + starting_sheet->rules.begin(),starting_sheet->rules.end(), + [](const auto& rule) { + return std::find( + rule->media_queries.begin(),rule->media_queries.end(), + webscene_native::css::starting_style_media_marker) + !=rule->media_queries.end(); + }):0U; + if(!starting_sheet || starting_rule_count!=3U) { + std::cerr<<"starting-style preparation rules=" + <<(starting_sheet?starting_sheet->rules.size():0U) + <<" starting="<diagnostics) + std::cerr< unrelated_entry_nodes; + unrelated_entry_nodes.reserve(256U); + for(size_t index=0;index<256U;++index) { + auto& unrelated=starting_document.create_element("div"); + unrelated.class_name="static"; + starting_document.append_child(starting_document.body(),unrelated); + unrelated_entry_nodes.push_back(&unrelated); + } + auto& starting_node=starting_document.create_element("div"); + starting_node.class_name="entry"; + starting_document.append_child(starting_document.body(),starting_node); + auto& precedence_node=starting_document.create_element("div"); + precedence_node.class_name="precedence"; + starting_document.append_child(starting_document.body(),precedence_node); + webscene_native::css::query_host starting_query(starting_document); + webscene_native::css::stylesheet_owner starting_sheets; + starting_sheets.replace(749,std::move(*starting_sheet)); + starting_document.signal_animation_frame(0); + webscene_native::css::apply_native_document_cascade( + starting_document,starting_sheets,starting_query, + [](const auto&,auto&,auto&,auto&) {return false;}, + [](const auto&,const auto&) {}); + if(starting_node.style.opacity!=1 + || std::abs(starting_node.painted_opacity_value())>0.0001F + || std::abs(starting_node.painted_transform_translate_x_value().value+10)>0.001F) + return 186; + if(std::abs(precedence_node.painted_opacity_value()-1)>0.0001F) + return 193; + if(std::any_of( + unrelated_entry_nodes.begin(),unrelated_entry_nodes.end(), + [](const auto* node) {return node->has_animation_runtime();})) return 192; + starting_document.signal_animation_frame(500); + if(!starting_document.advance_animations() + || std::abs(starting_node.painted_opacity_value()-0.5F)>0.01F + || std::abs(starting_node.painted_transform_translate_x_value().value+5)>0.01F) + return 187; + starting_document.signal_animation_frame(1000); + starting_document.advance_animations(); + if(std::abs(starting_node.painted_opacity_value()-1)>0.001F + || std::abs(starting_node.painted_transform_translate_x_value().value)>0.001F + || starting_document.has_active_animations()) + return 188; + starting_node.class_name="entry hidden"; + webscene_native::css::apply_native_document_cascade( + starting_document,starting_sheets,starting_query, + [](const auto&,auto&,auto&,auto&) {return false;}, + [](const auto&,const auto&) {}); + if(starting_node.style.display==webscene_native::display_mode::none + || starting_node.computed_display_value()!=webscene_native::display_mode::none + || !starting_node.css_was_rendered + || !starting_node.animation_runtime()->display_animation_active) return 189; + starting_document.signal_animation_frame(1260); + starting_document.advance_animations(); + if(starting_node.style.display!=webscene_native::display_mode::none + || starting_node.css_was_rendered + || starting_node.animation_runtime()->display_animation_active + || starting_document.has_active_animations()) return 191; + starting_node.class_name="entry"; + starting_document.signal_animation_frame(1300); + webscene_native::css::apply_native_document_cascade( + starting_document,starting_sheets,starting_query, + [](const auto&,auto&,auto&,auto&) {return false;}, + [](const auto&,const auto&) {}); + if(std::abs(starting_node.painted_opacity_value())>0.0001F + || !starting_node.css_was_rendered) return 190; std::cout<<"V8-free shared CSS declaration service passed\n"; } diff --git a/tests/RuntimeCompatibility/stylesheet-cssom.test.mjs b/tests/RuntimeCompatibility/stylesheet-cssom.test.mjs index 1d3204614..a0f9af120 100644 --- a/tests/RuntimeCompatibility/stylesheet-cssom.test.mjs +++ b/tests/RuntimeCompatibility/stylesheet-cssom.test.mjs @@ -105,6 +105,22 @@ test('CSS token boundaries retain quoted braces, comments, escapes and grouped r assert.equal(style.sheet.cssRules[0].selectorText, '.retained'); }); +test('starting-style rules expose grouping identity and publish child mutations', () => { + const { createStyle, realm } = setup(); + const style = createStyle(); + style.textContent = '@starting-style { .entry { opacity: 0; transform: translateX(-6px); } }'; + const rule = style.sheet.cssRules[0]; + assert.equal(rule instanceof realm.CSSStartingStyleRule, true); + assert.equal(rule.constructor.name, 'CSSStartingStyleRule'); + assert.equal(rule.cssRules.length, 1); + assert.equal(rule.cssRules[0].selectorText, '.entry'); + rule.deleteRule(0); + assert.equal(style.textContent, '@starting-style {}'); + rule.insertRule('.replacement { opacity: .5; }'); + assert.equal(rule.cssRules[0].selectorText, '.replacement'); + assert.equal(style.textContent, '@starting-style {.replacement { opacity: .5; }}'); +}); + test('invalid indices and malformed rules fail before native mutation', () => { const { createStyle } = setup(); const style = createStyle(); diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index ee5bd7b78..5edab0844 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -386,6 +386,14 @@ "productEvidence": ["Code OSS chat input and session banner border beams", "inline and terminal chat progress borders", "voice processing ring", "chat last-edit highlight"], "coverage": ["contracts/css-registered-custom-property-animation.html", "webscene_css_parser_tests", "native_web_css_service", "test_registered_custom_property_keyframes_update_dependent_paint"], "gap": "Issue #748 implements the exact / registration and interpolation slice used by unchanged Code OSS, with bounded registration/track/stop storage and change-only dependent token updates. Direct browser/native profile execution and product pixels remain candidate evidence. Conic-gradient and border-image gradient rasterization, arbitrary syntaxes, CSS.registerProperty(), and additive composition remain separate work." + }, + { + "family": "starting-style-entry-transition", + "required": ["document-level @starting-style grouping", "normal plus starting cascade", "first-render and display-none re-entry", "opacity and 2D transform host-clock admission", "transition-behavior allow-discrete display deferral", "CSSStartingStyleRule identity and mutation", "stylesheet teardown and cancellation"], + "candidate": ["browser/native deterministic midpoint comparison", "unchanged session-workbench fixture", "unchanged phone-sidebar drawer fixture"], + "productEvidence": ["Code OSS session workbench part entry", "Code OSS phone sidebar drawer"], + "coverage": ["contracts/css-starting-style-entry-transition.html", "webscene_css_parser_tests", "native_web_css_service", "test_starting_style_entry_and_discrete_display_transitions"], + "gap": "Issue #749 implements the exact entry-transition slice used by unchanged Code OSS. Static nodes retain only two lifecycle bits; temporary starting snapshots exist only during entry cascade and admitted transitions reuse host-clock runtime state. Product pixels, broader discrete properties, margin/border transition machines, and view transitions remain separate work." } ] } diff --git a/tests/WebPlatformSubset/contracts/css-starting-style-entry-transition.html b/tests/WebPlatformSubset/contracts/css-starting-style-entry-transition.html new file mode 100644 index 000000000..d2a9a05c7 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-starting-style-entry-transition.html @@ -0,0 +1,45 @@ + + +Starting style drives entry and discrete display transitions + + + +
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index 9a28f6ad0..2b869e43f 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2758,6 +2758,13 @@ "capabilities": ["css-property-rule", "registered-angle-property", "registered-percentage-property", "typed-custom-property-keyframes", "non-inheriting-initial-value", "dependent-gradient-token-update"], "evidence": ["native-parser-contract", "v8-free-native-cascade-contract", "focused-live-runtime-contract", "unchanged-code-oss-645f29c-inventory"], "reason": "Implementation-first WebScene #748 contract for the bounded / @property slice used by unchanged Code OSS. It checks CSSPropertyRule reflection, inherits:false initial values, a deterministic paused midpoint, and the dependent gradient token. Browser/native execution and exact product pixels remain qualification debt; conic-gradient and border-image raster consumers are separate paint work." + }, + { + "path": "contracts/css-starting-style-entry-transition.html", + "type": "testharness", + "capabilities": ["css-starting-style-rule", "entry-transition-cascade", "display-none-reentry", "allow-discrete-display", "host-clock-opacity-transform-transition"], + "evidence": ["native-parser-contract", "v8-free-native-cascade-contract", "focused-live-runtime-contract", "unchanged-code-oss-645f29c-inventory"], + "reason": "Implementation-first WebScene #749 contract for the two @starting-style groups used by unchanged Code OSS. It checks grouping-rule CSSOM, deterministic starting values, discrete display deferral, re-entry, bounded lifecycle state, and host-clock settlement. Direct browser/native profile execution and exact product pixels remain qualification debt." } ], "harnessBlocked": [],