Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/validation/css-registered-custom-property-animation-748.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Registered custom-property animation contract (#748)

WebScene retains document-level `@property` registrations for the `<angle>` and
`<percentage>` grammars used by unchanged Code OSS. Registrations are bounded to
64 per document; names and values are bounded to 128 bytes. A keyframe definition
retains at most eight registered custom-property tracks and 64 stops per track,
inside the existing eight-animation admission budget.

The shared cssparser stream now emits `@property` descriptors as declarations.
The prepared stylesheet and live runtime retain syntax, inheritance, initial
value, owner identity, and typed keyframe stops. Invalid or incomplete rules are
rejected atomically. Non-inheriting registrations materialize their initial value
only on elements whose declarations consume them; computed style supplies the
same initial value without allocating custom-property state on unrelated nodes.
Invalid authored values remain visible through inline CSSOM and compute to the
registered initial value.

The host-clock sampler interpolates registered values only while an admitted
track contributes. It updates the computed custom-property value and retained
background/mask/border-image tokens only when the serialized sample changes.
Paused and settled tracks request no idle frames. Stylesheet removal, navigation,
cache reset, and document teardown release registrations and sampled track state.

Coverage:

- `webscene_css_parser_tests` proves the Rust syntax stream emits all three
descriptors for `@property`.
- `native_web_css_service` proves preparation, ownership, non-inheriting initial
values, zero allocation across 256 unrelated nodes, midpoint interpolation,
dependent gradient-token updates, and removal.
- `test_registered_custom_property_keyframes_update_dependent_paint` covers live
CSSOM identity, percentage/angle sampling, forwards fill, and computed paint.
- `css-registered-custom-property-animation.html` is the browser/native candidate
for CSSPropertyRule reflection and a deterministic paused midpoint.

Conic-gradient rasterization, border-image gradient rasterization, arbitrary
registered syntaxes, `CSS.registerProperty()`, and additive composition remain
separate paint and Properties & Values work.
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,7 @@ mod css_syntax {
let mut declarations = 0usize;
if prelude.name.eq_ignore_ascii_case("font-face")
|| prelude.name.eq_ignore_ascii_case("page")
|| prelude.name.eq_ignore_ascii_case("property")
{
declarations = parse_css_stream_declaration_list(
input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@ void apply_declaration(native_document& document,dom_node& node,
decision.semantic_slice="unresolved custom property at computed-value time";
return;
}
if (contains_variable
&& (declaration.name == "background"
|| declaration.name == "background-image"
|| declaration.name == "mask"
|| declaration.name == "mask-image"
|| declaration.name == "border-image")) {
node.style.mutable_custom_properties().dependent_values[
declaration.name] = declaration.value;
}
on_resolved(contains_variable);
apply_resolved_declaration(
document,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace webscene_native::css {
// Clear previously cascaded fields before matching a new rule set. Authored
// inline metadata remains owned by the node and is preserved/reseeded here.
inline void reset_cascaded_style(dom_node& node,
const std::unordered_map<std::string,std::string>& variables)
const std::unordered_map<std::string,std::string>& variables,
const std::unordered_map<std::string, registered_custom_property>* registrations = nullptr)
{
node.style.important_property_mask = 0;
node.style.important_margin_sides = 0;
Expand Down Expand Up @@ -232,7 +233,7 @@ inline void reset_cascaded_style(dom_node& node,
node.style.word_spacing = 0;
node.style.word_spacing_specified = false;
}
css::seed_inline_custom_properties(node);
css::seed_inline_custom_properties(node, registrations);
node.style.clear_pseudo_elements();
// Recompute stylesheet visibility from the current class/selector set.
// React reuses toolbar nodes while replacing their responsive classes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,42 @@ bool apply_native_cascade(native_document& document,dom_node& node,
if(declaration.important) authored.important_declarations.insert(declaration.name);
}
}
reset_cascaded_style(node,variables);
reset_cascaded_style(
node, variables, &sheets.state().registered_custom_properties);
auto indices=sheets.candidates(node,focused);
auto matched=match_candidates(document,node,sheets.state().rules,indices,
[&](const auto& subject,const auto& rule,const auto&) { return query.matches_prepared(subject,rule.payload->compiled_pseudo_origin); },
[&](const auto& subject,const auto& rule) { return query.matches_prepared(subject,rule.compiled_selector()); });
seed_relevant_registered_custom_properties(
node, sheets.state().registered_custom_properties,
[&](const std::string& name, const std::string& variable_token) {
const auto references = [&](const css_rule* rule) {
return std::any_of(
rule->declarations().begin(),
rule->declarations().end(),
[&](const css_declaration& declaration) {
return declaration.name == name
|| declaration.value.find(variable_token)
!= std::string::npos;
});
};
return std::any_of(
matched.ordinary.begin(), matched.ordinary.end(), references)
|| std::any_of(
matched.pseudo.begin(), matched.pseudo.end(),
[&](const auto& entry) { return references(entry.second); });
});
const cascaded_rule_order cascade_order(matched.ordinary);
apply_matched_declarations(node,cascade_order,[&](const css_declaration& declaration,bool inline_origin) {
property_result result;
apply_declaration(document,node,declaration,variables,inline_origin,result,load_svg,[](bool) {});
if (declaration.name.starts_with("--")) {
apply_custom_property(
node, declaration,
&sheets.state().registered_custom_properties);
result.classification = "supported";
} else {
apply_declaration(document,node,declaration,variables,inline_origin,result,load_svg,[](bool) {});
}
observe(declaration,result);
});
if(inline_attributes) {
Expand Down Expand Up @@ -94,14 +121,23 @@ bool apply_native_cascade(native_document& document,dom_node& node,
node.style.mutable_before_pseudo().layout=previous.before_pseudo().layout;
if(node.style.after_pseudo().generated && previous.after_pseudo().generated)
node.style.mutable_after_pseudo().layout=previous.after_pseudo().layout;
configure_keyframes(node.style,sheets.state().opacity_keyframes);
configure_keyframes(
node.style,
sheets.state().opacity_keyframes,
sheets.state().registered_custom_properties);
if(node.style.has_pseudo_elements()) {
auto& before=node.style.mutable_before_pseudo();
auto& after=node.style.mutable_after_pseudo();
if(before.has_animation_data())
configure_keyframes(before.mutable_animations(),sheets.state().opacity_keyframes);
configure_keyframes(
before.mutable_animations(),
sheets.state().opacity_keyframes,
sheets.state().registered_custom_properties);
if(after.has_animation_data())
configure_keyframes(after.mutable_animations(),sheets.state().opacity_keyframes);
configure_keyframes(
after.mutable_animations(),
sheets.state().opacity_keyframes,
sheets.state().registered_custom_properties);
}
document.update_style_animations(node);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,12 @@ void apply_pseudo_declaration(dom_node& node,node_style::pseudo_element& pseudo,
return;
}
const auto& name = declaration.name;
if (contains_variable
&& (name == "background" || name == "background-image"
|| name == "mask" || name == "mask-image"
|| name == "border-image")) {
pseudo.variable_dependent_values[name] = declaration.value;
}
on_resolved(contains_variable);
if (name == "font-family"
&& pseudo.font_family_important
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,28 @@ struct compiled_css_selector final {
};

struct css_opacity_keyframes final {
struct custom_property_stop final {
float offset{0};
std::string value;
};
std::vector<node_style::opacity_keyframe> opacity_stops;
std::vector<node_style::translation_keyframe> translation_stops;
std::vector<node_style::scale_keyframe> scale_stops;
std::vector<node_style::rotation_keyframe> rotation_stops;
std::vector<node_style::filter_keyframe> filter_stops;
std::unordered_map<std::string, std::vector<custom_property_stop>>
custom_property_stops;
};

enum class registered_property_syntax : uint8_t { angle, percentage };

struct registered_custom_property final {
std::string name;
registered_property_syntax syntax{registered_property_syntax::angle};
std::string initial_value;
float initial_number{0};
bool inherits{false};
uint32_t stylesheet_owner_id{0};
};

struct css_inheritance_candidate_rule final {
Expand Down Expand Up @@ -310,6 +327,8 @@ struct compiled_css_selector final {
std::vector<hover_selector_dependency> active_dependencies;
std::unordered_map<std::string, std::string> variables;
std::unordered_set<std::string> important_variables;
std::unordered_map<std::string, registered_custom_property>
registered_custom_properties;
std::unordered_map<std::string, uint32_t> cascade_layer_orders;
std::unordered_map<uint32_t, std::vector<std::string>>
stylesheet_layer_names;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ std::optional<prepared_stylesheet> prepare_stylesheet(std::string_view text,
output.cascade_layers.push_back(name);
return static_cast<uint32_t>(output.cascade_layers.size());
}
void register_custom_property(const registered_custom_property& property) {
if (output.registered_custom_properties.size() < 64U) {
output.registered_custom_properties.push_back(property);
}
}
void record_feature(std::string_view, const std::string& feature,
std::string_view classification, const std::string& detail, std::string_view) {
// Font bytes/registration are handled by a resource host, which this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct prepared_stylesheet {
std::string source_address;
std::vector<std::shared_ptr<const css_rule_payload>> rules;
std::unordered_map<std::string,css_opacity_keyframes> keyframes;
std::vector<registered_custom_property> registered_custom_properties;
std::vector<stylesheet_diagnostic> diagnostics;
// First-appearance order. Empty entries are distinct anonymous layers;
// named entries are unified by the consuming document across stylesheets.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ class stylesheet_owner {
}
for(const auto& [name,definition]:entry.sheet.keyframes)
state_.opacity_keyframes[name]=definition;
for(auto property:entry.sheet.registered_custom_properties) {
property.stylesheet_owner_id=entry.id;
state_.registered_custom_properties[property.name]=std::move(property);
}
}
}
public:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ class stylesheet_sink final : public css_syntax_sink {
if (name == "keyframes" || name == "-webkit-keyframes") {
current.keyframes = true;
current.prelude = std::string(prelude);
} else if (name == "property" && stack_.empty() && has_block) {
current.property_rule = true;
current.prelude = trim_value(prelude);
current.declarations.reserve(3U);
current.children_active = false;
} else if (name == "font-face") {
current.children_active = false;
owner_.record_feature(
Expand Down Expand Up @@ -185,7 +190,8 @@ class stylesheet_sink final : public css_syntax_sink {
if (stack_.empty()) return false;
auto& current = stack_.back();
++current.observed_declarations;
if (current.active && current.kind == css_syntax_style_rule) {
if (current.active
&& (current.kind == css_syntax_style_rule || current.property_rule)) {
append_declaration(
current.declarations, name, value, important);
}
Expand Down Expand Up @@ -251,6 +257,20 @@ class stylesheet_sink final : public css_syntax_sink {
completed_keyframes_.emplace_back(
std::move(current.prelude),
std::move(current.keyframe_definition));
} else if (current.property_rule) {
const auto parsed = parse_registered_property(current);
if (parsed.has_value()) {
owner_.register_custom_property(*parsed);
owner_.record_feature(
"css", "at-rule:@property", "supported",
"bounded <angle> and <percentage> registrations",
"stylesheet-parser");
} else {
owner_.record_feature(
"css", "at-rule:@property", "invalid-authoring",
"requires a custom name, syntax, inherits and typed initial-value",
"stylesheet-parser");
}
}
return true;
}
Expand Down Expand Up @@ -321,8 +341,86 @@ class stylesheet_sink final : public css_syntax_sink {
bool active{false};
bool children_active{false};
bool keyframes{false};
bool property_rule{false};
};

static std::optional<float> parse_typed_number(
std::string_view value,
registered_property_syntax syntax)
{
const auto source = ascii_lower(trim_value(value));
const auto unit = syntax == registered_property_syntax::angle
? std::string_view{"deg"} : std::string_view{"%"};
if (!source.ends_with(unit)) return std::nullopt;
const auto number = source.substr(0U, source.size() - unit.size());
if (number.empty() || number.size() > 64U) return std::nullopt;
char* end = nullptr;
const auto parsed = std::strtof(number.c_str(), &end);
if (end != number.c_str() + number.size() || !std::isfinite(parsed)) {
return std::nullopt;
}
return parsed;
}

static std::optional<registered_custom_property> parse_registered_property(
const frame& source)
{
if (!source.prelude.starts_with("--") || source.prelude.size() > 128U
|| source.prelude.size() < 3U) return std::nullopt;
for (const auto value : std::string_view(source.prelude).substr(2U)) {
const auto byte = static_cast<unsigned char>(value);
if (std::isalnum(byte) == 0 && value != '-' && value != '_') {
return std::nullopt;
}
}
const css_declaration* syntax = nullptr;
const css_declaration* inherits = nullptr;
const css_declaration* initial = nullptr;
for (const auto& declaration : source.declarations) {
if (declaration.important) return std::nullopt;
if (declaration.name == "syntax") {
if (syntax != nullptr) return std::nullopt;
syntax = &declaration;
} else if (declaration.name == "inherits") {
if (inherits != nullptr) return std::nullopt;
inherits = &declaration;
} else if (declaration.name == "initial-value") {
if (initial != nullptr) return std::nullopt;
initial = &declaration;
}
}
if (syntax == nullptr || inherits == nullptr || initial == nullptr) {
return std::nullopt;
}
auto syntax_value = trim_value(syntax->value);
if (syntax_value.size() >= 2U
&& ((syntax_value.front() == '\'' && syntax_value.back() == '\'')
|| (syntax_value.front() == '"' && syntax_value.back() == '"'))) {
syntax_value = syntax_value.substr(1U, syntax_value.size() - 2U);
}
registered_custom_property result;
result.name = source.prelude;
if (syntax_value == "<angle>") {
result.syntax = registered_property_syntax::angle;
} else if (syntax_value == "<percentage>") {
result.syntax = registered_property_syntax::percentage;
} else {
return std::nullopt;
}
const auto inherits_value = ascii_lower(trim_value(inherits->value));
if (inherits_value != "true" && inherits_value != "false") {
return std::nullopt;
}
result.inherits = inherits_value == "true";
const auto number = parse_typed_number(initial->value, result.syntax);
if (!number.has_value() || initial->value.size() > 128U) {
return std::nullopt;
}
result.initial_number = *number;
result.initial_value = trim_value(initial->value);
return result;
}

frame* nearest_style_frame() noexcept
{
for (auto iterator = stack_.rbegin(); iterator != stack_.rend(); ++iterator) {
Expand Down
Loading
Loading