diff --git a/docs/validation/code-oss-user-select-791.md b/docs/validation/code-oss-user-select-791.md new file mode 100644 index 00000000..a32dea35 --- /dev/null +++ b/docs/validation/code-oss-user-select-791.md @@ -0,0 +1,43 @@ +# Code OSS user-select interaction implementation (#791) + +## Product trigger + +Unchanged Code OSS contains 276 `user-select` declarations across 94 files. +They control native selection in Monaco, lists, dialogs, hovers, terminal, +notebooks, Chat, media preview, Simple Browser, and webview content. + +## Implemented slice + +- Adds generated native/managed identities for `user-select` and the WebKit/MS + aliases used by the product. +- Cascades and serializes `auto`, `text`, `none`, and `all` in cold style state. +- Resolves `auto` from the nearest ancestor selection policy during pointer + default actions, avoiding a field on every hot node. +- Creates document Selection state from retained-text caret hit testing on + pointer down/move/up. +- Suppresses `none`, permits explicit descendant `text`, atomically selects the + nearest `all` subtree, and honors canceled `selectstart`. +- Reuses existing Selection/Range, selection paint, copy, and outbound-drag + paths; CSSOM mutation is paint-only. Non-collapsed selections use the + existing `Highlight` and `HighlightText` system colors by default, while + matching author `::selection` declarations override those defaults. +- Clears active pointer-selection state during navigation and teardown. + +Programmatic Selection and `Window.find` remain independent of `user-select`, +matching the property's user-interaction boundary. Text controls retain their +dedicated UTF-16 editing selection implementation. + +## Authored gates + +- `contracts/css-user-select-interaction.html`: canonical/prefixed CSSOM, + invalid writes, mutation, removal, and cascade fallback. +- `test_user_select_pointer_default_action`: drag selection, `none`, `all`, + and cancelable `selectstart`. +- `user-select-performance`: 4,096 nodes, ten policy transitions, zero added + layout passes, 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 8d3440ca..95699618 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json +++ b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json @@ -151,7 +151,8 @@ {"id": "contain_intrinsic_size", "name": "contain-intrinsic-size"}, {"id": "aspect_ratio", "name": "aspect-ratio"}, {"id": "object_fit", "name": "object-fit", "aliases": ["objectfit"]}, - {"id": "object_position", "name": "object-position", "aliases": ["objectposition"]} + {"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"]} ], "nativeStorageOnlyProperties": [ "accent-color", "align-content", "animation-direction", "animation-fill-mode", "animation-play-state", @@ -172,7 +173,7 @@ "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" + "list_style_type", "scrollbar_width", "container", "container_type", "content_visibility", "object_fit", "user_select" ], "componentList": [ "container_name", "background_position", "background_size", "transition", "transition_property", "transition_duration", @@ -216,7 +217,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" + "object_fit", "object_position", "user_select" ], "managedKnownProperties": [ "align-content", "align-items", "align-self", "all", "background", "background-color", @@ -237,7 +238,7 @@ "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" + "grid-template-areas", "aspect-ratio", "object-fit", "object-position", "user-select" ], "supportedPropertyExtras": [ "animation", "animation-delay", "animation-direction", "animation-duration", "animation-fill-mode", @@ -260,7 +261,7 @@ "moz-transform", "-webkit-transform", "webkit-transform", "-webkit-font-smoothing", "webkit-font-smoothing", "orphans", "resize", "scrollbar-color", "scrollbar-width", "table-layout", "text-anchor", "text-decoration", "text-overflow", "vertical-align", "transition", "transition-delay", "transition-duration", "transition-property", "transition-timing-function", "transform-origin", - "widows", "word-wrap", "zoom" + "widows", "word-wrap", "zoom", "-webkit-user-select", "webkit-user-select", "-ms-user-select", "ms-user-select" ], "properties": [ {"name": "border-block-color", "mask": ["inline_border"], "expansion": ["border-top-color", "border-bottom-color"], "applyExpansion": true}, 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 930127cd..2cd86618 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 @@ -217,6 +217,14 @@ inline constexpr std::array native_typed_property_identity_catalog{ native_typed_property_identity{"objectfit", css_property_id::object_fit}, native_typed_property_identity{"object-position", css_property_id::object_position}, native_typed_property_identity{"objectposition", css_property_id::object_position}, + native_typed_property_identity{"user-select", css_property_id::user_select}, + native_typed_property_identity{"userselect", css_property_id::user_select}, + native_typed_property_identity{"-webkit-user-select", css_property_id::user_select}, + native_typed_property_identity{"webkit-user-select", css_property_id::user_select}, + native_typed_property_identity{"webkituserselect", css_property_id::user_select}, + 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}, }; inline constexpr std::array native_storage_only_property_catalog{ @@ -436,6 +444,7 @@ inline constexpr std::array native_modeled_property_mask_catalog{ false, false, false, + false, }; inline constexpr bool generated_property_has_modeled_mask( @@ -634,6 +643,7 @@ inline constexpr std::array native_property_grammar_catalog{ native_property_grammar::complex, native_property_grammar::keyword, native_property_grammar::component_list, + native_property_grammar::keyword, }; inline constexpr native_property_grammar generated_property_grammar( @@ -859,5 +869,13 @@ inline css_property_id generated_property_id_lowercase(std::string_view name) no if (name == "objectfit") return css_property_id::object_fit; if (name == "object-position") return css_property_id::object_position; if (name == "objectposition") return css_property_id::object_position; + if (name == "user-select") return css_property_id::user_select; + if (name == "userselect") return css_property_id::user_select; + if (name == "-webkit-user-select") return css_property_id::user_select; + if (name == "webkit-user-select") return css_property_id::user_select; + if (name == "webkituserselect") return css_property_id::user_select; + 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; 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 622e693b..a4d9dfdb 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 @@ -150,3 +150,4 @@ contain_intrinsic_size, aspect_ratio, object_fit, object_position, +user_select, 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 7020a4ac..81d47410 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 @@ -6,6 +6,7 @@ struct cssom_supported_property_metadata final { inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"-moz-transform", "MozTransform"}, + cssom_supported_property_metadata{"-ms-user-select", "MsUserSelect"}, cssom_supported_property_metadata{"-webkit-font-smoothing", "WebkitFontSmoothing"}, cssom_supported_property_metadata{"-webkit-mask", "WebkitMask"}, cssom_supported_property_metadata{"-webkit-mask-composite", "WebkitMaskComposite"}, @@ -14,6 +15,7 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"-webkit-mask-repeat", "WebkitMaskRepeat"}, cssom_supported_property_metadata{"-webkit-mask-size", "WebkitMaskSize"}, cssom_supported_property_metadata{"-webkit-transform", "WebkitTransform"}, + cssom_supported_property_metadata{"-webkit-user-select", "WebkitUserSelect"}, cssom_supported_property_metadata{"accent-color", "accentColor"}, cssom_supported_property_metadata{"align-content", "alignContent"}, cssom_supported_property_metadata{"align-items", "alignItems"}, @@ -186,6 +188,7 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"min-height", "minHeight"}, cssom_supported_property_metadata{"min-width", "minWidth"}, cssom_supported_property_metadata{"moz-transform", "mozTransform"}, + cssom_supported_property_metadata{"ms-user-select", "msUserSelect"}, cssom_supported_property_metadata{"object-fit", "objectFit"}, cssom_supported_property_metadata{"object-position", "objectPosition"}, cssom_supported_property_metadata{"opacity", "opacity"}, @@ -237,10 +240,12 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"transition-duration", "transitionDuration"}, cssom_supported_property_metadata{"transition-property", "transitionProperty"}, cssom_supported_property_metadata{"transition-timing-function", "transitionTimingFunction"}, + cssom_supported_property_metadata{"user-select", "userSelect"}, cssom_supported_property_metadata{"vertical-align", "verticalAlign"}, cssom_supported_property_metadata{"visibility", "visibility"}, cssom_supported_property_metadata{"webkit-font-smoothing", "webkitFontSmoothing"}, cssom_supported_property_metadata{"webkit-transform", "webkitTransform"}, + cssom_supported_property_metadata{"webkit-user-select", "webkitUserSelect"}, cssom_supported_property_metadata{"white-space", "whiteSpace"}, cssom_supported_property_metadata{"widows", "widows"}, cssom_supported_property_metadata{"width", "width"}, @@ -251,4 +256,4 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"zoom", "zoom"}, }; -inline constexpr auto cssom_style_template_property_accessor_count = 445U; +inline constexpr auto cssom_style_template_property_accessor_count = 455U; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h index 06052f8a..c01505d3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h @@ -9,6 +9,7 @@ #include "webscene_css_variables.h" #include "webscene_css_scrollbar_values.h" #include "webscene_css_replaced_values.h" +#include "webscene_css_interaction_values.h" namespace webscene_native::css { // Apply an already-resolved declaration. The caller owns variable resolution, @@ -257,6 +258,7 @@ void apply_resolved_declaration(native_document& document,dom_node& node, load_svg)) { } 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_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 2b9b410d..ba6d7a21 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -228,6 +228,7 @@ inline void reset_cascaded_style(dom_node& node, textual->overflow_wrap.clear(); textual->object_fit.clear(); textual->object_position.clear(); + textual->user_select.clear(); } 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 new file mode 100644 index 00000000..b104721f --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_interaction_values.h @@ -0,0 +1,28 @@ +#pragma once +#include "webscene_css_property_mask.h" +#include + +namespace webscene_native::css { +template +bool apply_user_select_value(dom_node& node,const std::string& name, + const std::string& raw_value,Decision& decision) +{ + if (canonical_property_name(name) != "user-select") return false; + auto value = ascii_lower(trim_value(raw_value)); + if (value == "inherit") { + value = node.parent == nullptr ? std::string{} + : node.parent->style.textual().user_select; + } else if (value == "initial" || value == "unset" || value == "revert" + || value == "revert-layer") { + value.clear(); + } + if (!value.empty() && value != "auto" && value != "text" + && value != "none" && value != "all") { + decision.classification = "invalid-authoring"; + return true; + } + node.style.mutable_textual().user_select = value == "auto" + ? std::string{} : std::move(value); + 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 974cb2fe..6d11065f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -239,6 +239,9 @@ inline void apply_all_unset( reset.mutable_textual().object_position = previous.textual().object_position; } + if (has_inline({"user-select", "-webkit-user-select", "-ms-user-select"})) { + reset.mutable_textual().user_select = previous.textual().user_select; + } // 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 2458db4d..04b2bf78 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h @@ -9,6 +9,7 @@ struct specified_property_sample final { std::string_view name; std::string_view inline constexpr auto specified_property_samples = std::to_array({ {"all","unset"},{"content","\"x\""},{"aspect-ratio","16 / 9"}, {"object-fit","cover"},{"object-position","right 25%"}, + {"user-select","none"}, {"width","12px"},{"height","20%"},{"min-width","1rem"},{"min-height","2px"}, {"max-width","none"},{"max-height","fit-content"}, {"left","1px"},{"top","2px"},{"right","3px"},{"bottom","4px"},{"inset","1px 2px 3px 4px"}, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 56ede843..2d87f256 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -793,6 +793,9 @@ struct node_style final { // `fill` and `50% 50%` values without enlarging the hot node record. std::string object_fit; std::string object_position; + // Empty is `auto`; used-value resolution walks ancestors only during + // the uncommon native text-selection default action. + std::string user_select; std::string contain_value; std::string container_type{"normal"}; std::string container_name{"none"}; 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 50599e32..132fb69d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -345,6 +345,7 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + textual->overflow_wrap.capacity() + 1U + textual->object_fit.capacity() + 1U + textual->object_position.capacity() + 1U + + textual->user_select.capacity() + 1U + textual->cursor.capacity() + 1U + textual->svg_fill.capacity() + 1U + textual->svg_stroke.capacity() + 1U 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 2c4d0fa6..c32dbfa8 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 @@ -736,6 +736,7 @@ std::string_view{"opacity"}, std::string_view{"object-fit"}, std::string_view{"object-position"}, + std::string_view{"user-select"}, std::string_view{"pointer-events"}, std::string_view{"visibility"}, std::string_view{"-webkit-font-smoothing"} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_highlights.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_highlights.inc index f4df2a7f..2ee6d532 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_highlights.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_highlights.inc @@ -144,7 +144,6 @@ constexpr auto selection_priority = std::numeric_limits::max(); uint32_t selection_order = 0U; for (auto& [root_id, selection] : window_selections) { - if (selection_rules.empty()) break; auto* root = document.find_by_native_id(root_id); if (root == nullptr || !validate_selection(selection) || (selection.anchor_node_id == selection.focus_node_id @@ -183,38 +182,42 @@ matching.push_back(entry.rule); } } - if (matching.empty()) continue; std::stable_sort( matching.begin(), matching.end(), [](const css_rule* left, const css_rule* right) { return css::cascade_rule_precedes(left, right, false); }); resolved_highlight_style style; - const css::cascaded_rule_order cascade(matching); - css::for_each_cascaded_declaration( - cascade, - false, - [&](const css_declaration& declaration) { - const auto foreground = declaration.name == "color"; - const auto background = declaration.name == "background-color"; - if (!foreground && !background) return; - auto resolved = declaration.value.find("var(") != std::string::npos - ? resolve_value(*origin_node, declaration.value, css_variables) - : declaration.value; - const auto color = native_document::parse_color(resolved); - if (!css::is_explicit_color_token(resolved, color)) return; - if (foreground) { - style.foreground_rgba = color; - style.foreground_specified = true; - } else { - style.background_rgba = color; - style.background_specified = true; - } - }, - [](const css_declaration& declaration) -> std::string_view { - return declaration.name; - }); - if (!style.foreground_specified && !style.background_specified) continue; + style.foreground_rgba = css::system_color_rgba("HighlightText") + .value_or(0xffffffffU); + style.background_rgba = css::system_color_rgba("Highlight") + .value_or(0x0067c0ffU); + style.foreground_specified = true; + style.background_specified = true; + if (!matching.empty()) { + const css::cascaded_rule_order cascade(matching); + css::for_each_cascaded_declaration( + cascade, + false, + [&](const css_declaration& declaration) { + const auto foreground = declaration.name == "color"; + const auto background = declaration.name == "background-color"; + if (!foreground && !background) return; + auto resolved = declaration.value.find("var(") != std::string::npos + ? resolve_value(*origin_node, declaration.value, css_variables) + : declaration.value; + const auto color = native_document::parse_color(resolved); + if (!css::is_explicit_color_token(resolved, color)) return; + if (foreground) { + style.foreground_rgba = color; + } else { + style.background_rgba = color; + } + }, + [](const css_declaration& declaration) -> std::string_view { + return declaration.name; + }); + } const auto text_length = decode_wtf8_to_utf16(text->text_content).size(); const auto start_offset = iterator == first ? std::min(first_offset, text_length) : 0U; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index e9dcb2d0..8c3c411e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -377,6 +377,8 @@ frame_reload_reflected_sources.clear(); frame_event_listeners.clear(); window_selections.clear(); + pointer_text_selection_active = false; + pointer_selection_root_id = 0U; clear_highlight_bindings(); for (auto& binding : range_bindings) { if (binding != nullptr) binding->wrapper.Reset(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index 6a82088e..ffbb8c1e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -259,6 +259,8 @@ frame_reload_reflected_sources.clear(); frame_event_listeners.clear(); window_selections.clear(); + pointer_text_selection_active = false; + pointer_selection_root_id = 0U; clear_highlight_bindings(); for (auto& binding : range_bindings) { if (binding != nullptr) binding->wrapper.Reset(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 902373aa..8910b296 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -729,6 +729,8 @@ uint64_t input_callback_invocation_count{0}; dom_node* pointer_capture_target{nullptr}; dom_node* pointer_down_target{nullptr}; + uint32_t pointer_selection_root_id{0U}; + bool pointer_text_selection_active{false}; uint32_t middle_paste_target_id{0U}; bool outbound_drag_threshold_crossed{false}; dom_node* active_pointer_target{nullptr}; 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 07aae2ec..36729bc9 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -259,6 +259,12 @@ if (auto* textual = node.style.mutable_textual_if_present()) { textual->object_position.clear(); } + } else if (name == "userSelect" || name == "user-select" + || name == "webkitUserSelect" || name == "WebkitUserSelect" + || name == "msUserSelect" || name == "MsUserSelect") { + if (auto* textual = node.style.mutable_textual_if_present()) { + textual->user_select.clear(); + } } else if (name == "boxSizing" || name == "box-sizing") { node.style.border_box = false; node.style.inline_property_mask &= ~inline_box_sizing; @@ -959,6 +965,12 @@ std::string semantic_slice; } decision; css::apply_replaced_value(*node, name, value, decision); + } else if (name == "user-select") { + struct interaction_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + css::apply_user_select_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; @@ -1567,6 +1579,9 @@ } else if (name == "object-position") { value = node->style.textual().object_position.empty() ? "50% 50%" : node->style.textual().object_position; + } else if (name == "user-select") { + value = node->style.textual().user_select.empty() + ? "auto" : node->style.textual().user_select; } else if (name == "cursor") { value = resolved_cursor(*node); } else if (name == "font-size") { @@ -2030,6 +2045,11 @@ } else if (name == "objectPosition") { value = node->style.textual().object_position.empty() ? "50% 50%" : node->style.textual().object_position; + } else if (name == "userSelect" || name == "webkitUserSelect" + || name == "WebkitUserSelect" || name == "msUserSelect" + || name == "MsUserSelect") { + value = node->style.textual().user_select.empty() + ? "auto" : node->style.textual().user_select; } else if (name == "boxSizing") { value = node->style.border_box ? "border-box" : "content-box"; } else if (name == "borderRadius") { @@ -2609,6 +2629,14 @@ : std::string{"object-position"}, value, decision); + } else if (name == "userSelect" || name == "webkitUserSelect" + || name == "WebkitUserSelect" || name == "msUserSelect" + || name == "MsUserSelect") { + struct interaction_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + css::apply_user_select_value(*node, "user-select", 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 8aae6071..6c0cc4ba 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc @@ -659,6 +659,10 @@ bool valid_cssom_declaration_value(std::string_view property_name, std::string_v || normalized_value == "cover" || normalized_value == "none" || normalized_value == "scale-down"; } + if (canonical_name == "user-select") { + return normalized_value == "auto" || normalized_value == "text" + || normalized_value == "none" || normalized_value == "all"; + } 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 c8f7bfd4..34a1e8fd 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -3758,6 +3758,136 @@ } } + std::pair used_user_select(dom_node& target) + { + for (auto* node = target.kind == dom_node_kind::text + ? target.parent : ⌖ + node != nullptr; node = node->parent) { + const auto& value = node->style.textual().user_select; + if (value == "none" || value == "text" || value == "all") { + return {value, node}; + } + } + return {"text", target.kind == dom_node_kind::text + ? target.parent : &target}; + } + + dom_node* pointer_text_node_at(dom_node& hit, float x, float y) + { + if (hit.kind == dom_node_kind::text && !hit.text_content.empty()) { + return &hit; + } + dom_node* fallback = nullptr; + const auto find = [&](const auto& recurse, dom_node& node) -> dom_node* { + if (!node.visible || node.style.display == display_mode::none) return nullptr; + if (node.kind == dom_node_kind::text && !node.text_content.empty()) { + if (fallback == nullptr) fallback = &node; + const auto& box = node.layout; + if (x >= box.x && x <= box.x + box.width + && y >= box.y && y <= box.y + box.height) return &node; + for (const auto& fragment : node.text_layout_fragments) { + if (x >= fragment.x && x <= fragment.x + fragment.width + && y >= fragment.y && y <= fragment.y + fragment.height) { + return &node; + } + } + return nullptr; + } + for (auto* child : node.children) { + if (child == nullptr) continue; + if (auto* result = recurse(recurse, *child); result != nullptr) { + return result; + } + } + return nullptr; + }; + if (auto* exact = find(find, hit); exact != nullptr) return exact; + return fallback; + } + + bool begin_pointer_text_selection( + const webscene_input_event& input, dom_node& target) + { + auto [policy, boundary] = used_user_select(target); + if (policy == "none" || boundary == nullptr) return true; + for (auto* node = ⌖ node != nullptr; node = node->parent) { + if (node->tag == "input" || node->tag == "textarea" + || node->tag == "select" || node->tag == "button") return true; + if (const auto draggable = node->attributes.find("draggable"); + draggable != node->attributes.end() && draggable->second == "true") { + return true; + } + } + bool prevented = false; + if (!dispatch_input_event_type(input, "selectstart", target, &prevented)) { + return false; + } + if (prevented) return true; + auto* root = css_cascade_root_for_node(target); + if (root == nullptr) return true; + auto& selection = window_selections.try_emplace(root->id).first->second; + selection.root_id = root->id; + refresh_selection_text_nodes(selection, *root); + if (policy == "all") { + std::vector selected; + const auto collect = [&](const auto& recurse, dom_node& node) -> void { + if (!node.visible || node.style.display == display_mode::none) return; + if (node.kind == dom_node_kind::text && !node.text_content.empty()) { + selected.push_back(&node); + } + for (auto* child : node.children) { + if (child != nullptr) recurse(recurse, *child); + } + }; + collect(collect, *boundary); + if (selected.empty()) return true; + selection.has_range = true; + selection.anchor_node_id = selected.front()->id; + selection.anchor_offset = 0U; + selection.focus_node_id = selected.back()->id; + selection.focus_offset = decode_wtf8_to_utf16( + selected.back()->text_content).size(); + pointer_text_selection_active = false; + pointer_selection_root_id = 0U; + selection_paint_changed(); + return true; + } + auto* text = pointer_text_node_at( + target, static_cast(input.x), static_cast(input.y)); + if (text == nullptr) return true; + const auto offset = document.text_caret_offset_at_x( + *text, static_cast(input.x)); + selection.has_range = true; + selection.anchor_node_id = text->id; + selection.focus_node_id = text->id; + selection.anchor_offset = offset; + selection.focus_offset = offset; + pointer_selection_root_id = root->id; + pointer_text_selection_active = true; + selection_paint_changed(); + return true; + } + + void update_pointer_text_selection( + const webscene_input_event& input, dom_node& hit) + { + if (!pointer_text_selection_active) return; + const auto known = window_selections.find(pointer_selection_root_id); + if (known == window_selections.end()) return; + auto* text = pointer_text_node_at( + hit, static_cast(input.x), static_cast(input.y)); + if (text == nullptr || css_cascade_root_for_node(*text) == nullptr + || css_cascade_root_for_node(*text)->id != pointer_selection_root_id + || used_user_select(*text).first == "none") return; + auto& selection = known->second; + const auto offset = document.text_caret_offset_at_x( + *text, static_cast(input.x)); + if (selection.focus_node_id == text->id && selection.focus_offset == offset) return; + selection.focus_node_id = text->id; + selection.focus_offset = offset; + selection_paint_changed(); + } + bool dispatch_input(const webscene_input_event& input, bool defer_cursor_update = false) { struct user_input_dispatch_scope { @@ -4263,6 +4393,17 @@ report_input_exception(); return false; } + if (input.kind == WEBSCENE_INPUT_POINTER_DOWN + && changed_button == 0 && !pointer_focus_default_prevented + && !begin_pointer_text_selection(input, *target)) { + last_error = "Native selectstart dispatch failed"; + return false; + } + if ((input.kind == WEBSCENE_INPUT_POINTER_MOVE + || input.kind == WEBSCENE_INPUT_POINTER_UP) + && pointer_text_selection_active) { + update_pointer_text_selection(input, *hit_target); + } if (input.kind == WEBSCENE_INPUT_WHEEL && !wheel_default_prevented) { const auto discrete_wheel = (input.flags & WEBSCENE_INPUT_WHEEL_DISCRETE) != 0U; @@ -4519,6 +4660,8 @@ } } if (input.kind == WEBSCENE_INPUT_POINTER_UP) { + pointer_text_selection_active = false; + pointer_selection_root_id = 0U; pointer_capture_target = nullptr; pointer_down_target = nullptr; middle_paste_target_id = 0U; 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 37f016c9..9fdc4a7f 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp @@ -98,10 +98,11 @@ int main() static_assert(static_cast(css_property_id::custom) == 1U); static_assert(static_cast(css_property_id::contain_intrinsic_size) == 145U); static_assert(static_cast(css_property_id::object_position) == 148U); - static_assert(native_typed_property_identity_catalog.size() == 206U); + static_assert(static_cast(css_property_id::user_select) == 149U); + static_assert(native_typed_property_identity_catalog.size() == 220U); static_assert(native_storage_only_property_catalog.size() == 62U); - static_assert(cssom_supported_property_catalog.size() == 241U); - static_assert(cssom_style_template_property_accessor_count == 445U); + static_assert(cssom_supported_property_catalog.size() == 249U); + static_assert(cssom_style_template_property_accessor_count == 455U); 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 cce80d7e..72c19d52 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 @@ -154,6 +154,48 @@ void test_object_fit_scene_performance_gate(webscene_engine* engine) + std::to_string(elapsed) + " ms"); } +void test_user_select_performance_gate(webscene_engine* engine) +{ + resize(engine, 1024, 768, 1U); + execute(engine, R"JS( + document.body.innerHTML = `
`; + globalThis.selectionGrid = document.getElementById('selection-grid'); + const fragment = document.createDocumentFragment(); + for (let index = 0; index < 4096; ++index) { + const span = document.createElement('span'); + span.textContent = `item-${index} `; + fragment.appendChild(span); + } + selectionGrid.appendChild(fragment); + )JS", "native-user-select-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( + (() => { + let checksum = ''; + for (let cycle = 0; cycle < 10; ++cycle) { + selectionGrid.className = cycle % 2 === 0 ? 'none' : 'text'; + checksum += getComputedStyle(selectionGrid.lastElementChild).userSelect[0]; + } + return [selectionGrid.children.length, checksum.length]; + })() + )JS", "native-user-select-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 == "[4096,10]", "user-select performance fixture regressed: " + result); + require(after.layout_passes == before.layout_passes, + "paint-only user-select transitions performed layout"); + require(elapsed < 5000.0, + "4,096-node user-select 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 ab7746e9..73549b62 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 @@ -97,6 +97,63 @@ void animation_frame(webscene_engine* engine, double timestamp, uint64_t sequenc require(webscene_engine_enqueue(engine, &input) != 0, "animation frame was rejected"); } +void test_user_select_pointer_default_action(webscene_engine* engine) +{ + resize(engine, 500, 180, 7901U); + execute(engine, R"JS( + document.body.innerHTML = ` +
blocked value
+
selectable value
+
atomic value
+
cancelled value
`; + globalThis.selectStarts = 0; + document.addEventListener('selectstart', event => { + ++selectStarts; + if (event.target.id === 'cancel') event.preventDefault(); + }); + )JS", "native-user-select-setup.js"); + + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 2, 45, 7902U, true); + pointer_move(engine, 100, 45, 7903U, true); + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 100, 45, 7904U, false); + const auto text = evaluate(engine, + "JSON.stringify([getSelection().toString(),selectStarts])", + "native-user-select-text.js"); + require(text.find("selectable") != std::string::npos + && text.ends_with(",1]"), + "user-select:text pointer drag did not create one selection: " + text); + + execute(engine, "getSelection().removeAllRanges()", + "native-user-select-clear.js"); + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 2, 15, 7905U, true); + pointer_move(engine, 100, 15, 7906U, true); + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 100, 15, 7907U, false); + require(evaluate(engine, "getSelection().toString()", + "native-user-select-none.js").empty(), + "user-select:none admitted a native pointer selection"); + + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 30, 75, 7908U, true); + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 30, 75, 7909U, false); + require(evaluate(engine, "getSelection().toString()", + "native-user-select-all.js") == "atomic value", + "user-select:all did not atomically select its subtree"); + + execute(engine, "getSelection().removeAllRanges()", + "native-user-select-clear-cancel.js"); + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 30, 105, 7910U, true); + pointer_move(engine, 100, 105, 7911U, true); + pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 100, 105, 7912U, false); + require(evaluate(engine, "getSelection().toString()", + "native-user-select-cancel.js").empty(), + "canceled selectstart still created a pointer selection"); +} + void animation_frame_and_wait( webscene_engine* engine, double timestamp, 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 dab78985..158e1c8f 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -397,6 +397,14 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "user-select-performance") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "user-select performance engine creation failed"); + test_user_select_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"); @@ -1485,6 +1493,7 @@ int main() test_appending_child_invalidates_empty_selector(engine); 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_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 96fccf20..ba7ab4da 100644 --- a/src/WebScene.Css/CssPropertyCatalog.cs +++ b/src/WebScene.Css/CssPropertyCatalog.cs @@ -84,6 +84,9 @@ public static bool IsValidCssomValue(string propertyName, string value) or "-webkit-sticky", "object-fit" => normalizedValue is "fill" or "contain" or "cover" or "none" or "scale-down", "object-position" => IsObjectPosition(normalizedValue), + "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", "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", diff --git a/src/WebScene.Css/CssPropertyMetadata.Generated.cs b/src/WebScene.Css/CssPropertyMetadata.Generated.cs index 90ef936c..883d99b0 100644 --- a/src/WebScene.Css/CssPropertyMetadata.Generated.cs +++ b/src/WebScene.Css/CssPropertyMetadata.Generated.cs @@ -115,11 +115,13 @@ internal static class CssGeneratedPropertyMetadata "aspect-ratio", "object-fit", "object-position", + "user-select", ]; internal static readonly string[] SupportedNames = [ "-moz-transform", + "-ms-user-select", "-webkit-font-smoothing", "-webkit-mask", "-webkit-mask-composite", @@ -128,6 +130,7 @@ internal static class CssGeneratedPropertyMetadata "-webkit-mask-repeat", "-webkit-mask-size", "-webkit-transform", + "-webkit-user-select", "accent-color", "align-content", "align-items", @@ -300,6 +303,7 @@ internal static class CssGeneratedPropertyMetadata "min-height", "min-width", "moz-transform", + "ms-user-select", "object-fit", "object-position", "opacity", @@ -351,10 +355,12 @@ internal static class CssGeneratedPropertyMetadata "transition-duration", "transition-property", "transition-timing-function", + "user-select", "vertical-align", "visibility", "webkit-font-smoothing", "webkit-transform", + "webkit-user-select", "white-space", "widows", "width", @@ -479,6 +485,7 @@ internal static bool TryGetKnownId(string name, out int id) "aspect-ratio" => 107, "object-fit" => 108, "object-position" => 109, + "user-select" => 110, _ => -1, }; return id >= 0; diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index d3caab53..bc0437d9 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -210,6 +210,13 @@ "coverage": ["contracts/html-raster-image-intrinsic-size.html", "contracts/css-object-fit-position.html", "test_image_elements_load_and_reach_scene", "test_object_fit_scene_performance_gate", "appscene_compositor_opacity encoded-raster regression"], "gap": "The native pipeline preserves encoded raster bytes for the platform Skia decoder, parses intrinsic dimensions for PNG, JPEG, GIF, and WebP, and now authors provider-neutral object-fit/object-position destination and content-clip commands for SVG and raster images. Direct native/browser execution, exact raster/SVG pixels, and the 4,096-image paint-only performance gate remain qualification debt for WebScene #789. Animated image playback, srcset/sizes selection, picture/source negotiation, decoding hints, and CSS raster background images remain outside this bounded claim." }, + { + "family": "user-text-selection", + "required": ["user-select auto/text/none/all", "WebKit/MS aliases", "pointer drag Selection", "cancelable selectstart", "atomic all subtree", "paint-only mutation"], + "productEvidence": ["Monaco native selection policy", "lists and dialogs", "terminal and notebooks", "Chat and webview content"], + "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": "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-user-select-interaction.html b/tests/WebPlatformSubset/contracts/css-user-select-interaction.html new file mode 100644 index 00000000..60b66a7b --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-user-select-interaction.html @@ -0,0 +1,34 @@ + + +User selection policy retains browser-shaped CSSOM + + + +
blocked text
+
selectable text
+
atomic text
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index a8e2d756..fd9862eb 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2829,6 +2829,13 @@ "capabilities": ["object-fit-replaced-image-paint", "object-position-alignment", "intrinsic-image-ratio", "content-box-clipping", "object-fit-cssom-mutation"], "evidence": ["focused-native-scene-contract-authored", "direct-chromium-contract-authored", "unchanged-code-oss-645f29c-inventory"], "reason": "Implementation-first WebScene #789 contract for the replaced-image fit and alignment used by unchanged Code OSS avatars, account imagery, thumbnails, extension surfaces, and session UI. It covers initial/computed CSSOM, inline mutation/removal, intrinsic dimensions, and stable element geometry; the native companion records cover/contain destination and clip commands. Direct browser/native execution, raster/SVG pixels, exact product regions, and performance evidence remain qualification debt." + }, + { + "path": "contracts/css-user-select-interaction.html", + "type": "testharness", + "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." } ], "harnessBlocked": [], diff --git a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs index ffd9ada3..e80e38de 100644 --- a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs @@ -53,6 +53,8 @@ public void DoesNotMisclassifyNonColorValueProperties(string propertyName) [InlineData("wordWrap")] [InlineData("objectFit")] [InlineData("object-position")] + [InlineData("userSelect")] + [InlineData("webkitUserSelect")] public void ExposesSupportedCssomProperties(string name) => Assert.True(CssPropertyCatalog.IsSupported(name)); @@ -110,6 +112,9 @@ public void DoesNotExposeUnknownOrCustomPropertiesAsIdlAttributes(string name) [InlineData("object-position", "left right", false)] [InlineData("object-position", "top bottom", false)] [InlineData("object-position", "left middle", false)] + [InlineData("user-select", "none", true)] + [InlineData("-webkit-user-select", "text", true)] + [InlineData("user-select", "toggle", false)] public void ValidatesCssomValuesWithoutFrameworkKnowledge(string name, string value, bool expected) => Assert.Equal(expected, CssPropertyCatalog.IsValidCssomValue(name, value)); }