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
19 changes: 19 additions & 0 deletions docs/validation/css-retained-effect-values-256.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ This gate covers the first product-neutral slice of issue #256:
Retained-scene mask, clip, filter, and backdrop painting is outside this slice
and remains separately qualified by later issue #256 work.

## Backdrop command extension — 19 September 2026

Issue #503 extends this contract with a bounded kind-48 retained command. The
resource preserves authored `blur()`/`saturate()` order, the command carries
rounded output bounds and maximum blur sigma, and localized damage includes a
backdrop when earlier sampled content changes. The producer forces these boxes
into the foreground phase so reference presenters execute the command after
retained canvas/GPU layers and before the element background.

The native regression now authors 4,096 backdrop commands, the WebKit alias,
the Code OSS `blur(8px) saturate(1.08)` sequence, phase metadata, and resource
bounds. Avalonia 12 records Skia backdrop save layers; Flutter replays bounded
prior content through nested filters. AppScene #174 owns the packaged native
Skia/Graphite consumer.

These additions have not been executed under the current fast-merge directive.
Build, WPT, pixels, runtime, performance, memory, and package results remain
deferred and must not be inferred from the historical results below.

## Direct gates

| Gate | Denominator | Result |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ inline void append_declaration(
const auto custom = name.starts_with("--");
if (custom && !valid_custom_property_name(name)) return;
if (name == "-webkit-mask") name = "mask";
else if (name == "-webkit-backdrop-filter") name = "backdrop-filter";
else if (name == "-webkit-mask-image") name = "mask-image";
else if (name == "-webkit-mask-position") name = "mask-position";
else if (name == "-webkit-mask-size") name = "mask-size";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,132 @@ void native_document::append_scene(
}
return result;
}();
struct retained_backdrop_effect final {
bool present{};
std::string resource;
float maximum_blur_sigma{};
};
const auto backdrop_effect = [&]() {
retained_backdrop_effect result{};
const auto& effects = node.style.textual().effect_values;
const auto known = effects.find("backdrop-filter");
if (known == effects.end()) return result;
auto remaining = std::string_view{known->second};
const auto trim = [](std::string_view value) {
while (!value.empty()
&& std::isspace(static_cast<unsigned char>(value.front()))) {
value.remove_prefix(1U);
}
while (!value.empty()
&& std::isspace(static_cast<unsigned char>(value.back()))) {
value.remove_suffix(1U);
}
return value;
};
const auto equals_ascii_case_insensitive = [](std::string_view left,
std::string_view right) {
if (left.size() != right.size()) return false;
for (size_t index = 0U; index < left.size(); ++index) {
const auto character = static_cast<unsigned char>(left[index]);
const auto lower = character >= 'A' && character <= 'Z'
? static_cast<char>(character - 'A' + 'a')
: static_cast<char>(character);
if (lower != right[index]) return false;
}
return true;
};
remaining = trim(remaining);
if (remaining.empty()
|| equals_ascii_case_insensitive(remaining, "none")) {
return result;
}
result.present = true;
const auto invalid = [&]() {
result.resource = "webscene-backdrop-invalid-v1";
result.maximum_blur_sigma = 0.0F;
return result;
};
std::ostringstream resource;
resource << "webscene-backdrop-v1\t";
constexpr size_t maximum_filter_functions = 16U;
size_t function_count = 0U;
while (!remaining.empty()) {
if (++function_count > maximum_filter_functions) return invalid();
const auto opening = remaining.find('(');
auto closing = std::string_view::npos;
if (opening != std::string_view::npos) {
auto depth = 1U;
for (auto index = opening + 1U; index < remaining.size(); ++index) {
if (remaining[index] == '(') ++depth;
else if (remaining[index] == ')' && --depth == 0U) {
closing = index;
break;
}
}
}
if (opening == 0U || opening == std::string_view::npos
|| closing == std::string_view::npos) {
return invalid();
}
const auto name = trim(remaining.substr(0U, opening));
const auto argument = trim(
remaining.substr(opening + 1U, closing - opening - 1U));
float amount = -1.0F;
if (equals_ascii_case_insensitive(name, "blur")) {
const auto length = parse_length(std::string{argument});
switch (length.unit) {
case length_unit::pixels:
case length_unit::em:
case length_unit::rem:
case length_unit::viewport_width:
case length_unit::viewport_height:
case length_unit::viewport_width_capped:
case length_unit::viewport_height_capped:
case length_unit::viewport_width_floored:
case length_unit::viewport_height_floored:
case length_unit::container_inline:
case length_unit::container_block:
case length_unit::container_min:
case length_unit::container_max:
amount = resolve_length(node, length, 0.0F, -1.0F);
break;
default:
break;
}
constexpr auto maximum_backdrop_blur_sigma = 64.0F;
if (!std::isfinite(amount) || amount < 0.0F
|| amount > maximum_backdrop_blur_sigma) {
return invalid();
}
result.maximum_blur_sigma = std::max(
result.maximum_blur_sigma, amount);
resource << (function_count == 1U ? "" : ";")
<< "blur=" << amount;
} else if (equals_ascii_case_insensitive(name, "saturate")) {
const auto parsed = css::parse_ascii_number_prefix(argument);
if (!parsed.has_value() || parsed->value < 0.0F) return invalid();
const auto suffix = argument.substr(parsed->consumed);
amount = suffix.empty()
? parsed->value
: suffix == "%" ? parsed->value / 100.0F : -1.0F;
if (!std::isfinite(amount) || amount < 0.0F || amount > 10.0F) {
return invalid();
}
resource << (function_count == 1U ? "" : ";")
<< "saturate=" << amount;
} else {
return invalid();
}
remaining.remove_prefix(closing + 1U);
if (!remaining.empty()
&& !std::isspace(static_cast<unsigned char>(remaining.front()))) {
return invalid();
}
remaining = trim(remaining);
}
result.resource = resource.str();
return result;
}();
const auto mask_resource = [&]() -> std::optional<uint32_t> {
const auto& effects = node.style.textual().effect_values;
const auto known = effects.find("mask-image");
Expand Down Expand Up @@ -1280,7 +1406,9 @@ void native_document::append_scene(
// backgrounds must be emitted after retained chart canvases, just
// like their text and SVG foreground commands.
}
return is_in_modal_layer(node) || node.paints_after_retained_canvas || fixed_layer || outermost_z_index > 0;
return backdrop_effect.present || is_in_modal_layer(node)
|| node.paints_after_retained_canvas || fixed_layer
|| outermost_z_index > 0;
}();
struct resolved_radii final {
float top_left;
Expand Down Expand Up @@ -1707,6 +1835,29 @@ void native_document::append_scene(
node.style.border_bottom_left_radius_y(),
node.layout.width,
node.layout.height);
if (paint_self && backdrop_effect.present
&& node.layout.width > 0.0F && node.layout.height > 0.0F) {
append_ellipse_metadata(
radii,
node.layout.x,
node.layout.y,
node.layout.width,
node.layout.height);
commands.push_back(webscene_scene_command{
48U,
append_scene_string(backdrop_effect.resource, strings, string_bytes),
node.layout.x,
node.layout.y,
node.layout.width,
node.layout.height,
1U,
node.id,
radii.top_left,
radii.top_right,
radii.bottom_right,
radii.bottom_left,
backdrop_effect.maximum_blur_sigma});
}
if (paint_self
&& node.style.box_shadow_present && !node.style.box_shadow_inset
&& node.layout.width > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ typedef struct webscene_scene_header {
// webscene-mask-svg-v1 tiled SVG resource to the current isolated layer using
// destination-in before kind 31 restores it. webscene-mask-invalid-v1 clears
// the isolated layer for failed or unsupported authored masks.
// DOM kind 48 applies one bounded backdrop effect before the element's own
// background and descendants. flags indexes a webscene-backdrop-v1 resource
// containing the authored blur/saturate sequence; rgba is the paint phase,
// the box and corner radii bound output, and stroke_width is the maximum blur
// sigma used to bound sampling and damage. Consumers must sample pixels already
// painted at this exact command position, including retained canvas/GPU output.
// webscene-backdrop-invalid-v1 is an explicit fail-closed no-op.
typedef struct webscene_scene_command {
uint32_t kind;
uint32_t flags;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ bool command_uses_dom_string(const webscene_scene_command& command)
{
return (command.kind >= 3U && command.kind <= 6U)
|| (command.kind == 12U && (command.flags & (1U << 31U)) != 0U)
|| command.kind == 47U;
|| command.kind == 47U
|| command.kind == 48U;
}

std::string_view command_dom_string(
Expand Down Expand Up @@ -166,7 +167,13 @@ webscene_damage_rect command_damage_bounds(
bottom = center_y + radius;
}
constexpr auto antialias_padding = 2.0F;
const auto padding = antialias_padding + std::max(0.0F, effect_padding);
constexpr auto backdrop_blur_extent_multiplier = 3.0F;
const auto backdrop_padding = command.kind == 48U
? std::clamp(command.stroke_width, 0.0F, 64.0F)
* backdrop_blur_extent_multiplier
: 0.0F;
const auto padding = antialias_padding
+ std::max(std::max(0.0F, effect_padding), backdrop_padding);
return {
left - padding,
top - padding,
Expand Down Expand Up @@ -241,6 +248,27 @@ bool append_localized_dom_damage(
right = std::max(right, bounds.x + bounds.width);
bottom = std::max(bottom, bounds.y + bounds.height);
};
const auto intersects = [](const webscene_damage_rect& left,
const webscene_damage_rect& right) {
return left.x < right.x + right.width
&& right.x < left.x + left.width
&& left.y < right.y + right.height
&& right.y < left.y + left.height;
};
const auto include_sampled_backdrops = [&](const scene& owner,
size_t changed_index,
const webscene_damage_rect& changed_bounds) {
for (auto index = changed_index + 1U; index < owner.commands.size(); ++index) {
const auto& candidate = owner.commands[index];
if (candidate.kind != 48U) continue;
const auto sample_bounds = command_damage_bounds(
candidate,
std::clamp(candidate.stroke_width, 0.0F, 64.0F) * 3.0F);
if (intersects(changed_bounds, sample_bounds)) {
include(candidate, candidate.stroke_width * 3.0F);
}
}
};
for (size_t index = 0; index < next.commands.size(); ++index) {
const auto& old_command = previous.commands[index];
const auto& new_command = next.commands[index];
Expand All @@ -263,6 +291,14 @@ bool append_localized_dom_damage(
changed = true;
include(old_command, previous_blur_paddings[index]);
include(new_command, next_blur_paddings[index]);
include_sampled_backdrops(
previous,
index,
command_damage_bounds(old_command, previous_blur_paddings[index]));
include_sampled_backdrops(
next,
index,
command_damage_bounds(new_command, next_blur_paddings[index]));
}
if (!changed) return false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ struct clip_scene_counts final {
uint32_t blur_filter_begins{};
uint32_t functional_blur_begins{};
uint32_t linear_mask_commands{};
uint32_t backdrop_filter_commands{};
uint32_t command_count{};
bool transform_clip_nested{};
bool compound_filter_ordered{};
bool path_clip_metadata{};
bool url_clip_metadata{};
bool compound_backdrop_ordered{};
};

clip_scene_counts wait_for_inset_clip_scene(
Expand Down Expand Up @@ -181,6 +183,23 @@ clip_scene_counts wait_for_inset_clip_scene(
++latest.functional_blur_begins;
} else if (command.kind == 47U) {
++latest.linear_mask_commands;
} else if (command.kind == 48U) {
++latest.backdrop_filter_commands;
if (command.flags < scene->string_count) {
const auto& resource = scene->strings[command.flags];
if (resource.byte_offset <= scene->string_byte_count
&& resource.byte_length
<= scene->string_byte_count - resource.byte_offset) {
const auto data = std::string_view(
scene->string_bytes + resource.byte_offset,
resource.byte_length);
if (data == "webscene-backdrop-v1\tblur=8;saturate=1.08"
&& std::abs(command.stroke_width - 8.0F) < 0.01F
&& command.rgba == 1U) {
latest.compound_backdrop_ordered = true;
}
}
}
}
}
webscene_scene_acknowledge_v3(lease);
Expand Down Expand Up @@ -305,6 +324,7 @@ int main()
#url-clip { clip-path: url(#local-clip); }
#functional-blur { filter: blur(max(4px, calc(8px * 0.25))); }
#compound-filter { filter: blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25); }
#compound-backdrop { -webkit-backdrop-filter: blur(8px) saturate(1.08); }
#effects > span:last-child { filter: blur(2px); }
`;
document.head.appendChild(rules);
Expand All @@ -318,6 +338,7 @@ int main()
host.children[3].id = 'url-clip';
host.children[4093].id = 'functional-blur';
host.children[4094].id = 'compound-filter';
host.children[4092].id = 'compound-backdrop';
document.body.appendChild(host);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.display = 'none';
Expand Down Expand Up @@ -357,6 +378,10 @@ int main()
!== 'blur(max(4px, calc(8px * 0.25)))') {
throw new Error('initial functional blur value failed');
}
if (getComputedStyle(document.getElementById('compound-backdrop'))
.getPropertyValue('backdrop-filter') !== 'blur(8px) saturate(1.08)') {
throw new Error('prefixed compound backdrop filter did not canonicalize');
}
if (getComputedStyle(document.getElementById('ellipse-clip')).getPropertyValue('clip-path')
!== 'ellipse(25% 50% at 50% 50%)') {
throw new Error('initial ellipse clip value failed');
Expand Down Expand Up @@ -449,6 +474,9 @@ int main()
"retained scene did not resolve the functional blur radius");
require(initial_clip_scene.linear_mask_commands == 4096U,
"retained scene did not emit all linear-gradient mask commands");
require(initial_clip_scene.backdrop_filter_commands == 4096U
&& initial_clip_scene.compound_backdrop_ordered,
"retained scene did not emit bounded authored-order backdrop filters");
const auto initial_scene_command_bytes =
static_cast<uint64_t>(initial_clip_scene.command_count)
* sizeof(webscene_scene_command);
Expand Down Expand Up @@ -584,6 +612,8 @@ int main()
<< " blur-filter-begins=" << initial_clip_scene.blur_filter_begins
<< " functional-blur-begins=" << initial_clip_scene.functional_blur_begins
<< " linear-mask-commands=" << initial_clip_scene.linear_mask_commands
<< " backdrop-filter-commands=" << initial_clip_scene.backdrop_filter_commands
<< " compound-backdrop-ordered=" << initial_clip_scene.compound_backdrop_ordered
<< " compound-filter-ordered=" << initial_clip_scene.compound_filter_ordered
<< " transform-clip-nested=" << initial_clip_scene.transform_clip_nested
<< " initial-scene-command-bytes=" << initial_scene_command_bytes
Expand Down
Loading
Loading