diff --git a/docs/validation/css-user-validity-state-20260919.md b/docs/validation/css-user-validity-state-20260919.md new file mode 100644 index 00000000..6920b71c --- /dev/null +++ b/docs/validation/css-user-validity-state-20260919.md @@ -0,0 +1,56 @@ +# CSS user-validity state source contract + +## Scope and baseline + +This implementation-first slice starts from exact WebScene `main` +`c45cd36ce59c264206aace5d0d3f524e8ded675c`. The issue #257 audit found that +`:enabled` / `:disabled`, `:checked` / `:indeterminate`, and `:in-range` / +`:out-of-range` already have matcher and dependency paths. The selector parser +recognized `:user-valid` and `:user-invalid`, but both fell through matching and +there was no user-interaction state or invalidation dependency. + +The implemented boundary adds one non-reflected boolean to the existing cold +form-control record. Native text edits and accessibility value actions set it. +JavaScript `value`, `defaultValue`, textarea child text, and attribute writes do +not create user interaction; after interaction they may change whether the +control is user-valid or user-invalid. Reset through the current form owner +clears the state. Detach and reparent preserve the element-owned state. + +Both user pseudos consume the same current live-value validity classification +as `:valid` and `:invalid`. This slice therefore does not claim a broader +constraint-validation implementation. Checkbox/radio required-value rules, +select placeholder rules, pattern/type/step mismatch, date/time range types, +form/fieldset aggregation, submit-attempt state, external `form=` reset, and +the constraint-validation IDL remain later #257 work. + +## Invalidation and ownership + +The selector dependency compiler records `$live-form-user-validity` for the +two user pseudos alongside their existing `required`, `value`, live-value, and +textarea-child dependencies. The existing live form-value transition compares +the old and new value classification and user-interaction bit in one checkpoint. +It recascades through the compiled selector route only when one of those inputs +changes. Programmatic nonempty-to-nonempty writes after interaction remain a +paint/layout dirty operation without unnecessary user-validity selector work. + +No child-list implementation is added. Textarea default-value changes continue +to reuse the merged structural route, and detach/reparent use the existing DOM +mutation checkpoints. Ordinary DOM nodes retain no new inline field because +the boolean remains in pay-for-use `form_control_data`. + +## Authored source contracts + +- `tests/WebPlatformSubset/contracts/css-user-validity-state.html` derives a + browser-shaped native-input sequence from CSS UI user-validity behavior and + checks selector APIs, computed style, programmatic writes, required mutation, + detach/reparent, and owner-specific reset. +- The existing `live-form-state` native gate now checks the same distinctions + using the host keyboard-input path beside its 4,096-control bounded + invalidation gate. + +## Validation status + +Per the task constraint, no build, browser/WPT run, native test, benchmark, +scene/pixel check, product check, package gate, or CI job was run. Only +`git diff --check` is used for this commit. The new profile entry remains a +candidate until direct Chromium and native execution establish parity. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_compound.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_compound.h index 2607f13d..b4593ab2 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_compound.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_compound.h @@ -317,20 +317,15 @@ inline bool compound_matches(const Host& host,const dom_node& node, } else if (name == "read-only") { if (css::is_read_write(document,node)) return false; } else if (name == "valid") { - if (!form_control || node.tag == "fieldset" || node.tag == "optgroup" - || node.tag == "option") return false; - if (node.attributes.contains("required")) { - if (forms::supports_text_selection(&node) - ? forms::text_value_empty(node) - : !node.attributes.contains("value") - || node.attributes.at("value").empty()) return false; - } + if (forms::validity_state(node) != forms::simple_validity_state::valid) return false; } else if (name == "invalid") { - if (!form_control || !node.attributes.contains("required")) return false; - if (forms::supports_text_selection(&node) - ? !forms::text_value_empty(node) - : node.attributes.contains("value") - && !node.attributes.at("value").empty()) return false; + if (forms::validity_state(node) != forms::simple_validity_state::invalid) return false; + } else if (name == "user-valid") { + if (!node.form_control().user_validity_interacted + || forms::validity_state(node) != forms::simple_validity_state::valid) return false; + } else if (name == "user-invalid") { + if (!node.form_control().user_validity_interacted + || forms::validity_state(node) != forms::simple_validity_state::invalid) return false; } else if (name == "placeholder-shown") { if (!forms::supports_placeholder_selector(node) || !node.attributes.contains("placeholder") diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_invalidation.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_invalidation.h index a823241c..bae1cb04 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_invalidation.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_invalidation.h @@ -108,7 +108,8 @@ inline std::vector compile_invalidation_plan( output.child_list_sensitive = true; add(output.child_list, inherited_route); } else if (pseudo.name == "required" || pseudo.name == "optional" - || pseudo.name == "valid" || pseudo.name == "invalid") { + || pseudo.name == "valid" || pseudo.name == "invalid" + || pseudo.name == "user-valid" || pseudo.name == "user-invalid") { add(output.attributes["required"], route); if (pseudo.name == "required" || pseudo.name == "optional") { add(output.attributes["type"], route); @@ -121,6 +122,8 @@ inline std::vector compile_invalidation_plan( // text children. Reuse the structural route machinery. output.child_list_sensitive = true; add(output.child_list, route); + if (pseudo.name == "user-valid" || pseudo.name == "user-invalid") + add(output.attributes["$live-form-user-validity"], route); } } else if (pseudo.name == "placeholder-shown") { add(output.attributes["placeholder"], route); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 59e5b5e5..25704e75 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -1391,6 +1391,7 @@ struct dom_node final { bool indeterminate{false}; bool value_initialized{false}; bool dirty_value{false}; + bool user_validity_interacted{false}; bool input_focused{false}; bool caret_visible{false}; }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h index e98b887c..5db8d463 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_form_state.h @@ -10,6 +10,12 @@ enum class numeric_range_state : uint8_t { out_of_range, }; +enum class simple_validity_state : uint8_t { + not_applicable, + valid, + invalid, +}; + inline bool form_keyword_equals(std::string_view value,std::string_view expected) { if(value.size()!=expected.size()) return false; @@ -288,6 +294,22 @@ inline bool text_value_empty(const dom_node& node) { return attribute==node.attributes.end() || attribute->second.empty(); } +inline simple_validity_state validity_state(const dom_node& node) { + const auto form_control = node.tag == "button" || node.tag == "input" + || node.tag == "select" || node.tag == "textarea" + || node.tag == "option" || node.tag == "optgroup" || node.tag == "fieldset"; + if (!form_control || node.tag == "fieldset" || node.tag == "optgroup" + || node.tag == "option") return simple_validity_state::not_applicable; + if (node.attributes.contains("required")) { + const auto empty = supports_text_selection(&node) + ? text_value_empty(node) + : !node.attributes.contains("value") + || node.attributes.at("value").empty(); + if (empty) return simple_validity_state::invalid; + } + return simple_validity_state::valid; +} + inline bool supports_placeholder_selector(const dom_node& node) { return (node.tag=="input" || node.tag=="textarea") && supports_text_selection(&node); 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 39052fb7..ba86bdd5 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 @@ -2401,14 +2401,19 @@ void recascade_form_value_selector_transition( dom_node& subject, std::string previous_value, - std::string current_value) + std::string current_value, + std::optional user_validity_interacted = std::nullopt) { document.mark_dirty(); const auto empty_state_changed=previous_value.empty()!=current_value.empty(); const auto previous_range=forms::range_state(subject,previous_value); const auto current_range=forms::range_state(subject,current_value); const auto range_state_changed=previous_range!=current_range; - if(!empty_state_changed && !range_state_changed) return; + const auto previous_user_validity=subject.form_control().user_validity_interacted; + const auto current_user_validity=user_validity_interacted.value_or(previous_user_validity); + const auto user_validity_changed=previous_user_validity!=current_user_validity; + subject.mutable_form_control().user_validity_interacted=current_user_validity; + if(!empty_state_changed && !range_state_changed && !user_validity_changed) return; webscene_frame_trace::scope invalidation_trace( frame_trace, "form-state-invalidation-start", @@ -2424,6 +2429,7 @@ }; if(empty_state_changed) append_rules("$live-form-value"); if(range_state_changed) append_rules("$live-form-range"); + if(user_validity_changed) append_rules("$live-form-user-validity"); std::ranges::sort(changed_rule_storage); changed_rule_storage.erase( std::unique(changed_rule_storage.begin(),changed_rule_storage.end()), @@ -2441,10 +2447,19 @@ }; if(empty_state_changed) include("$live-form-value"); if(range_state_changed) include("$live-form-range"); + if(user_validity_changed) include("$live-form-user-validity"); return result; }, - [&] { subject.mutable_form_control().value = previous_value; }, - [&] { subject.mutable_form_control().value = current_value; }); + [&] { + auto& control=subject.mutable_form_control(); + control.value=previous_value; + control.user_validity_interacted=previous_user_validity; + }, + [&] { + auto& control=subject.mutable_form_control(); + control.value=current_value; + control.user_validity_interacted=current_user_validity; + }); } void recascade_class_selector_transition( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc index 66422c33..9718af85 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc @@ -3676,7 +3676,9 @@ } if (self->is_connected(node)) { self->recascade_form_value_selector_transition( - node, previous_value, node.form_control().value); + node, previous_value, node.form_control().value, false); + } else { + node.mutable_form_control().user_validity_interacted = false; } } for (auto* child : node.children) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc index 795f5f1e..256902cc 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc @@ -1000,7 +1000,7 @@ node.mutable_form_control().value_initialized = true; node.mutable_form_control().dirty_value = true; recascade_form_value_selector_transition( - node, previous, node.form_control().value); + node, previous, node.form_control().value, true); webscene_input_event synthetic{}; if (!dispatch_input_event_type(synthetic, "input", node) || !dispatch_input_event_type(synthetic, "change", node)) { @@ -1086,7 +1086,7 @@ text_selection_direction::none; node->mutable_form_control().selection_explicitly_set = true; recascade_form_value_selector_transition( - *node, previous, node->form_control().value); + *node, previous, node->form_control().value, true); succeeded = dispatch_input_event_type(synthetic, "input", *node); break; } 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 34a168e4..5e46c7d7 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2824,7 +2824,7 @@ target->mutable_form_control().caret_visible = true; caret_blink_epoch_ms = last_animation_frame_timestamp_ms; recascade_form_value_selector_transition( - *target, previous_value, target->form_control().value); + *target, previous_value, target->form_control().value, true); return dispatch_input_event_type(input, "input", *target); } @@ -3076,7 +3076,7 @@ target->mutable_form_control().selection_direction = text_selection_direction::none; target->mutable_form_control().selection_explicitly_set = true; recascade_form_value_selector_transition( - *target, previous_value, target->form_control().value); + *target, previous_value, target->form_control().value, true); if (!dispatch_input_event_type(input, "input", *target)) return false; } } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc index c928db96..1d396093 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_css_invalidation_tests.inc @@ -9,9 +9,11 @@ void test_live_form_state_selectors_and_scaling() const auto correctness = evaluate(engine, R"JS((() => { document.body.innerHTML = `
`; const form=document.getElementById('form'); const textarea=document.createElement('textarea'); @@ -54,20 +56,40 @@ void test_live_form_state_selectors_and_scaling() const auto native_setup = evaluate(engine, R"JS((() => { const input=document.createElement('input'); input.id='native-value'; input.className='probe'; input.setAttribute('required',''); input.setAttribute('placeholder','type'); - document.body.appendChild(input); input.focus(); return [ + document.getElementById('form').appendChild(input); input.focus(); return [ input.matches(':invalid'),input.matches(':placeholder-shown'), - getComputedStyle(input).paddingLeft,getComputedStyle(input).paddingTop]; + input.matches(':user-valid'),input.matches(':user-invalid'), + getComputedStyle(input).paddingLeft,getComputedStyle(input).paddingTop, + getComputedStyle(input).marginLeft,getComputedStyle(input).marginTop]; })())JS", "live-form-state-native-setup.js"); - require(native_setup == R"([true,true,"47px","83px"])", + require(native_setup == R"([true,true,false,false,"47px","83px","5px","7px"])", "native form-state setup diverged: " + native_setup); keyboard_input(engine, WEBSCENE_INPUT_TEXT, 'x', 2U); wait_for_consumed_inputs(engine, 2U, "native form-state input was not consumed"); const auto native_result = evaluate(engine, R"JS((() => {const input=document.getElementById('native-value');return [ input.value,input.matches(':valid'),input.matches(':invalid'), - input.matches(':placeholder-shown'),getComputedStyle(input).paddingLeft, - getComputedStyle(input).paddingTop];})())JS", "live-form-state-native-result.js"); - require(native_result == R"(["x",true,false,false,"19px","17px"])", + input.matches(':placeholder-shown'),input.matches(':user-valid'), + input.matches(':user-invalid'),getComputedStyle(input).paddingLeft, + getComputedStyle(input).paddingTop,getComputedStyle(input).marginLeft, + getComputedStyle(input).marginTop];})())JS", "live-form-state-native-result.js"); + require(native_result == R"(["x",true,false,false,true,false,"19px","17px","43px","7px"])", "native input did not invalidate live form state: " + native_result); + const auto user_validity_lifecycle = evaluate(engine, R"JS((() => { + const input=document.getElementById('native-value'),first=document.getElementById('form'); + const snapshot=()=>{const style=getComputedStyle(input);return [ + input.matches(':valid'),input.matches(':invalid'),input.matches(':user-valid'), + input.matches(':user-invalid'),style.marginLeft,style.marginTop];}; + input.value='';const states=[snapshot()]; + input.removeAttribute('required');states.push(snapshot()); + input.setAttribute('required','');input.remove();states.push([ + input.matches(':valid'),input.matches(':invalid'),input.matches(':user-valid'), + input.matches(':user-invalid')]); + const second=document.createElement('form');document.body.appendChild(second);second.appendChild(input); + first.reset();states.push(snapshot());second.reset();states.push(snapshot()); + return states; + })())JS", "live-form-user-validity-lifecycle.js"); + require(user_validity_lifecycle == R"JSON([[false,true,false,true,"5px","61px"],[true,false,true,false,"43px","7px"],[false,true,false,true],[false,true,false,true,"5px","61px"],[false,true,false,false,"5px","7px"]])JSON", + "live user-validity lifecycle diverged: " + user_validity_lifecycle); const auto setup = evaluate(engine, R"JS((() => { document.body.innerHTML=' +
+
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index d379e957..4e141ca7 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -1310,6 +1310,13 @@ } ], "candidate": [ + { + "path": "contracts/css-user-validity-state.html", + "type": "testharness", + "capabilities": ["user-valid-selector", "user-invalid-selector", "live-user-validity-invalidation"], + "evidence": ["wpt-css-ui-user-validity-derived", "native-source-companion-contract"], + "reason": "Implementation-first candidate for WebScene #257. The focused text-control slice keeps user validity as non-reflected live state: native input marks it, programmatic value writes preserve it without creating it, required/value changes recalculate matching, detach/reparent retains it, and reset by the current form owner clears it. Direct browser/native execution remains validation debt." + }, { "path": "contracts/css-grid-auto-fill-minmax-custom-property.html", "type": "testharness",