diff --git a/docs/validation/code-oss-overscroll-behavior-793.md b/docs/validation/code-oss-overscroll-behavior-793.md new file mode 100644 index 00000000..90c8d1e4 --- /dev/null +++ b/docs/validation/code-oss-overscroll-behavior-793.md @@ -0,0 +1,40 @@ +# Code OSS overscroll behavior implementation (#793) + +## Product trigger + +Unchanged Code OSS contains 12 `overscroll-behavior` declarations. The +workbench root uses `none`; Markdown, Chat, question carousels, voice input, +sessions/mobile shells, and overlay views use `contain`. + +## Implemented slice + +- Adds generated native/managed identities and CSSOM accessors for the + shorthand and both physical-axis longhands. +- Parses and serializes `auto`, `contain`, and `none`, including one/two-value + shorthand, global keywords, inline mutation, removal, and cascade fallback. +- Stores two compact policies in the existing cold textual style allocation. +- Stops exhausted wheel-chain axes at qualifying retained scroll containers + for `contain` and `none`, while preserving `auto`, the other axis, root + viewport ownership, wheel cancellation, scrolling, and scroll events. +- Adds no hot-node field, timer, frame participant, platform widget, document + scan, browser shell, or Code OSS patch. Policy-only CSSOM mutation causes no + layout or scene publication. + +WebScene does not provide a rubber-band affordance, so this bounded slice does +not invent a visual distinction between `contain` and `none`. Both values stop +scroll chaining as required by the product paths. + +## Authored gates + +- `contracts/css-overscroll-behavior.html`: shorthand/longhand CSSOM, + mutation, invalid writes, removal, and cascade fallback. +- `test_overscroll_behavior_wheel_chaining`: nested boundary containment for + `contain`, `none`, and restored `auto` chaining. +- `overscroll-performance`: 4,096 policies, ten transitions, zero added layout + and scene publications, and a five-second guardrail. + +## Evidence state + +Implementation and gates are authored from source review. Per the active fast +merge direction, no build, test, WPT, visual, package, memory, lifecycle, or +performance command was executed. Evidence remains zero until full validation. diff --git a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json index 95699618..59279259 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json +++ b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json @@ -152,7 +152,10 @@ {"id": "aspect_ratio", "name": "aspect-ratio"}, {"id": "object_fit", "name": "object-fit", "aliases": ["objectfit"]}, {"id": "object_position", "name": "object-position", "aliases": ["objectposition"]}, - {"id": "user_select", "name": "user-select", "aliases": ["userselect", "-webkit-user-select", "webkit-user-select", "webkituserselect", "-ms-user-select", "ms-user-select", "msuserselect"]} + {"id": "user_select", "name": "user-select", "aliases": ["userselect", "-webkit-user-select", "webkit-user-select", "webkituserselect", "-ms-user-select", "ms-user-select", "msuserselect"]}, + {"id": "overscroll_behavior", "name": "overscroll-behavior", "aliases": ["overscrollbehavior"]}, + {"id": "overscroll_behavior_x", "name": "overscroll-behavior-x", "aliases": ["overscrollbehaviorx"]}, + {"id": "overscroll_behavior_y", "name": "overscroll-behavior-y", "aliases": ["overscrollbehaviory"]} ], "nativeStorageOnlyProperties": [ "accent-color", "align-content", "animation-direction", "animation-fill-mode", "animation-play-state", @@ -173,7 +176,8 @@ "all", "display", "position", "floating", "flex_direction", "flex_wrap", "align_items", "align_self", "justify_content", "box_sizing", "vertical_align", "grid_auto_flow", "border_collapse", "table_layout", "border_style", "background_repeat", "visibility", "pointer_events", "text_anchor", "font_smoothing", "text_align", "text_transform", "white_space", "word_break", "overflow_wrap", "list_style_position", - "list_style_type", "scrollbar_width", "container", "container_type", "content_visibility", "object_fit", "user_select" + "list_style_type", "scrollbar_width", "container", "container_type", "content_visibility", "object_fit", "user_select", + "overscroll_behavior", "overscroll_behavior_x", "overscroll_behavior_y" ], "componentList": [ "container_name", "background_position", "background_size", "transition", "transition_property", "transition_duration", @@ -217,7 +221,7 @@ "outline", "outline_width", "outline_color", "animation", "animation_name", "animation_duration", "animation_delay", "animation_timing_function", "animation_iteration_count", "background_repeat", "background_position", "background_size", "font", "text_transform", "word_break", "overflow_wrap", "list_style", "list_style_position", "list_style_type", - "object_fit", "object_position", "user_select" + "object_fit", "object_position", "user_select", "overscroll_behavior", "overscroll_behavior_x", "overscroll_behavior_y" ], "managedKnownProperties": [ "align-content", "align-items", "align-self", "all", "background", "background-color", @@ -238,7 +242,8 @@ "stroke-linecap", "stroke-linejoin", "stroke-width", "text-align", "text-indent", "text-transform", "top", "transform", "visibility", "white-space", "word-break", "overflow-wrap", "width", "word-spacing", "z-index", "outline", "outline-color", "outline-offset", "outline-style", "outline-width", - "grid-template-areas", "aspect-ratio", "object-fit", "object-position", "user-select" + "grid-template-areas", "aspect-ratio", "object-fit", "object-position", "user-select", + "overscroll-behavior", "overscroll-behavior-x", "overscroll-behavior-y" ], "supportedPropertyExtras": [ "animation", "animation-delay", "animation-direction", "animation-duration", "animation-fill-mode", diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc index 2cd86618..0de98ce0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc @@ -225,6 +225,12 @@ inline constexpr std::array native_typed_property_identity_catalog{ native_typed_property_identity{"-ms-user-select", css_property_id::user_select}, native_typed_property_identity{"ms-user-select", css_property_id::user_select}, native_typed_property_identity{"msuserselect", css_property_id::user_select}, + native_typed_property_identity{"overscroll-behavior", css_property_id::overscroll_behavior}, + native_typed_property_identity{"overscrollbehavior", css_property_id::overscroll_behavior}, + native_typed_property_identity{"overscroll-behavior-x", css_property_id::overscroll_behavior_x}, + native_typed_property_identity{"overscrollbehaviorx", css_property_id::overscroll_behavior_x}, + native_typed_property_identity{"overscroll-behavior-y", css_property_id::overscroll_behavior_y}, + native_typed_property_identity{"overscrollbehaviory", css_property_id::overscroll_behavior_y}, }; inline constexpr std::array native_storage_only_property_catalog{ @@ -445,6 +451,9 @@ inline constexpr std::array native_modeled_property_mask_catalog{ false, false, false, + false, + false, + false, }; inline constexpr bool generated_property_has_modeled_mask( @@ -644,6 +653,9 @@ inline constexpr std::array native_property_grammar_catalog{ native_property_grammar::keyword, native_property_grammar::component_list, native_property_grammar::keyword, + native_property_grammar::keyword, + native_property_grammar::keyword, + native_property_grammar::keyword, }; inline constexpr native_property_grammar generated_property_grammar( @@ -877,5 +889,11 @@ inline css_property_id generated_property_id_lowercase(std::string_view name) no if (name == "-ms-user-select") return css_property_id::user_select; if (name == "ms-user-select") return css_property_id::user_select; if (name == "msuserselect") return css_property_id::user_select; + if (name == "overscroll-behavior") return css_property_id::overscroll_behavior; + if (name == "overscrollbehavior") return css_property_id::overscroll_behavior; + if (name == "overscroll-behavior-x") return css_property_id::overscroll_behavior_x; + if (name == "overscrollbehaviorx") return css_property_id::overscroll_behavior_x; + if (name == "overscroll-behavior-y") return css_property_id::overscroll_behavior_y; + if (name == "overscrollbehaviory") return css_property_id::overscroll_behavior_y; return css_property_id::unknown; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc index a4d9dfdb..6fd5e181 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc @@ -151,3 +151,6 @@ aspect_ratio, object_fit, object_position, user_select, +overscroll_behavior, +overscroll_behavior_x, +overscroll_behavior_y, diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc index 81d47410..dcee1f49 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc @@ -203,6 +203,9 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"overflow-wrap", "overflowWrap"}, cssom_supported_property_metadata{"overflow-x", "overflowX"}, cssom_supported_property_metadata{"overflow-y", "overflowY"}, + cssom_supported_property_metadata{"overscroll-behavior", "overscrollBehavior"}, + cssom_supported_property_metadata{"overscroll-behavior-x", "overscrollBehaviorX"}, + cssom_supported_property_metadata{"overscroll-behavior-y", "overscrollBehaviorY"}, cssom_supported_property_metadata{"padding", "padding"}, cssom_supported_property_metadata{"padding-block", "paddingBlock"}, cssom_supported_property_metadata{"padding-block-end", "paddingBlockEnd"}, @@ -256,4 +259,4 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"zoom", "zoom"}, }; -inline constexpr auto cssom_style_template_property_accessor_count = 455U; +inline constexpr auto cssom_style_template_property_accessor_count = 461U; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h index c01505d3..eb395206 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h @@ -259,6 +259,7 @@ void apply_resolved_declaration(native_document& document,dom_node& node, } else if (css::apply_visibility_value(document,node,name,value,is_inline)) { } else if (css::apply_replaced_value(node,name,value,decision)) { } else if (css::apply_user_select_value(node,name,value,decision)) { + } else if (css::apply_overscroll_behavior_value(node,name,value,decision)) { } else if (css::apply_text_value(node,name,value,decision,is_inline)) { } else if (name == "border-style") { decision.classification = "partially-supported"; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h index ba6d7a21..6d781721 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -229,6 +229,8 @@ inline void reset_cascaded_style(dom_node& node, textual->object_fit.clear(); textual->object_position.clear(); textual->user_select.clear(); + textual->overscroll_x = overscroll_behavior::automatic; + textual->overscroll_y = overscroll_behavior::automatic; } if ((node.style.inline_property_mask & inline_font_weight) == 0U) node.style.font_weight = 0; if ((node.style.inline_property_mask & inline_line_height) == 0U) node.style.line_height = -1; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_interaction_values.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_interaction_values.h index b104721f..1287d9a3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_interaction_values.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_interaction_values.h @@ -1,5 +1,6 @@ #pragma once #include "webscene_css_property_mask.h" +#include #include namespace webscene_native::css { @@ -25,4 +26,88 @@ bool apply_user_select_value(dom_node& node,const std::string& name, ? std::string{} : std::move(value); return true; } + +inline std::string_view serialize_overscroll_behavior( + overscroll_behavior value) noexcept +{ + switch (value) { + case overscroll_behavior::contain: return "contain"; + case overscroll_behavior::none: return "none"; + default: return "auto"; + } +} + +inline std::optional parse_overscroll_behavior( + std::string_view value) noexcept +{ + if (value == "auto") return overscroll_behavior::automatic; + if (value == "contain") return overscroll_behavior::contain; + if (value == "none") return overscroll_behavior::none; + return std::nullopt; +} + +inline std::string serialize_overscroll_shorthand(const node_style& style) +{ + const auto x = serialize_overscroll_behavior(style.textual().overscroll_x); + const auto y = serialize_overscroll_behavior(style.textual().overscroll_y); + return x == y ? std::string(x) : std::string(x) + " " + std::string(y); +} + +template +bool apply_overscroll_behavior_value(dom_node& node,const std::string& name, + const std::string& raw_value,Decision& decision) +{ + const auto property = canonical_property_name(name); + if (property != "overscroll-behavior" + && property != "overscroll-behavior-x" + && property != "overscroll-behavior-y") return false; + auto value = ascii_lower(trim_value(raw_value)); + const auto inherited = value == "inherit"; + const auto initial = value == "initial" || value == "unset" + || value == "revert" || value == "revert-layer"; + const auto parent_x = node.parent == nullptr + ? overscroll_behavior::automatic + : node.parent->style.textual().overscroll_x; + const auto parent_y = node.parent == nullptr + ? overscroll_behavior::automatic + : node.parent->style.textual().overscroll_y; + auto x = node.style.textual().overscroll_x; + auto y = node.style.textual().overscroll_y; + if (inherited || initial) { + const auto reset_x = inherited ? parent_x : overscroll_behavior::automatic; + const auto reset_y = inherited ? parent_y : overscroll_behavior::automatic; + if (property != "overscroll-behavior-y") x = reset_x; + if (property != "overscroll-behavior-x") y = reset_y; + } else if (property == "overscroll-behavior") { + std::istringstream stream(value); + std::string first; + std::string second; + std::string extra; + stream >> first >> second >> extra; + const auto parsed_x = parse_overscroll_behavior(first); + const auto parsed_y = parse_overscroll_behavior( + second.empty() ? first : second); + if (!parsed_x.has_value() || !parsed_y.has_value() || !extra.empty()) { + decision.classification = "invalid-authoring"; + return true; + } + x = *parsed_x; + y = *parsed_y; + } else { + const auto parsed = parse_overscroll_behavior(value); + if (!parsed.has_value()) { + decision.classification = "invalid-authoring"; + return true; + } + if (property == "overscroll-behavior-x") x = *parsed; + else y = *parsed; + } + if (x == overscroll_behavior::automatic + && y == overscroll_behavior::automatic + && !node.style.has_textual_data()) return true; + auto& textual = node.style.mutable_textual(); + textual.overscroll_x = x; + textual.overscroll_y = y; + return true; +} } // namespace webscene_native::css diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index 6d11065f..3eedc24b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -242,6 +242,12 @@ inline void apply_all_unset( if (has_inline({"user-select", "-webkit-user-select", "-ms-user-select"})) { reset.mutable_textual().user_select = previous.textual().user_select; } + if (has_inline({"overscroll-behavior", "overscroll-behavior-x"})) { + reset.mutable_textual().overscroll_x = previous.textual().overscroll_x; + } + if (has_inline({"overscroll-behavior", "overscroll-behavior-y"})) { + reset.mutable_textual().overscroll_y = previous.textual().overscroll_y; + } // These modeled properties do not yet have dedicated inline-mask // bits, so preserve their applied values by authored declaration. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h index 04b2bf78..aa5b8a17 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h @@ -10,6 +10,7 @@ inline constexpr auto specified_property_samples = std::to_arrayuser_select.clear(); } + } else if (name == "overscrollBehavior" || name == "overscroll-behavior" + || name == "overscrollBehaviorX" || name == "overscroll-behavior-x" + || name == "overscrollBehaviorY" || name == "overscroll-behavior-y") { + if (auto* textual = node.style.mutable_textual_if_present()) { + if (name == "overscrollBehavior" || name == "overscroll-behavior") { + textual->overscroll_x = overscroll_behavior::automatic; + textual->overscroll_y = overscroll_behavior::automatic; + } else if (name == "overscrollBehaviorX" + || name == "overscroll-behavior-x") { + textual->overscroll_x = overscroll_behavior::automatic; + } else { + textual->overscroll_y = overscroll_behavior::automatic; + } + } } else if (name == "boxSizing" || name == "box-sizing") { node.style.border_box = false; node.style.inline_property_mask &= ~inline_box_sizing; @@ -971,6 +985,14 @@ std::string semantic_slice; } decision; css::apply_user_select_value(*node, name, value, decision); + } else if (name == "overscroll-behavior" + || name == "overscroll-behavior-x" + || name == "overscroll-behavior-y") { + struct interaction_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + css::apply_overscroll_behavior_value(*node, name, value, decision); } else if (name == "box-sizing") { node->style.border_box = value == "border-box"; node->style.inline_property_mask |= inline_box_sizing; @@ -1582,6 +1604,14 @@ } else if (name == "user-select") { value = node->style.textual().user_select.empty() ? "auto" : node->style.textual().user_select; + } else if (name == "overscroll-behavior") { + value = css::serialize_overscroll_shorthand(node->style); + } else if (name == "overscroll-behavior-x") { + value = css::serialize_overscroll_behavior( + node->style.textual().overscroll_x); + } else if (name == "overscroll-behavior-y") { + value = css::serialize_overscroll_behavior( + node->style.textual().overscroll_y); } else if (name == "cursor") { value = resolved_cursor(*node); } else if (name == "font-size") { @@ -2050,6 +2080,14 @@ || name == "MsUserSelect") { value = node->style.textual().user_select.empty() ? "auto" : node->style.textual().user_select; + } else if (name == "overscrollBehavior") { + value = css::serialize_overscroll_shorthand(node->style); + } else if (name == "overscrollBehaviorX") { + value = css::serialize_overscroll_behavior( + node->style.textual().overscroll_x); + } else if (name == "overscrollBehaviorY") { + value = css::serialize_overscroll_behavior( + node->style.textual().overscroll_y); } else if (name == "boxSizing") { value = node->style.border_box ? "border-box" : "content-box"; } else if (name == "borderRadius") { @@ -2637,6 +2675,18 @@ std::string semantic_slice; } decision; css::apply_user_select_value(*node, "user-select", value, decision); + } else if (name == "overscrollBehavior" + || name == "overscrollBehaviorX" || name == "overscrollBehaviorY") { + struct interaction_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + const auto property = name == "overscrollBehavior" + ? std::string{"overscroll-behavior"} + : name == "overscrollBehaviorX" + ? std::string{"overscroll-behavior-x"} + : std::string{"overscroll-behavior-y"}; + css::apply_overscroll_behavior_value(*node, property, value, decision); } else if (name == "boxSizing") { node->style.border_box = value == "border-box"; node->style.inline_property_mask |= inline_box_sizing; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc index 6c0cc4ba..ea25b4bf 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc @@ -663,6 +663,22 @@ bool valid_cssom_declaration_value(std::string_view property_name, std::string_v return normalized_value == "auto" || normalized_value == "text" || normalized_value == "none" || normalized_value == "all"; } + if (canonical_name == "overscroll-behavior-x" + || canonical_name == "overscroll-behavior-y") { + return normalized_value == "auto" || normalized_value == "contain" + || normalized_value == "none"; + } + if (canonical_name == "overscroll-behavior") { + std::istringstream stream(normalized_value); + std::string first; + std::string second; + std::string extra; + stream >> first >> second >> extra; + const auto valid = [](std::string_view token) { + return token == "auto" || token == "contain" || token == "none"; + }; + return valid(first) && (second.empty() || valid(second)) && extra.empty(); + } if (canonical_name == "position") { return normalized_value == "static" || normalized_value == "relative" diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 34a1e8fd..2ae7b6c5 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -4414,6 +4414,8 @@ && !platform_smoothed_wheel && !css::get_active_accessibility_preferences().reduced_motion; if (!animate_wheel) cancel_discrete_wheel_scroll(); + auto remaining_delta_x = static_cast(input.delta_x); + auto remaining_delta_y = static_cast(input.delta_y); for (auto* scroll_target = target; scroll_target != nullptr; scroll_target = scroll_target->parent) { // A loaded document exposes HTML beneath the retained native @@ -4447,22 +4449,22 @@ const auto [base_x, base_y] = animate_wheel ? discrete_wheel_scroll_base( *scroll_target, - static_cast(input.delta_x), - static_cast(input.delta_y)) + remaining_delta_x, + remaining_delta_y) : std::pair{ scroll_target->scroll_left, scroll_target->scroll_top}; const auto next_x = (scroll_target->style.scroll_x_enabled || root_viewport_scroll_x) ? std::clamp( - base_x + static_cast(input.delta_x), + base_x + remaining_delta_x, 0.0F, maximum_x) : scroll_target->scroll_left; const auto next_y = (scroll_target->style.scroll_y_enabled || root_viewport_scroll_y) ? std::clamp( - base_y + static_cast(input.delta_y), + base_y + remaining_delta_y, 0.0F, maximum_y) : scroll_target->scroll_top; @@ -4472,6 +4474,23 @@ ? base_y : scroll_target->scroll_top; if (std::abs(next_x - comparison_x) <= 0.01F && std::abs(next_y - comparison_y) <= 0.01F) { + const auto& overscroll = scroll_target->style.textual(); + if (std::abs(remaining_delta_x) > 0.01F + && (scroll_target->style.scroll_x_enabled + || root_viewport_scroll_x) + && overscroll.overscroll_x + != overscroll_behavior::automatic) { + remaining_delta_x = 0.0F; + } + if (std::abs(remaining_delta_y) > 0.01F + && (scroll_target->style.scroll_y_enabled + || root_viewport_scroll_y) + && overscroll.overscroll_y + != overscroll_behavior::automatic) { + remaining_delta_y = 0.0F; + } + if (std::abs(remaining_delta_x) <= 0.01F + && std::abs(remaining_delta_y) <= 0.01F) break; continue; } if (animate_wheel) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp index 9fdc4a7f..94c298da 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp @@ -99,10 +99,13 @@ int main() static_assert(static_cast(css_property_id::contain_intrinsic_size) == 145U); static_assert(static_cast(css_property_id::object_position) == 148U); static_assert(static_cast(css_property_id::user_select) == 149U); - static_assert(native_typed_property_identity_catalog.size() == 220U); + static_assert(static_cast(css_property_id::overscroll_behavior) == 150U); + static_assert(static_cast(css_property_id::overscroll_behavior_x) == 151U); + static_assert(static_cast(css_property_id::overscroll_behavior_y) == 152U); + static_assert(native_typed_property_identity_catalog.size() == 226U); static_assert(native_storage_only_property_catalog.size() == 62U); - static_assert(cssom_supported_property_catalog.size() == 249U); - static_assert(cssom_style_template_property_accessor_count == 455U); + static_assert(cssom_supported_property_catalog.size() == 252U); + static_assert(cssom_style_template_property_accessor_count == 461U); for (const auto& entry : native_typed_property_identity_catalog) { require(property_id(entry.name) == entry.id, "typed name maps to its generated id", entry.name); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc index 72c19d52..ce29e424 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc @@ -196,6 +196,50 @@ void test_user_select_performance_gate(webscene_engine* engine) + std::to_string(elapsed) + " ms"); } +void test_overscroll_behavior_performance_gate(webscene_engine* engine) +{ + resize(engine, 1024, 768, 1U); + execute(engine, R"JS( + document.body.innerHTML = '
'; + globalThis.overscrollGrid = document.getElementById('overscroll-grid'); + const fragment = document.createDocumentFragment(); + for (let index = 0; index < 4096; ++index) { + const node = document.createElement('div'); + node.style.overflow = 'auto'; + fragment.appendChild(node); + } + overscrollGrid.appendChild(fragment); + )JS", "native-overscroll-performance-setup.js"); + webscene_engine_metrics before{}; + webscene_engine_get_metrics(engine, &before); + const auto started = std::chrono::steady_clock::now(); + const auto result = evaluate(engine, R"JS( + (() => { + for (let cycle = 0; cycle < 10; ++cycle) { + const value = cycle % 2 === 0 ? 'contain none' : 'auto'; + for (const node of overscrollGrid.children) { + node.style.overscrollBehavior = value; + } + } + const last = overscrollGrid.lastElementChild; + return [overscrollGrid.children.length, last.style.overscrollBehavior]; + })() + )JS", "native-overscroll-performance-gate.js"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + webscene_engine_metrics after{}; + webscene_engine_get_metrics(engine, &after); + require(result == R"JSON([4096,"auto"])JSON", + "overscroll performance fixture regressed: " + result); + require(after.layout_passes == before.layout_passes, + "overscroll policy transitions performed layout"); + require(after.published_scenes == before.published_scenes, + "overscroll policy transitions published scenes"); + require(elapsed < 5000.0, + "4,096-node overscroll transition gate exceeded 5 seconds: " + + std::to_string(elapsed) + " ms"); +} + void test_modal_backdrop_scene(webscene_engine* engine) { resize(engine,400,300,1); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 73549b62..8f2683e9 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1979,6 +1979,54 @@ void wheel_input( require(webscene_engine_enqueue(engine, &input) != 0, "wheel input was rejected"); } +void test_overscroll_behavior_wheel_chaining(webscene_engine* engine) +{ + resize(engine, 400, 240, 79'300U); + execute(engine, R"JS( + document.body.innerHTML = `
`; + globalThis.overscrollInner = document.getElementById('inner'); + overscrollInner.scrollTop = 200; + document.body.scrollTop = 0; + )JS", "native-overscroll-setup.js"); + auto consumed = consumed_input_count(engine); + wheel_input(engine, 50, 50, 80, 79'301U, WEBSCENE_INPUT_WHEEL_PRECISE); + wait_for_consumed_inputs(engine, ++consumed, + "contained overscroll wheel was not consumed"); + require(evaluate(engine, "document.body.scrollTop", + "native-overscroll-contain.js") == "0", + "overscroll-behavior:contain chained into the outer scroller"); + + execute(engine, R"JS( + overscrollInner.style.overscrollBehavior = 'auto'; + overscrollInner.scrollTop = 200; + document.body.scrollTop = 0; + )JS", "native-overscroll-auto-setup.js"); + wheel_input(engine, 50, 50, 80, 79'302U, WEBSCENE_INPUT_WHEEL_PRECISE); + wait_for_consumed_inputs(engine, ++consumed, + "automatic overscroll wheel was not consumed"); + require(std::stod(evaluate(engine, "document.body.scrollTop", + "native-overscroll-auto.js")) > 0.0, + "overscroll-behavior:auto did not chain into the outer scroller"); + + execute(engine, R"JS( + overscrollInner.style.overscrollBehavior = 'none'; + overscrollInner.scrollTop = 200; + document.body.scrollTop = 0; + )JS", "native-overscroll-none-setup.js"); + wheel_input(engine, 50, 50, 80, 79'303U, WEBSCENE_INPUT_WHEEL_PRECISE); + wait_for_consumed_inputs(engine, ++consumed, + "none overscroll wheel was not consumed"); + require(evaluate(engine, "document.body.scrollTop", + "native-overscroll-none.js") == "0", + "overscroll-behavior:none chained into the outer scroller"); +} + void test_discrete_wheel_scroll_uses_bounded_frame_animation() { auto* engine = webscene_engine_create(0); 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 158e1c8f..c5986fce 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -405,6 +405,14 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "overscroll-performance") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "overscroll performance engine creation failed"); + test_overscroll_behavior_performance_gate(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if(selected=="placeholder-pseudo") { auto* focused_engine=webscene_engine_create(0); require(focused_engine!=nullptr,"placeholder pseudo engine creation failed"); @@ -1494,6 +1502,7 @@ int main() test_inline_block_preserves_vertical_padding(engine); test_pointer_hit_targets_and_related_targets_are_elements(engine); test_user_select_pointer_default_action(engine); + test_overscroll_behavior_wheel_chaining(engine); test_pointer_cursor_and_external_anchor_host_handoff(engine); test_nested_drag_drop_routing_and_retirement(); test_outbound_drag_browser_contract_and_retirement(engine); diff --git a/src/WebScene.Css/CssPropertyCatalog.cs b/src/WebScene.Css/CssPropertyCatalog.cs index ba7ab4da..07e2f107 100644 --- a/src/WebScene.Css/CssPropertyCatalog.cs +++ b/src/WebScene.Css/CssPropertyCatalog.cs @@ -87,6 +87,9 @@ public static bool IsValidCssomValue(string propertyName, string value) "user-select" or "-webkit-user-select" or "webkit-user-select" or "-ms-user-select" or "ms-user-select" => normalizedValue is "auto" or "text" or "none" or "all", + "overscroll-behavior-x" or "overscroll-behavior-y" + => normalizedValue is "auto" or "contain" or "none", + "overscroll-behavior" => IsOverscrollBehavior(normalizedValue), "font-size" => IsFontSize(normalizedValue), "color-scheme" => normalizedValue is "normal" or "light" or "dark" or "light dark" or "dark light" or "only light" or "only dark", @@ -157,6 +160,14 @@ private static bool IsInvalidUnitlessLength(string value) && double.IsFinite(numeric) && numeric != 0; + private static bool IsOverscrollBehavior(string value) + { + var tokens = value.Split([' ', '\t', '\r', '\n'], + StringSplitOptions.RemoveEmptyEntries); + return tokens.Length is 1 or 2 + && tokens.All(static token => token is "auto" or "contain" or "none"); + } + private static bool IsObjectPosition(string value) { var tokens = value.Split([' ', '\t', '\r', '\n'], diff --git a/src/WebScene.Css/CssPropertyMetadata.Generated.cs b/src/WebScene.Css/CssPropertyMetadata.Generated.cs index 883d99b0..95606de0 100644 --- a/src/WebScene.Css/CssPropertyMetadata.Generated.cs +++ b/src/WebScene.Css/CssPropertyMetadata.Generated.cs @@ -116,6 +116,9 @@ internal static class CssGeneratedPropertyMetadata "object-fit", "object-position", "user-select", + "overscroll-behavior", + "overscroll-behavior-x", + "overscroll-behavior-y", ]; internal static readonly string[] SupportedNames = @@ -318,6 +321,9 @@ internal static class CssGeneratedPropertyMetadata "overflow-wrap", "overflow-x", "overflow-y", + "overscroll-behavior", + "overscroll-behavior-x", + "overscroll-behavior-y", "padding", "padding-block", "padding-block-end", @@ -486,6 +492,9 @@ internal static bool TryGetKnownId(string name, out int id) "object-fit" => 108, "object-position" => 109, "user-select" => 110, + "overscroll-behavior" => 111, + "overscroll-behavior-x" => 112, + "overscroll-behavior-y" => 113, _ => -1, }; return id >= 0; diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index bc0437d9..5ba4683f 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -217,6 +217,13 @@ "coverage": ["contracts/css-user-select-interaction.html", "test_user_select_pointer_default_action", "test_user_select_performance_gate", "existing Selection/Range and ::selection retained-paint contracts"], "gap": "WebScene #791 authors the bounded native pointer-selection default action over existing Selection/Range and retained highlight paint. Direct native/browser execution, multiline and bidirectional exact pixels, package interaction, accessibility, memory/lifecycle, and the 4,096-node performance gate remain qualification debt. Touch selection handles and platform-native context selection UI remain outside this bounded slice." }, + { + "family": "overscroll-chain-containment", + "required": ["overscroll-behavior auto/contain/none", "physical axis longhands", "one/two-value shorthand", "wheel boundary chain containment", "interaction-only mutation"], + "productEvidence": ["workbench root bounce suppression", "Markdown preview containment", "Chat and question carousels", "sessions/mobile overlay views"], + "coverage": ["contracts/css-overscroll-behavior.html", "test_overscroll_behavior_wheel_chaining", "test_overscroll_behavior_performance_gate"], + "gap": "WebScene #793 authors compact per-axis policy and boundary wheel-chain containment. Direct browser/native execution, exact mixed-axis and nested-frame interaction, platform affordance behavior, package regions, accessibility, memory/lifecycle, and the 4,096-node performance gate remain qualification debt. WebScene currently has no rubber-band affordance, so contain and none intentionally share the chain-stopping behavior in this slice." + }, { "family": "svg-presentation-paint", "required": ["presentation attributes below author rules in the cascade", "inherited fill and stroke properties", "currentColor resolution against the painted element", "scene invalidation after inherited color and class changes"], diff --git a/tests/WebPlatformSubset/contracts/css-overscroll-behavior.html b/tests/WebPlatformSubset/contracts/css-overscroll-behavior.html new file mode 100644 index 00000000..65978c8c --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-overscroll-behavior.html @@ -0,0 +1,28 @@ + + +Overscroll behavior retains browser-shaped CSSOM + + + +
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index fd9862eb..d70ab16e 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2836,6 +2836,13 @@ "capabilities": ["user-select-cssom", "prefixed-user-select-alias", "pointer-text-selection", "cancelable-selectstart", "atomic-select-all"], "evidence": ["focused-native-pointer-contract-authored", "direct-chromium-contract-authored", "unchanged-code-oss-645f29c-inventory"], "reason": "Implementation-first WebScene #791 contract for 276 user-select declarations across 94 unchanged Code OSS files. It covers canonical/prefixed CSSOM, mutation/removal, pointer-created Selection state, none/text/all policy, and selectstart cancellation. Direct browser/native execution, exact selection pixels, package regions, performance, memory, and lifecycle evidence remain qualification debt." + }, + { + "path": "contracts/css-overscroll-behavior.html", + "type": "testharness", + "capabilities": ["overscroll-behavior-cssom", "per-axis-scroll-chain-policy", "nested-wheel-containment", "interaction-only-mutation"], + "evidence": ["focused-native-wheel-contract-authored", "direct-chromium-contract-authored", "unchanged-code-oss-645f29c-inventory"], + "reason": "Implementation-first WebScene #793 contract for the 12 unchanged Code OSS overscroll declarations used by the workbench root, Markdown, Chat, sessions/mobile shells, and overlay views. It covers shorthand/longhand CSSOM and retained wheel boundary containment. Direct browser/native execution, mixed-axis/nested-frame interaction, package regions, accessibility, performance, memory, and lifecycle evidence remain qualification debt." } ], "harnessBlocked": [], diff --git a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs index e80e38de..1bb70d33 100644 --- a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs @@ -55,6 +55,9 @@ public void DoesNotMisclassifyNonColorValueProperties(string propertyName) [InlineData("object-position")] [InlineData("userSelect")] [InlineData("webkitUserSelect")] + [InlineData("overscrollBehavior")] + [InlineData("overscrollBehaviorX")] + [InlineData("overscrollBehaviorY")] public void ExposesSupportedCssomProperties(string name) => Assert.True(CssPropertyCatalog.IsSupported(name)); @@ -115,6 +118,10 @@ public void DoesNotExposeUnknownOrCustomPropertiesAsIdlAttributes(string name) [InlineData("user-select", "none", true)] [InlineData("-webkit-user-select", "text", true)] [InlineData("user-select", "toggle", false)] + [InlineData("overscroll-behavior", "contain none", true)] + [InlineData("overscroll-behavior-x", "contain", true)] + [InlineData("overscroll-behavior", "contain auto none", false)] + [InlineData("overscroll-behavior-y", "bounce", false)] public void ValidatesCssomValuesWithoutFrameworkKnowledge(string name, string value, bool expected) => Assert.Equal(expected, CssPropertyCatalog.IsValidCssomValue(name, value)); }