and
) that should not create a block.
let all_whitespace = items.iter().all(|item| match item {
InlineRunItem::Text(r) => r.text.trim().is_empty(),
- InlineRunItem::OpenBox { .. } | InlineRunItem::CloseBox { .. } => false,
+ InlineRunItem::OpenBox { .. }
+ | InlineRunItem::CloseBox { .. }
+ | InlineRunItem::SymbolMarker(_) => false,
});
if all_whitespace {
return;
@@ -832,6 +954,7 @@ fn flush_inline_group(
children.push(StyledNode::InlineGroup(InlineGroup {
items,
text_align,
+ direction,
text_indent,
}));
}
@@ -2322,15 +2445,29 @@ fn extract_font(style: &ComputedValues, current_color: CGColor) -> FontProps {
..Default::default()
};
- // Text align
+ // Direction (ltr / rtl) — inherited. Affects Skia paragraph base
+ // direction for bidi reordering.
+ {
+ use style::properties::longhands::direction::computed_value::T as StyloDir;
+ props.direction = match style.get_inherited_box().clone_direction() {
+ StyloDir::Ltr => types::Direction::Ltr,
+ StyloDir::Rtl => types::Direction::Rtl,
+ };
+ }
+
+ // Text align. Logical `start` / `end` keywords resolve against the
+ // already-extracted `direction`: in LTR, `start` = left; in RTL,
+ // `start` = right.
use style::values::specified::text::TextAlignKeyword;
+ let (logical_start, logical_end) = match props.direction {
+ types::Direction::Ltr => (TextAlign::Left, TextAlign::Right),
+ types::Direction::Rtl => (TextAlign::Right, TextAlign::Left),
+ };
props.text_align = match inherited_text.text_align {
- TextAlignKeyword::Start | TextAlignKeyword::Left | TextAlignKeyword::MozLeft => {
- TextAlign::Left
- }
- TextAlignKeyword::End | TextAlignKeyword::Right | TextAlignKeyword::MozRight => {
- TextAlign::Right
- }
+ TextAlignKeyword::Start => logical_start,
+ TextAlignKeyword::End => logical_end,
+ TextAlignKeyword::Left | TextAlignKeyword::MozLeft => TextAlign::Left,
+ TextAlignKeyword::Right | TextAlignKeyword::MozRight => TextAlign::Right,
TextAlignKeyword::Center | TextAlignKeyword::MozCenter => TextAlign::Center,
TextAlignKeyword::Justify => TextAlign::Justify,
};
diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs
index 8702abcf0..7c03e12a3 100644
--- a/crates/grida-canvas/src/htmlcss/layout.rs
+++ b/crates/grida-canvas/src/htmlcss/layout.rs
@@ -147,6 +147,7 @@ fn build_taffy_node(
TextMeasure {
items: vec![InlineRunItem::Text(run.clone())],
text_indent: run.font.text_indent,
+ direction: run.font.direction,
},
)
.unwrap();
@@ -163,6 +164,7 @@ fn build_taffy_node(
TextMeasure {
items: group.items.clone(),
text_indent: group.text_indent,
+ direction: group.direction,
},
)
.unwrap();
@@ -184,6 +186,7 @@ fn build_taffy_node(
struct TextMeasure {
items: Vec,
text_indent: types::CssLength,
+ direction: types::Direction,
}
/// Taffy measure callback — builds a Skia Paragraph at the given available
@@ -208,7 +211,8 @@ fn text_measure_func(
// Build Paragraph with placeholders for inline box spacing
// (Chromium: LineBreaker processes kOpenTag/kText/kCloseTag)
- let ps = ParagraphStyle::new();
+ let mut ps = ParagraphStyle::new();
+ ps.set_text_direction(direction_to_skia(ctx.direction));
let mut builder = ParagraphBuilder::new(&ps, fonts);
// text-indent: prepend a width-reserving placeholder. Because it sits
@@ -249,6 +253,16 @@ fn text_measure_func(
));
}
}
+ InlineRunItem::SymbolMarker(m) => {
+ let (w, h) = m.placeholder_size();
+ builder.add_placeholder(&skia_safe::textlayout::PlaceholderStyle::new(
+ w,
+ h,
+ skia_safe::textlayout::PlaceholderAlignment::AboveBaseline,
+ skia_safe::textlayout::TextBaseline::Alphabetic,
+ 0.0,
+ ));
+ }
}
}
let mut para = builder.build();
@@ -866,3 +880,11 @@ pub(crate) fn build_skia_text_style(font: &FontProps, color: &CGColor) -> TextSt
ts
}
+
+/// Map CSS `direction` to Skia's `ParagraphStyle` text direction.
+pub(crate) fn direction_to_skia(dir: types::Direction) -> skia_safe::textlayout::TextDirection {
+ match dir {
+ types::Direction::Ltr => skia_safe::textlayout::TextDirection::LTR,
+ types::Direction::Rtl => skia_safe::textlayout::TextDirection::RTL,
+ }
+}
diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs
index 78e8f5145..0f444d728 100644
--- a/crates/grida-canvas/src/htmlcss/paint.rs
+++ b/crates/grida-canvas/src/htmlcss/paint.rs
@@ -524,14 +524,78 @@ fn resolve_bg_position_axis(v: CssLength, area: f32, tile: f32) -> f32 {
}
}
+/// Map a `background-repeat` keyword to a Skia `TileMode` for the
+/// shader path. `Round` is collapsed to `Repeat` — the round-specific
+/// tile-size adjustment is applied by the caller before constructing
+/// the shader. `Space` is handled via an explicit draw loop (see
+/// [`paint_background_image_layer`]) and should never reach here.
fn repeat_keyword_to_tile_mode(k: BackgroundRepeatKeyword) -> skia_safe::TileMode {
match k {
BackgroundRepeatKeyword::NoRepeat => skia_safe::TileMode::Decal,
- // Space / Round fall back to plain repeat (P1 follow-up).
- _ => skia_safe::TileMode::Repeat,
+ BackgroundRepeatKeyword::Repeat | BackgroundRepeatKeyword::Round => {
+ skia_safe::TileMode::Repeat
+ }
+ // Space reaches here only if the caller forgot to branch; use
+ // `Repeat` as a safe fallback rather than panicking.
+ BackgroundRepeatKeyword::Space => skia_safe::TileMode::Repeat,
}
}
+/// Tile start offsets along one axis relative to the positioning area.
+///
+/// - `NoRepeat` → `[pos]` (single copy at the resolved background-position).
+/// - `Repeat` / `Round` → offsets seeded at `pos`, spanning `[0, area]` after
+/// clipping (caller supplies the already-adjusted tile size for `Round`).
+/// - `Space` → edge-pinned offsets with whitespace distributed evenly
+/// between copies; `pos` is ignored when two or more copies fit.
+/// When fewer than two fit the spec permits arbitrary positioning —
+/// we apply `pos` so the fallback matches `no-repeat` behavior.
+fn axis_tile_positions(
+ keyword: BackgroundRepeatKeyword,
+ area: f32,
+ tile: f32,
+ pos: f32,
+) -> Vec {
+ if tile <= 0.0 {
+ return Vec::new();
+ }
+ match keyword {
+ BackgroundRepeatKeyword::NoRepeat => vec![pos],
+ BackgroundRepeatKeyword::Space => space_axis_positions(area, tile, pos),
+ BackgroundRepeatKeyword::Repeat | BackgroundRepeatKeyword::Round => {
+ repeat_axis_positions(area, tile, pos)
+ }
+ }
+}
+
+/// CSS `background-repeat: space` on one axis. If at least two copies fit,
+/// the first and last are pinned to the edges of the positioning area and
+/// the remaining whitespace is distributed evenly between copies. If fewer
+/// than two fit, the spec permits arbitrary position — we honor
+/// `background-position` (same as `no-repeat`).
+fn space_axis_positions(area: f32, tile: f32, pos: f32) -> Vec {
+ let n = (area / tile).floor() as i32;
+ if n < 2 {
+ return vec![pos];
+ }
+ let gap = (area - n as f32 * tile) / (n - 1) as f32;
+ (0..n).map(|k| k as f32 * (tile + gap)).collect()
+}
+
+/// Tile start offsets for `repeat` on one axis, seeded at `pos`. The first
+/// offset sits in `(-tile, 0]` so the repeating strip fills `[0, area]`
+/// after clipping.
+fn repeat_axis_positions(area: f32, tile: f32, pos: f32) -> Vec {
+ let first = pos - (pos / tile).ceil() * tile;
+ let mut out = Vec::new();
+ let mut x = first;
+ while x < area {
+ out.push(x);
+ x += tile;
+ }
+ out
+}
+
fn paint_background_image_layer(
canvas: &Canvas,
style: &StyledElement,
@@ -557,7 +621,7 @@ fn paint_background_image_layer(
_ => None,
};
- let (tile_w, tile_h) = resolve_bg_size(
+ let (mut tile_w, mut tile_h) = resolve_bg_size(
img.size,
origin_rect.width(),
origin_rect.height(),
@@ -567,6 +631,17 @@ fn paint_background_image_layer(
return;
}
+ // `background-repeat: round` scales the tile so an integer number of
+ // copies fit exactly along the axis (CSS Backgrounds §3.4).
+ if img.repeat.x == BackgroundRepeatKeyword::Round {
+ let n = (origin_rect.width() / tile_w).round().max(1.0);
+ tile_w = origin_rect.width() / n;
+ }
+ if img.repeat.y == BackgroundRepeatKeyword::Round {
+ let n = (origin_rect.height() / tile_h).round().max(1.0);
+ tile_h = origin_rect.height() / n;
+ }
+
// Resolve the source image. For gradients, rasterize at tile size so the
// resulting image maps 1:1 onto a single tile via the shader's local matrix.
let src_image = match &img.source {
@@ -577,33 +652,6 @@ fn paint_background_image_layer(
return;
};
- let px =
- resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w) + origin_rect.left;
- let py =
- resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h) + origin_rect.top;
-
- let sx = tile_w / src_image.width() as f32;
- let sy = tile_h / src_image.height() as f32;
- let mut local = skia_safe::Matrix::scale((sx, sy));
- local.post_translate((px, py));
-
- let tmx = repeat_keyword_to_tile_mode(img.repeat.x);
- let tmy = repeat_keyword_to_tile_mode(img.repeat.y);
-
- let shader = src_image.to_shader(
- Some((tmx, tmy)),
- sampling_for(style.font.image_rendering),
- Some(&local),
- );
- let Some(shader) = shader else {
- return;
- };
-
- let mut paint = Paint::default();
- paint.set_style(PaintStyle::Fill);
- paint.set_anti_alias(true);
- paint.set_shader(shader);
-
canvas.save();
// Clip to the referenced box with radii shrunk to match the box's
// inner edge. border-box uses the declared `border-radius` as-is;
@@ -620,7 +668,54 @@ fn paint_background_image_layer(
} else {
canvas.clip_rect(clip_rect, ClipOp::Intersect, true);
}
- canvas.draw_rect(clip_rect, &paint);
+
+ let needs_space_loop = img.repeat.x == BackgroundRepeatKeyword::Space
+ || img.repeat.y == BackgroundRepeatKeyword::Space;
+
+ if needs_space_loop {
+ // `space` distributes whitespace between copies, which a single
+ // tiled shader can't express. Draw each copy individually.
+ let pos_x = resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w);
+ let pos_y = resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h);
+ let xs = axis_tile_positions(img.repeat.x, origin_rect.width(), tile_w, pos_x);
+ let ys = axis_tile_positions(img.repeat.y, origin_rect.height(), tile_h, pos_y);
+ let sampling = sampling_for(style.font.image_rendering);
+ let paint = Paint::default();
+ for y in &ys {
+ for x in &xs {
+ let dst =
+ Rect::from_xywh(origin_rect.left + *x, origin_rect.top + *y, tile_w, tile_h);
+ canvas
+ .draw_image_rect_with_sampling_options(&src_image, None, dst, sampling, &paint);
+ }
+ }
+ } else {
+ let px = resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w)
+ + origin_rect.left;
+ let py = resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h)
+ + origin_rect.top;
+
+ let sx = tile_w / src_image.width() as f32;
+ let sy = tile_h / src_image.height() as f32;
+ let mut local = skia_safe::Matrix::scale((sx, sy));
+ local.post_translate((px, py));
+
+ let tmx = repeat_keyword_to_tile_mode(img.repeat.x);
+ let tmy = repeat_keyword_to_tile_mode(img.repeat.y);
+
+ if let Some(shader) = src_image.to_shader(
+ Some((tmx, tmy)),
+ sampling_for(style.font.image_rendering),
+ Some(&local),
+ ) {
+ let mut paint = Paint::default();
+ paint.set_style(PaintStyle::Fill);
+ paint.set_anti_alias(true);
+ paint.set_shader(shader);
+ canvas.draw_rect(clip_rect, &paint);
+ }
+ }
+
canvas.restore();
}
@@ -2122,6 +2217,46 @@ fn paint_box_shadow_inset(canvas: &Canvas, style: &StyledElement, w: f32, h: f32
// ─── Text painting (Chromium: TextPainter) ───────────────────────────
+/// Paint a geometric list-item marker (disc/circle/square).
+///
+/// Mirrors Chromium's `TextFragmentPainter::PaintSymbol`: filled ellipse
+/// for disc, 1px-stroked ellipse for circle, filled rect for square.
+/// `placeholder` is in canvas-absolute coordinates.
+fn paint_symbol_marker(
+ canvas: &Canvas,
+ marker: &super::style::SymbolMarker,
+ placeholder: Rect,
+ direction: super::types::Direction,
+) {
+ let bullet = marker.bullet_rect(placeholder, direction);
+
+ let mut paint = Paint::default();
+ paint.set_anti_alias(true);
+ paint.set_color(Color::from_argb(
+ marker.color.a,
+ marker.color.r,
+ marker.color.g,
+ marker.color.b,
+ ));
+
+ use super::types::SymbolMarkerKind::*;
+ match marker.kind {
+ Disc => {
+ paint.set_style(PaintStyle::Fill);
+ canvas.draw_oval(bullet, &paint);
+ }
+ Circle => {
+ paint.set_style(PaintStyle::Stroke);
+ paint.set_stroke_width(1.0);
+ canvas.draw_oval(bullet, &paint);
+ }
+ Square => {
+ paint.set_style(PaintStyle::Fill);
+ canvas.draw_rect(bullet, &paint);
+ }
+ }
+}
+
fn paint_text(canvas: &Canvas, run: &TextRun, x: f32, y: f32, width: f32, fonts: &FontCollection) {
let mut ps = ParagraphStyle::new();
let align = match run.font.text_align {
@@ -2131,6 +2266,7 @@ fn paint_text(canvas: &Canvas, run: &TextRun, x: f32, y: f32, width: f32, fonts:
TextAlign::Justify => textlayout::TextAlign::Justify,
};
ps.set_text_align(align);
+ ps.set_text_direction(super::layout::direction_to_skia(run.font.direction));
let mut builder = ParagraphBuilder::new(&ps, fonts);
@@ -2182,6 +2318,7 @@ fn paint_inline_group(
TextAlign::Justify => textlayout::TextAlign::Justify,
};
ps.set_text_align(align);
+ ps.set_text_direction(super::layout::direction_to_skia(group.direction));
let mut builder = ParagraphBuilder::new(&ps, fonts);
@@ -2205,6 +2342,12 @@ fn paint_inline_group(
let mut deco_ranges: Vec = Vec::new();
let mut offset: usize = 0;
+ // `(placeholder_index, marker)` pairs — Skia returns
+ // `get_rects_for_placeholders()` in insertion order, so the index
+ // is just the placeholder count at push time.
+ let mut marker_placeholders: Vec<(usize, super::style::SymbolMarker)> = Vec::new();
+ let mut ph_idx: usize = 0;
+
if indent_px > 0.0 {
builder.add_placeholder(&PlaceholderStyle::new(
indent_px,
@@ -2214,6 +2357,7 @@ fn paint_inline_group(
0.0,
));
offset += PLACEHOLDER_OFFSET;
+ ph_idx += 1;
}
for item in &group.items {
@@ -2238,6 +2382,7 @@ fn paint_inline_group(
0.0,
));
offset += PLACEHOLDER_OFFSET;
+ ph_idx += 1;
}
// Record start AFTER the open placeholder
deco_stack.push((offset, decoration.clone()));
@@ -2260,14 +2405,38 @@ fn paint_inline_group(
0.0,
));
offset += PLACEHOLDER_OFFSET;
+ ph_idx += 1;
}
}
+ InlineRunItem::SymbolMarker(m) => {
+ let (w, h) = m.placeholder_size();
+ builder.add_placeholder(&PlaceholderStyle::new(
+ w,
+ h,
+ PlaceholderAlignment::AboveBaseline,
+ TextBaseline::Alphabetic,
+ 0.0,
+ ));
+ marker_placeholders.push((ph_idx, *m));
+ offset += PLACEHOLDER_OFFSET;
+ ph_idx += 1;
+ }
}
}
let mut para = builder.build();
para.layout(width);
+ // Pass 0: Paint geometric list-item markers.
+ if !marker_placeholders.is_empty() {
+ let ph_rects = para.get_rects_for_placeholders();
+ for (idx, marker) in &marker_placeholders {
+ if let Some(tb) = ph_rects.get(*idx) {
+ paint_symbol_marker(canvas, marker, tb.rect.with_offset((x, y)), group.direction);
+ }
+ }
+ }
+
// Pass 1: Paint inline box decorations (Chromium: InlineBoxPainter)
for deco_range in &deco_ranges {
if deco_range.range_start >= deco_range.range_end {
diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs
index f43ee53f5..480827e4a 100644
--- a/crates/grida-canvas/src/htmlcss/style.rs
+++ b/crates/grida-canvas/src/htmlcss/style.rs
@@ -208,6 +208,9 @@ pub struct ReplacedContent {
pub struct InlineGroup {
pub items: Vec,
pub text_align: TextAlign,
+ /// Inherited `direction` — sets the paragraph's base bidi
+ /// direction (LTR / RTL). Passed to Skia's `ParagraphStyle`.
+ pub direction: super::types::Direction,
/// Inherited `text-indent` of the containing block. Applied as a
/// first-line-only inline-start offset by prepending a Skia
/// placeholder to the Paragraph.
@@ -220,6 +223,8 @@ pub struct InlineGroup {
/// - `Text` → `kText` — a contiguous span of styled text
/// - `OpenBox` → `kOpenTag` — start of an inline box (adds inline-start spacing)
/// - `CloseBox` → `kCloseTag` — end of an inline box (adds inline-end spacing)
+/// - `SymbolMarker` → `ListStyleCategory::kSymbol` — geometric list-item
+/// marker (disc/circle/square), painted as ellipse/rect rather than text.
#[derive(Debug, Clone)]
pub enum InlineRunItem {
/// Text content with uniform styling.
@@ -237,6 +242,55 @@ pub enum InlineRunItem {
/// Inline-end spacing = padding + border + margin (inline-end side).
inline_size: f32,
},
+ /// Geometric list-item marker (disc/circle/square). Reserved as a
+ /// Skia placeholder at measure time; painted as
+ /// `draw_oval`/`draw_rect` at the placeholder's rect.
+ SymbolMarker(SymbolMarker),
+}
+
+/// A geometric list-item marker for `list-style-type: disc | circle | square`.
+///
+/// `font_size` is captured so the marker can compute its own dimensions
+/// (bullet ≈ `ascent / 3` ≈ `font_size / 4`) without re-threading the
+/// inherited font through layout and paint.
+#[derive(Debug, Clone, Copy)]
+pub struct SymbolMarker {
+ pub kind: super::types::SymbolMarkerKind,
+ pub color: CGColor,
+ pub font_size: f32,
+}
+
+impl SymbolMarker {
+ /// `(width, height)` of the Skia placeholder reserved for this
+ /// marker. Width = bullet + inline-end gap; height = ascent
+ /// approximation so the placeholder sits in the correct vertical
+ /// band above the baseline.
+ pub fn placeholder_size(&self) -> (f32, f32) {
+ let bullet = self.font_size * 0.25;
+ let gap = self.font_size * 0.5;
+ let ascent_approx = self.font_size * 0.75;
+ (bullet + gap, ascent_approx)
+ }
+
+ /// Rect (in the same coordinate space as `placeholder`) where the
+ /// bullet geometry should be painted — `bullet_width` square,
+ /// inset 1px from the inline-start edge, vertically centered.
+ /// `direction` determines which edge is inline-start so the gap
+ /// sits between the bullet and the text in both LTR and RTL.
+ pub fn bullet_rect(
+ &self,
+ placeholder: skia_safe::Rect,
+ direction: super::types::Direction,
+ ) -> skia_safe::Rect {
+ let bullet = self.font_size * 0.25;
+ let cy = (placeholder.top + placeholder.bottom) * 0.5;
+ let left = match direction {
+ super::types::Direction::Ltr => placeholder.left + 1.0,
+ super::types::Direction::Rtl => placeholder.right - bullet - 1.0,
+ };
+ let top = cy - bullet * 0.5;
+ skia_safe::Rect::from_xywh(left, top, bullet, bullet)
+ }
}
// ─── Box Model Sub-types (StyleSurroundData) ─────────────────────────
@@ -828,6 +882,7 @@ pub struct FontProps {
pub word_spacing: f32,
pub text_align: TextAlign,
pub text_transform: TextTransform,
+ pub direction: super::types::Direction,
/// Bitfield: multiple decorations can be active simultaneously.
/// CSS `text-decoration-line: underline line-through` sets both.
pub decoration_underline: bool,
@@ -859,6 +914,7 @@ impl Default for FontProps {
word_spacing: 0.0,
text_align: TextAlign::Left,
text_transform: TextTransform::None,
+ direction: super::types::Direction::Ltr,
decoration_underline: false,
decoration_overline: false,
decoration_line_through: false,
diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs
index 5f73f07d8..6ac88f8e0 100644
--- a/crates/grida-canvas/src/htmlcss/types.rs
+++ b/crates/grida-canvas/src/htmlcss/types.rs
@@ -206,6 +206,18 @@ pub enum ImageRendering {
Pixelated,
}
+/// CSS `direction` property — inline base direction.
+///
+/// Inherited. Affects bidi reordering, the meaning of logical
+/// `start`/`end` keywords, and the default text alignment in
+/// absence of an explicit `text-align`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Direction {
+ #[default]
+ Ltr,
+ Rtl,
+}
+
/// CSS `vertical-align` property (inline-level).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VerticalAlign {
@@ -220,6 +232,22 @@ pub enum VerticalAlign {
Super,
}
+/// Symbol marker kinds — painted geometrically rather than as Unicode
+/// glyphs so they stay proportional to the font metrics regardless of
+/// platform font coverage.
+///
+/// Mirrors Chromium's `ListStyleCategory::kSymbol` paint path
+/// (`core/paint/text_fragment_painter.cc::PaintSymbol`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SymbolMarkerKind {
+ /// Filled ellipse.
+ Disc,
+ /// 1px-stroked ellipse.
+ Circle,
+ /// Filled square.
+ Square,
+}
+
/// CSS `list-style-type` property.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListStyleType {
diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md
index a8e87a290..71a7bcfc2 100644
--- a/docs/wg/feat-2d/htmlcss.md
+++ b/docs/wg/feat-2d/htmlcss.md
@@ -190,7 +190,7 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics:
| Multi-layer backgrounds | ✅ | Stacked gradient + solid + URL layers |
| `background-position` | ✅ | Per-layer, px/%/keyword per axis |
| `background-size` | ✅ | `cover`/`contain`/`auto`/explicit (with aspect preservation) |
-| `background-repeat` | ⚠️ | `repeat`/`no-repeat`/`repeat-x`/`repeat-y`; `space` and `round` currently fall back to `repeat` |
+| `background-repeat` | ✅ | `repeat`/`no-repeat`/`repeat-x`/`repeat-y`/`space`/`round`; `space` distributes whitespace between edge-pinned copies, `round` scales tile to fit integer copies |
| `background-origin` | ✅ | `border-box`/`padding-box`/`content-box` |
| `background-clip` | ✅ | `border-box`/`padding-box`/`content-box`; border-box clip honors radius |
| `background-attachment` | ❌ | |
@@ -336,13 +336,13 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics:
### Writing Modes & BiDi
-| CSS Property | Status | Notes |
-| ---------------------- | ------ | ----- |
-| `direction` | ❌ | |
-| `writing-mode` | ❌ | |
-| `unicode-bidi` | ❌ | |
-| `text-orientation` | ❌ | |
-| `text-combine-upright` | ❌ | |
+| CSS Property | Status | Notes |
+| ---------------------- | ------ | -------------------------------------------------------------------------------------- |
+| `direction` | ✅ | ltr/rtl — Skia paragraph base direction; also resolves logical `text-align: start/end` |
+| `writing-mode` | ❌ | |
+| `unicode-bidi` | ❌ | |
+| `text-orientation` | ❌ | |
+| `text-combine-upright` | ❌ | |
### Inline Layout & Alignment
@@ -364,17 +364,17 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics:
### Lists
-| Feature | Status | Notes |
-| ------------------------------ | ------ | --------------------------------------------- |
-| `` with disc/circle/square | ✅ | Marker text prepended to list item content |
-| `` with decimal numbering | ✅ | Auto-incrementing counter |
-| `lower-alpha`, `upper-alpha` | ✅ | |
-| `lower-roman`, `upper-roman` | ❌ | Stylo servo-mode limitation (servo/stylo#349) |
-| `list-style-type: none` | ✅ | |
-| `list-style-image` | ❌ | |
-| `list-style-position` | ❌ | |
-| `list-style` (shorthand) | ⚠️ | type only |
-| Nested lists | ✅ | Independent counters per list |
+| Feature | Status | Notes |
+| ------------------------------ | ------ | ------------------------------------------------------------------------------- |
+| `` with disc/circle/square | ✅ | Marker text prepended to list item content |
+| `` with decimal numbering | ✅ | Auto-incrementing counter |
+| `lower-alpha`, `upper-alpha` | ✅ | |
+| `lower-roman`, `upper-roman` | ⚠️ | Via HTML ``/`"I"` attribute (Stylo servo can't parse the CSS form) |
+| `list-style-type: none` | ✅ | |
+| `list-style-image` | ❌ | |
+| `list-style-position` | ❌ | |
+| `list-style` (shorthand) | ⚠️ | type only |
+| Nested lists | ✅ | Independent counters per list |
### Visual Effects
diff --git a/fixtures/test-html/L0/list-ol-start-value.html b/fixtures/test-html/L0/list-ol-start-value.html
new file mode 100644
index 000000000..e3d653f3c
--- /dev/null
+++ b/fixtures/test-html/L0/list-ol-start-value.html
@@ -0,0 +1,109 @@
+
+
+
+
+ List: <ol start> + <li value>
+
+
+
+
+
+
no attrs (baseline)
+
+ - a
+ - b
+ - c
+
+
+
+
start="5"
+
+ - a
+ - b
+ - c
+
+
+
+
start="-1" (allowed)
+
+ - a
+ - b
+ - c
+
+
+
+
li value="10" on middle item
+
+ - a
+ - b
+ - c
+ - d
+
+
+
+
start="3" + li value="20"
+
+ - a
+ - b
+ - c
+ - d
+
+
+
+
type="I" + start="7"
+
+ - seven
+ - eight
+ - nine
+ - ten
+
+
+
+
type="a" start="25" (y, z, aa, ab)
+
+ - y
+ - z
+ - aa
+ - ab
+
+
+
+
type="A" start="51" (AY, AZ, BA, BB)
+
+ - AY
+ - AZ
+ - BA
+ - BB
+
+
+
+
+
diff --git a/fixtures/test-html/L0/list-ol-type.html b/fixtures/test-html/L0/list-ol-type.html
new file mode 100644
index 000000000..d5950349a
--- /dev/null
+++ b/fixtures/test-html/L0/list-ol-type.html
@@ -0,0 +1,105 @@
+
+
+
+
+ List: <ol type> HTML attribute
+
+
+
+
+
+
type="1" (default)
+
+ - Alpha
+ - Beta
+ - Gamma
+ - Delta
+
+
+
+
type="a"
+
+ - Alpha
+ - Beta
+ - Gamma
+ - Delta
+
+
+
+
type="A"
+
+ - Alpha
+ - Beta
+ - Gamma
+ - Delta
+
+
+
+
type="i"
+
+ - Alpha
+ - Beta
+ - Gamma
+ - Delta
+ - Epsilon
+
+
+
+
type="I" (to 10 to show X)
+
+ - I
+ - II
+ - III
+ - IV
+ - V
+ - VI
+ - VII
+ - VIII
+ - IX
+ - X
+
+
+
+
nested: I outer, a inner
+
+ -
+ Outer one
+
+ - inner a
+ - inner b
+
+
+ - Outer two
+
+
+
+
+
diff --git a/fixtures/test-html/L0/list-ul-type.html b/fixtures/test-html/L0/list-ul-type.html
new file mode 100644
index 000000000..66b0fc30b
--- /dev/null
+++ b/fixtures/test-html/L0/list-ul-type.html
@@ -0,0 +1,92 @@
+
+
+
+
+ List: <ul type> HTML attribute
+
+
+
+
+
+
no attr (default: disc)
+
+
+
+
+
+
+
type="CIRCLE" (case-insensitive)
+
+
+
+
nested: square outer, circle inner
+
+ -
+ outer one
+
+
+ - outer two
+
+
+
+
+
diff --git a/fixtures/test-html/L0/paint-background-repeat-space-round.html b/fixtures/test-html/L0/paint-background-repeat-space-round.html
new file mode 100644
index 000000000..8faf165d7
--- /dev/null
+++ b/fixtures/test-html/L0/paint-background-repeat-space-round.html
@@ -0,0 +1,121 @@
+
+
+
+
+ Paint: background-repeat — space & round
+
+
+
+
+
+
+
space x, no-repeat y
+
+
+
+
no-repeat x, space y
+
+
+
+
+
round x, no-repeat y
+
+
+
+
no-repeat x, round y
+
+
+
+
+
+
space, only 1 fits (60×100, tile 50)
+
+
+
+
+
diff --git a/fixtures/test-html/L0/paint-background-repeat.html b/fixtures/test-html/L0/paint-background-repeat.html
index 2475a4e85..66cab268f 100644
--- a/fixtures/test-html/L0/paint-background-repeat.html
+++ b/fixtures/test-html/L0/paint-background-repeat.html
@@ -76,11 +76,11 @@