fix(layout): share absolute automatic sizing across containing blocks

Use Taffy's shared absolute sizing contract for deferred inline containing blocks, preserving formatter-owned content sizes and logical-axis constraints. Remove the duplicate physical-width sizing path.

Add 384 font-independent screenshot geometry checks across block, flex, grid and inline owners in three writing modes. Verified against Chromium; fixed sizing WPT improves 220 to 223 of 293 with no regressions, and the 565-case border guard is unchanged. Root fmt, full Clippy and all 17217 Nextest tests pass.
This commit is contained in:
ldm0
2026-09-09 22:43:18 +08:00
parent fe70921c5f
commit 82e198d264
7 changed files with 242 additions and 224 deletions
Generated
+1 -1
View File
@@ -4842,7 +4842,7 @@ dependencies = [
[[package]]
name = "taffy"
version = "0.13.0"
source = "git+https://github.com/ldm0/taffy?rev=d9915f3dbfa6dae40afd9e97d214b4220fd90359#d9915f3dbfa6dae40afd9e97d214b4220fd90359"
source = "git+https://github.com/ldm0/taffy?rev=b5a55e2b3b7aa3b71ae9f8dfb124fb018c4a2cf2#b5a55e2b3b7aa3b71ae9f8dfb124fb018c4a2cf2"
dependencies = [
"arrayvec",
"serde",
+1 -1
View File
@@ -102,7 +102,7 @@ stylo_atoms = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e937
stylo_dom = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
stylo_static_prefs = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
stylo_traits = { git = "https://github.com/ldm0/stylo", rev = "cfa170da90c467e93786ec0fa5526bd83f201933" }
taffy = { git = "https://github.com/ldm0/taffy", rev = "d9915f3dbfa6dae40afd9e97d214b4220fd90359" }
taffy = { git = "https://github.com/ldm0/taffy", rev = "b5a55e2b3b7aa3b71ae9f8dfb124fb018c4a2cf2" }
[workspace.lints.clippy]
disallowed_methods = "deny"
-20
View File
@@ -18,26 +18,6 @@ impl<N> LayoutWorld<N>
where
N: Copy + Debug + Eq + Hash,
{
/// Measure the border-box width selected by CSS fit-content sizing.
///
/// Taffy's block and flex absolute-layout paths use the same two intrinsic
/// measurements internally. Moli also needs the operation at the
/// IFC seam, where an out-of-flow placeholder is owned by Parley and the
/// positioned box cannot remain a normal child of Taffy's numeric tree.
pub(crate) fn measure_fit_content_width(
&mut self,
child: LayoutBoxId,
inputs: LayoutInput,
available_width: f32,
) -> f32 {
self.measure_fit_content_inline_size(
child,
inputs,
WritingMode::HorizontalTb,
available_width,
)
}
pub(crate) fn measure_fit_content_inline_size(
&mut self,
child: LayoutBoxId,
+44 -16
View File
@@ -89,13 +89,18 @@ impl PhysicalStaticPosition {
}
}
/// Fit-content width available on the side(s) selected by the static
/// position. A center edge constrains both sides, unlike a start point.
pub(crate) fn available_width(self, containing_width: f32) -> f32 {
let bounds = self
.horizontal_axis()
.inset_modified_bounds(containing_width);
bounds.end - bounds.start
/// Fit-content space on both physical axes selected by the static edges.
/// The child chooses its inline axis from its own writing mode; a center
/// edge constrains both sides, unlike a start point.
pub(crate) fn available_size(self, containing_size: Size<f32>) -> Size<f32> {
let extent = |axis: StaticPositionAxis, size| {
let bounds = axis.inset_modified_bounds(size);
bounds.end - bounds.start
};
Size {
width: extent(self.horizontal_axis(), containing_size.width),
height: extent(self.vertical_axis(), containing_size.height),
}
}
fn horizontal_axis(self) -> StaticPositionAxis {
@@ -110,6 +115,18 @@ impl PhysicalStaticPosition {
}
}
fn vertical_axis(self) -> StaticPositionAxis {
StaticPositionAxis {
offset: self.point.y,
edge: match self.vertical_edge {
VerticalStaticEdge::Top => PhysicalAxisStaticEdge::Min,
VerticalStaticEdge::Center => PhysicalAxisStaticEdge::Center,
VerticalStaticEdge::Bottom => PhysicalAxisStaticEdge::Max,
},
safety: self.safety.height,
}
}
/// Resolve the border-box origin from the static margin-box edge and used
/// margins. Auto margins on a static-position axis have already become zero
/// during absolute sizing; this step must not distribute them a second time.
@@ -122,15 +139,7 @@ impl PhysicalStaticPosition {
containing_direction: Direction,
) -> Point<f32> {
let horizontal = self.horizontal_axis();
let vertical = StaticPositionAxis {
offset: self.point.y,
edge: match self.vertical_edge {
VerticalStaticEdge::Top => PhysicalAxisStaticEdge::Min,
VerticalStaticEdge::Center => PhysicalAxisStaticEdge::Center,
VerticalStaticEdge::Bottom => PhysicalAxisStaticEdge::Max,
},
safety: self.safety.height,
};
let vertical = self.vertical_axis();
Point {
x: horizontal.border_box_start(
containing_size.width,
@@ -463,6 +472,25 @@ mod tests {
direction: Direction::Ltr,
};
#[test]
fn static_position_constrains_both_physical_available_axes() {
let position = PhysicalStaticPosition::new(
Point { x: 40.0, y: 70.0 },
HorizontalStaticEdge::Center,
VerticalStaticEdge::Bottom,
);
assert_eq!(
position.available_size(Size {
width: 200.0,
height: 100.0
}),
Size {
width: 80.0,
height: 70.0
},
);
}
#[test]
fn centered_static_position_centers_the_margin_box() {
let position = PhysicalStaticPosition::new(
+36 -186
View File
@@ -7,9 +7,9 @@ use inline_containing_block::InlineContainingBlocks;
use parley::{AlignmentOptions, PositionedLayoutItem};
use style::Atom;
use taffy::{
AbsoluteAxis, AlignContent, AlignContentKeyword, AlignmentSafety, AutoSizeBehavior,
AvailableSpace, BlockContext, BlockFormattingContext, BoxSizing, CacheTree, DetailedGridInfo,
Dimension, Display, FlexDirection, Layout, LayoutBlockContainer, LayoutFlexboxContainer,
AlignContent, AlignContentKeyword, AlignmentSafety, AutoSizeBehavior, AvailableSpace,
BlockContext, BlockFormattingContext, BoxSizing, CacheTree, DetailedGridInfo, Dimension,
Display, FlexDirection, Layout, LayoutBlockContainer, LayoutFlexboxContainer,
LayoutGridContainer, LayoutInput, LayoutOutput, LayoutPartialTree, LeafLayoutContext, Line,
MaybeMath, MaybeResolve, NodeId, Point, ResolveOrZero, RoundTree, RunMode, Size, SizingMode,
SizingPurpose, Style, TraversePartialTree, TraverseTree, compute_block_layout,
@@ -1202,221 +1202,71 @@ fn layout_deferred_absolute_child<N>(
) where
N: Copy + Debug + Eq + Hash,
{
let child_writing_mode = world.boxes[child.index()].style.writing_mode();
let style = world.boxes[child.index()].style.taffy.clone();
let style = &world.boxes[child.index()].style.taffy;
if style.display == Display::None || style.position != taffy::Position::Absolute {
return;
}
let area_width = area.size.width;
let area_height = area.size.height;
let percentage_basis = area.writing_mode.to_logical(area.size).inline_size;
let aspect_ratio = style.aspect_ratio;
let margin = style
.margin
.map(|value| value.maybe_resolve(percentage_basis, resolve_stylo_calc_value));
let padding = style
.padding
.resolve_or_zero(Some(percentage_basis), resolve_stylo_calc_value);
let border = style
.border
.resolve_or_zero(Some(percentage_basis), resolve_stylo_calc_value);
let padding_border_sum = (padding + border).sum_axes();
let box_sizing_adjustment = if style.box_sizing == BoxSizing::ContentBox {
padding_border_sum
} else {
Size::ZERO
};
let left = style
.inset
.left
.maybe_resolve(area_width, resolve_stylo_calc_value);
let right = style
.inset
.right
.maybe_resolve(area_width, resolve_stylo_calc_value);
let top = style
.inset
.top
.maybe_resolve(area_height, resolve_stylo_calc_value);
let bottom = style
.inset
.bottom
.maybe_resolve(area_height, resolve_stylo_calc_value);
let block_auto_behavior = match child_writing_mode.block_axis() {
AbsoluteAxis::Horizontal if left.is_some() && right.is_some() => {
AutoSizeBehavior::StretchExplicit
}
AbsoluteAxis::Vertical if top.is_some() && bottom.is_some() => {
AutoSizeBehavior::StretchExplicit
}
_ => AutoSizeBehavior::FitContent,
};
let style_size = style
.size
.maybe_resolve(area.size, resolve_stylo_calc_value)
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_add(box_sizing_adjustment);
let min_size = style
.min_size
.maybe_resolve(area.size, resolve_stylo_calc_value)
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_add(box_sizing_adjustment)
.or(padding_border_sum.map(Some))
.maybe_max(padding_border_sum);
let max_size = style
.max_size
.maybe_resolve(area.size, resolve_stylo_calc_value)
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_add(box_sizing_adjustment);
let mut known_dimensions = style_size.maybe_clamp(min_size, max_size);
if let (None, Some(left), Some(right)) = (known_dimensions.width, left, right) {
known_dimensions.width = Some(
(area_width.maybe_sub(margin.left).maybe_sub(margin.right) - left - right).max(0.0),
);
known_dimensions = known_dimensions
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_clamp(min_size, max_size);
}
if let (None, Some(top), Some(bottom)) = (known_dimensions.height, top, bottom) {
known_dimensions.height = Some(
(area_height.maybe_sub(margin.top).maybe_sub(margin.bottom) - top - bottom).max(0.0),
);
known_dimensions = known_dimensions
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_clamp(min_size, max_size);
}
let available_space = Size {
width: AvailableSpace::Definite(area_width.maybe_clamp(min_size.width, max_size.width)),
height: AvailableSpace::Definite(area_height.maybe_clamp(min_size.height, max_size.height)),
};
if known_dimensions.width.is_none() {
// CSS 2.2 §10.3.7 resolves an auto-width absolute box with at least
// one auto horizontal inset as fit-content. Taffy's block/flex paths
// already implement this contract, but an IFC's Parley placeholder
// requires Moli to perform the same sizing at this custom seam.
let non_auto_margin_width = margin.left.unwrap_or(0.0) + margin.right.unwrap_or(0.0);
let available_width = match (left, right) {
(Some(left), None) => area_width - left,
(None, Some(right)) => area_width - right,
(None, None) => static_position.available_width(area_width),
(Some(_), Some(_)) => unreachable!("both insets already resolve auto width"),
} - non_auto_margin_width;
known_dimensions.width = Some(world.measure_fit_content_width(
child,
LayoutInput {
known_dimensions,
definite_dimensions: known_dimensions,
parent_size: area.size.map(Some),
parent_writing_mode: area.writing_mode,
available_space,
sizing_mode: SizingMode::ContentSize,
sizing_purpose: SizingPurpose::IntrinsicContribution,
run_mode: RunMode::ComputeSize,
axis: taffy::RequestedAxis::Horizontal,
block_auto_behavior,
block_margins_are_collapsible: Line::FALSE,
},
available_width,
));
known_dimensions = known_dimensions
.maybe_apply_aspect_ratio(aspect_ratio)
.maybe_clamp(min_size, max_size);
}
let measured_size = world
.compute_child_layout(
child.to_taffy(),
LayoutInput {
known_dimensions,
definite_dimensions: known_dimensions,
parent_size: area.size.map(Some),
parent_writing_mode: area.writing_mode,
available_space,
sizing_mode: SizingMode::ContentSize,
sizing_purpose: SizingPurpose::Layout,
run_mode: RunMode::ComputeSize,
axis: taffy::RequestedAxis::Both,
block_auto_behavior,
block_margins_are_collapsible: Line::FALSE,
},
)
.size;
let final_size = known_dimensions
.unwrap_or(measured_size)
.maybe_clamp(min_size, max_size);
let output = world.compute_child_layout(
// Inline-containing-block geometry becomes available after the numeric
// traversal, but sizing uses the same absolute contract as block/flex/grid.
let computed = taffy::compute::compute_absolute_layout(
world,
child.to_taffy(),
LayoutInput {
known_dimensions: final_size.map(Some),
definite_dimensions: known_dimensions,
parent_size: area.size.map(Some),
parent_writing_mode: area.writing_mode,
available_space,
sizing_mode: SizingMode::ContentSize,
sizing_purpose: SizingPurpose::Layout,
run_mode: RunMode::PerformLayout,
axis: taffy::RequestedAxis::Both,
block_auto_behavior,
block_margins_are_collapsible: Line::FALSE,
},
);
let resolved_margin = taffy::compute::resolve_absolute_margins(
margin,
taffy::Rect {
left,
right,
top,
bottom,
},
area.size,
final_size,
taffy::WritingDirection {
mode: area.writing_mode,
direction: area.direction,
taffy::compute::AbsoluteConstraintSpace {
containing_block_size: area.size,
writing_direction: taffy::WritingDirection {
mode: area.writing_mode,
direction: area.direction,
},
static_available_space: static_position.available_size(area.size),
},
);
let output = computed.output;
let size = output.size;
let margin = computed.margin;
let inset = computed.inset;
let static_origin = static_position.border_box_origin(
final_size,
resolved_margin,
size,
margin,
area.size,
area.writing_mode,
area.direction,
);
let x = match (left, right) {
let x = match (inset.left, inset.right) {
(Some(left), Some(right)) => {
if area.direction == taffy::Direction::Rtl {
area_width - final_size.width - right - resolved_margin.right
area.size.width - size.width - right - margin.right
} else {
left + resolved_margin.left
left + margin.left
}
}
(Some(left), None) => left + resolved_margin.left,
(None, Some(right)) => area_width - final_size.width - right - resolved_margin.right,
(Some(left), None) => left + margin.left,
(None, Some(right)) => area.size.width - size.width - right - margin.right,
(None, None) => static_origin.x,
};
let y = top
.map(|top| top + resolved_margin.top)
let y = inset
.top
.map(|top| top + margin.top)
.or_else(|| {
bottom.map(|bottom| area_height - final_size.height - bottom - resolved_margin.bottom)
inset
.bottom
.map(|bottom| area.size.height - size.height - bottom - margin.bottom)
})
.unwrap_or(static_origin.y);
let scrollbar_size = world.get_scrollbar_insets(child.to_taffy()).sum_axes();
world.boxes[child.index()].unrounded_layout = Layout {
order: 0,
in_flow: None,
size: final_size,
size,
content_size: output.content_size,
scrollbar_size,
location: Point {
x: area.origin.x + x - numeric_parent_origin.x,
y: area.origin.y + y - numeric_parent_origin.y,
},
padding,
border,
margin: resolved_margin,
padding: computed.padding,
border: computed.border,
margin,
};
}
@@ -1,5 +1,50 @@
use super::*;
/// WPT abspos-005/012/013 with authored stretch, limits and percentage-basis
/// controls across all formatting owners, including inline containing blocks.
#[tokio::test(flavor = "current_thread")]
async fn screenshot_resolves_absolute_automatic_sizes_in_logical_order() {
run_page_vm_async_test(async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?;
let mut page = test_page_vm_with_loader_and_document_url(
&loader,
Vec::new(),
Url::parse("https://example.com/absolute-automatic-sizing.html")?,
);
page.vm_mut()
.set_layout_policy(moli_page_types::LayoutPolicy::OnDemand);
let fixture = include_str!("../../../../tests/fixtures/absolute-automatic-sizing.html");
page.vm_mut().eval(&format!(
"document.open();document.write({});document.close()",
serde_json::to_string(fixture)?,
))?;
page.vm_mut()
.prime_document_lifecycle_processing_and_record_stylesheet_network_results();
// Synchronous geometry reads intentionally use the last snapshot.
page.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1280, 1600, 1.0))?
.expect("absolute sizing fixture must retain a layout root");
let checks: serde_json::Value = serde_json::from_str(
&page
.vm_mut()
.eval("JSON.stringify(collectAbsoluteAutoSizingChecks())")?,
)?;
let checks = checks.as_array().expect("absolute sizing checks");
assert_eq!(checks.len(), 384);
let failures: Vec<_> = checks
.iter()
.filter(|check| check["actual"] != check["expected"])
.collect();
assert!(
failures.is_empty(),
"absolute sizing mismatches: {failures:#?}"
);
Ok::<_, anyhow::Error>(())
})
.await
.expect("absolute automatic sizing fixture should run");
}
/// WPT block-aspect-ratio-015/017/043/056, with explicit limits, overflow, sizing-box
/// semantics, and logical-axis controls shared with the Chromium comparison.
#[tokio::test(flavor = "current_thread")]
@@ -0,0 +1,115 @@
<!doctype html>
<meta charset="utf-8">
<title>Absolute automatic sizing across formatting and containing blocks</title>
<link rel="help" href="https://drafts.csswg.org/css-position-3/#abspos-auto-size">
<style>
html, body { margin:0; padding:0; }
html { overflow:hidden; }
.slot { position:absolute; inline-size:240px; block-size:300px; font:0/0 sans-serif; }
.owner { inline-size:240px; block-size:300px; }
.item { position:absolute; inset:0; box-sizing:content-box; background:rgb(31,127,63); }
</style>
<body>
<script>
// Literal logical border-box dimensions. Empty and fixed-size content avoid
// font metrics and isolate the inset/alignment/ratio sizing contract.
const cases = [
['inline-ratio-normal', 'inline-size:100px;aspect-ratio:1', null, [100,100]],
['inline-ratio-stretch', 'inline-size:100px;aspect-ratio:1;align-self:stretch', null, [100,300]],
['inline-ratio-start', 'inline-size:100px;aspect-ratio:1;align-self:start', null, [100,100]],
['both-auto-normal', 'aspect-ratio:1', null, [240,240]],
['both-auto-block-stretch', 'aspect-ratio:1;align-self:stretch', null, [300,300]],
['both-auto-both-stretch', 'aspect-ratio:1;align-self:stretch;justify-self:stretch', null, [240,300]],
['both-auto-inline-stretch', 'aspect-ratio:1;justify-self:stretch', null, [240,240]],
['block-ratio-normal', 'block-size:100px;aspect-ratio:1/2', null, [50,100]],
['block-ratio-inline-stretch', 'block-size:100px;aspect-ratio:1/2;justify-self:stretch', null, [240,100]],
['block-content-minimum', 'inline-size:100px;aspect-ratio:2;inset-block-end:auto', [100,100], [100,100]],
['block-min-zero', 'inline-size:100px;aspect-ratio:2;inset-block-end:auto;min-block-size:0', [100,100], [100,50]],
['block-overflow-hidden', 'inline-size:100px;aspect-ratio:2;inset-block-end:auto;overflow:hidden', [100,100], [100,50]],
['block-authored-maximum', 'inline-size:100px;aspect-ratio:2;inset-block-end:auto;max-block-size:75px', [100,100], [100,75]],
['block-transferred-maximum', 'inline-size:200px;max-inline-size:100px;aspect-ratio:2;inset-block-end:auto', [100,100], [100,100]],
['inline-content-minimum', 'block-size:100px;aspect-ratio:1/2;inset-inline-end:auto', [100,20], [100,100]],
['inline-min-zero', 'block-size:100px;aspect-ratio:1/2;inset-inline-end:auto;min-inline-size:0', [100,20], [50,100]],
['inline-overflow-hidden', 'block-size:100px;aspect-ratio:1/2;inset-inline-end:auto;overflow:hidden', [100,20], [50,100]],
['inline-authored-maximum', 'block-size:100px;aspect-ratio:1/2;inset-inline-end:auto;max-inline-size:75px', [100,20], [75,100]],
['inline-transferred-maximum', 'block-size:200px;max-block-size:100px;aspect-ratio:1/2;inset-inline-end:auto', [100,20], [100,100]],
['no-ratio-stretch', '', null, [240,300]],
['no-ratio-start', 'align-self:start;justify-self:start', [80,120], [80,120]],
['auto-inset-overrides-stretch', 'inline-size:100px;aspect-ratio:1;align-self:stretch;inset-block-end:auto', null, [100,100]],
['ratio-content-box-edges', 'inline-size:100px;aspect-ratio:1;padding:5px;border:2px solid black', null, [114,114]],
['ratio-stretch-content-box-edges', 'inline-size:100px;aspect-ratio:1;align-self:stretch;padding:5px;border:2px solid black', null, [114,300]],
['block-insets-determine-ratio', 'aspect-ratio:3;inset-inline:auto;inset-block-start:30%;inset-block-end:50%', null, [180,60]],
['fit-content-cap-and-ratio-minimum', 'max-inline-size:50px;aspect-ratio:3;inset-inline-end:auto;inset-block-end:auto', 'wrappable', [50,90]],
['content-block-is-indefinite', 'inline-size:100px;inset-block-end:auto', {offset:0}, [100,20]],
['ratio-block-is-definite', 'inline-size:100px;aspect-ratio:1;inset-block-end:auto', {offset:100}, [100,100]],
['stretch-block-is-definite', 'inline-size:100px', {offset:300}, [100,300]],
];
let index = 0;
for (const mode of ['horizontal-tb','vertical-rl','vertical-lr']) {
for (const ownerType of ['block','flex','grid','inline']) {
for (const [name, style, contentSize, expected] of cases) {
const slot = document.createElement('div');
slot.className = 'slot';
slot.style.cssText = `left:${index%3*400}px;top:${Math.floor(index/3)*360}px;writing-mode:${mode}`;
const owner = document.createElement('div');
owner.className = 'owner';
owner.style.display = ownerType === 'inline' ? 'block' : ownerType;
if (ownerType !== 'inline') owner.style.position = 'relative';
const item = document.createElement('div');
item.className = 'item';
item.id = `${mode}-${ownerType}-${name}`;
item.style.cssText = style;
// aspect-ratio is physical width/height; the cases describe a logical
// inline/block ratio, so invert it for vertical writing modes.
if (mode !== 'horizontal-tb' && item.style.aspectRatio) {
const parts = item.style.aspectRatio.split('/').map(Number);
item.style.aspectRatio = `${parts[1] || 1}/${parts[0]}`;
}
item.dataset.expected = JSON.stringify(mode === 'horizontal-tb' ? expected : [expected[1],expected[0]]);
if (Array.isArray(contentSize)) {
const content = document.createElement('div');
content.style.cssText = `inline-size:${contentSize[0]}px;block-size:${contentSize[1]}px`;
item.append(content);
} else if (contentSize === 'wrappable') {
for (let i = 0; i < 9; i++) {
if (i) item.append(document.createTextNode(' '));
const word = document.createElement('span');
word.style.cssText = 'display:inline-block;inline-size:40px;block-size:10px;vertical-align:top';
item.append(word);
}
} else if (contentSize) {
const relative = document.createElement('div');
relative.style.cssText = 'inline-size:50px;block-size:20px;position:relative;inset-block-start:100%';
relative.dataset.offset = contentSize.offset;
relative.dataset.mode = mode;
item.append(relative);
}
owner.append(item);
if (ownerType === 'inline') {
const inline = document.createElement('span');
inline.style.position = 'relative';
inline.append(owner);
slot.append(inline);
} else {
slot.append(owner);
}
document.body.append(slot);
index++;
}
}
}
function collectAbsoluteAutoSizingChecks() {
const checks = Array.from(document.querySelectorAll('.item'), item => {
const rect = item.getBoundingClientRect();
return {id:item.id, actual:[rect.width,rect.height], expected:JSON.parse(item.dataset.expected)};
});
for (const child of document.querySelectorAll('[data-offset]')) {
const rect = child.getBoundingClientRect();
const parent = child.parentElement.getBoundingClientRect();
const offset = child.dataset.mode === 'horizontal-tb' ? rect.top-parent.top
: child.dataset.mode === 'vertical-rl' ? parent.right-rect.right : rect.left-parent.left;
checks.push({id:`${child.parentElement.id}-relative-offset`, actual:[offset], expected:[Number(child.dataset.offset)]});
}
return checks;
}
</script>