Skip to content

fix(paint): orient linear gradients to the css angle convention - #225

Merged
LeadcodeDev merged 1 commit into
chantier/audit-2026-09from
fix/gradient-angle
Sep 22, 2026
Merged

LeadcodeDev merged 1 commit into
chantier/audit-2026-09from
fix/gradient-angle

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Severity Medium, category correctness. Location: crates/rustmotion-core/src/engine/paint_pass.rs:1262

Impact

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).

Fix

Drop the - 180.0 offset (use let rad = angle_deg.to_radians(); with the existing sign handling re-derived, or swap p0/p1) so that angle: 180 puts the first stop at the top, and add a pixel test asserting linear-gradient(180deg, white, black) is white at the top. Decide whether shape.rs's Fill::Gradient should be aligned onto the same convention.

Evidence the audit read

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 (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);
    let p1 = Point::new(cx + sin_a * len, cy + cos_a * len);

Stacked on fix/spring-step-response, which carries the previous finding of this workstream. GitHub shows only this finding's diff; merge in order.

Part of the September 2026 audit remediation chantier. Refs #220 (RM-10).

@LeadcodeDev LeadcodeDev added the bug Something isn't working label Sep 21, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 21, 2026
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from 4c92d97 to 42a83b8 Compare September 21, 2026 23:39
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from 5a808bd to d414df5 Compare September 22, 2026 06:09
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from d414df5 to bdc887f Compare September 22, 2026 08:33
@LeadcodeDev
LeadcodeDev force-pushed the fix/spring-step-response branch from bdc887f to 1cde354 Compare September 22, 2026 08:43
@LeadcodeDev
LeadcodeDev changed the base branch from fix/spring-step-response to chantier/audit-2026-09 September 22, 2026 08:52
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
@LeadcodeDev
LeadcodeDev merged commit e37518f into chantier/audit-2026-09 Sep 22, 2026
LeadcodeDev added a commit that referenced this pull request Sep 22, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant