From 1fbf918f4ee6bac44454476b38868d2217037d0e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 22 Sep 2026 11:04:04 +0200 Subject: [PATCH] fix(paint): orient linear gradients to the css angle convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `angle_deg = 180` (the default, CSS "to bottom"): rad = 0, sin_a = 0, cos_a = -1, so p0 = (cx, cy + h/2) = bottom edge and p1 = top edge. Skia places `colors[0]` at p0, so the first stop lands at the *bottom* where CSS puts it at the top. Same 180-degree offset at every angle (90 -> first stop on the right, CSS says left). The repo's own skill doc contradicts the rendered result: `.claude/skills/rustmotion/rules/glassmorphism.md:114` prescribes `"gradient-border": { "colors": ["#FFFFFF50", "#FFFFFF08"], "width": 1.5, "angle": 180 }` and describes it at line 118 as "la lumière qui accroche le haut de la carte" — but this function puts the bright stop at the bottom. `paint_gradient_border` (line 1375) shares the function, so both `background` gradients and `gradient-border` are affected. Note the engine also carries a second, incompatible convention for `Fill::Gradient` in rustmotion-components/src/shape.rs:78 (angle 0 = left-to-right, math convention). Refs #220 --- .../rustmotion-core/src/engine/paint_pass.rs | 9 ++- crates/rustmotion-core/tests/audit_ws_a.rs | 55 ++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 048b3c81..4b93fe75 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1337,11 +1337,16 @@ fn gradient_stops(stops: &[crate::css::style::GradientStop]) -> (Vec, V (colors, positions) } +/// Gradient-line endpoints for a CSS ``: `0deg` points the line "to +/// top" (first stop at the bottom, travelling up to the last stop), and the +/// angle increases clockwise, so `90deg` is "to right" and `180deg` (the +/// default) is "to bottom" (first stop at the top). Skia's +/// `linear_gradient` places `colors[0]` at `p0`, so `p0` is always the end +/// the angle points *away from*. fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) { - // CSS angle: 0deg = bottom→top, increasing clockwise. let cx = bounds.left + bounds.width() / 2.0; let cy = bounds.top + bounds.height() / 2.0; - let rad = (angle_deg - 180.0).to_radians(); + let rad = angle_deg.to_radians(); let (sin_a, cos_a) = (rad.sin(), -rad.cos()); let len = (bounds.width().abs() * sin_a.abs() + bounds.height().abs() * cos_a.abs()) / 2.0; let p0 = Point::new(cx - sin_a * len, cy - cos_a * len); diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs index d42a55bf..dbf4160d 100644 --- a/crates/rustmotion-core/tests/audit_ws_a.rs +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -2,8 +2,8 @@ //! tracked in issue #220. use rustmotion_core::css::style::{ - Background, BoxShadow, Color as CssColor, CssStyle, Display, FlexDirection, Position, - Size as CSize, + Background, BackgroundLayer, BoxShadow, Color as CssColor, CssStyle, Display, FlexDirection, + GradientStop, Position, Size as CSize, }; use rustmotion_core::css::taffy_bridge::ConversionContext; use rustmotion_core::css::units::{Length, LengthPercentage as CLP}; @@ -191,3 +191,54 @@ fn underdamped_spring_matches_the_analytic_closed_form() { ); } } + +// ---- linear-gradient(180deg, ...) must put the first stop at the top ---- + +fn gradient_card(w: f32, h: f32, angle: f32) -> BoxNode { + let css = CssStyle { + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Single(BackgroundLayer::LinearGradient { + angle: Some(angle), + stops: vec![ + GradientStop { + color: CssColor::String("#ffffff".into()), + offset: Some(0.0), + }, + GradientStop { + color: CssColor::String("#000000".into()), + offset: Some(1.0), + }, + ], + })), + ..Default::default() + }; + BoxNode { + id: 0, + kind: BoxKind::Container, + css, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } +} + +#[test] +fn linear_gradient_180deg_puts_the_first_stop_at_the_top() { + // CSS: `angle: 180` ("to bottom") points the gradient line downward, so + // the first stop lands at the top and the last stop at the bottom. + let mut root = gradient_card(100.0, 100.0, 180.0); + let buf = render_pixels(&mut root, 100, 100); + + let top = probe(&buf, 100, 50, 2); + let bottom = probe(&buf, 100, 50, 97); + assert!( + top.0 > 200 && top.1 > 200 && top.2 > 200, + "angle: 180 must put the white first stop at the top, got {top:?}" + ); + assert!( + bottom.0 < 50 && bottom.1 < 50 && bottom.2 < 50, + "angle: 180 must put the black last stop at the bottom, got {bottom:?}" + ); +}