From 106873f2b29254afed1bac94b89b9d4029e5750a Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 19:17:47 +0200 Subject: [PATCH 1/3] feat(native): ease the mix between two gradient stops --- .changeset/eased-gradients.md | 15 ++ examples/demo.test.tsx | 2 + examples/demo/gradients.tsx | 88 ++++++++++- packages/native/css/src/background.rs | 150 ++++++++++++++++++- packages/react/src/__tests__/styles.test.tsx | 24 +++ 5 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 .changeset/eased-gradients.md diff --git a/.changeset/eased-gradients.md b/.changeset/eased-gradients.md new file mode 100644 index 00000000..3060b72d --- /dev/null +++ b/.changeset/eased-gradients.md @@ -0,0 +1,15 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Ease the mix between two gradient stops. + +An `` between two colour stops bends the mix, following the +CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black, +ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and +`cubic-bezier()` are read. The shader solves the curve per fragment, so an +eased scrim stays one quad. A straight fade to transparent looks dense near +the solid stop and thin near the clear one; an eased one reads as one smooth +fall-off. The Windows shader also catches up with eight stops and corner +shapes. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 93cfd63e..7b579606 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -14,6 +14,7 @@ import type { TestRoot } from "@gpuix/react" import { App, BASE, PALETTES } from "./demo/app" import { ClassNames } from "./demo/class-names" import { Colors } from "./demo/colors" +import { Gradients } from "./demo/gradients" import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" @@ -31,6 +32,7 @@ function root(): TestRoot { const PANELS = [ ["colors", ], + ["gradients", ], ["lengths", ], ["variables", ], ["inheritance", ], diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx index 702e73aa..876f51be 100644 --- a/examples/demo/gradients.tsx +++ b/examples/demo/gradients.tsx @@ -8,6 +8,77 @@ import React from "react" import { Grid, Panel, Sample, Swatch } from "./ui.js" +/// A sticky header over scrolling rows, with a progressive blur under it. +/// +/// The header box has `backdropFilter: blur() saturate()`, which blurs the +/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` +/// gradient that fades that blur out toward the bottom of the box. The +/// easing on the mask keeps the fall-off smooth. There is no scrim, only a +/// faint tint at the top so the large title stays readable over bright +/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, +/// so the page does not move with it. +export function StickyHeader() { + const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) + return ( + +
+
+ {rows.map((row, i) => ( +
+ {row} +
+ ))} +
+
+
+ Inbox +
+
+ + ) +} + const DIRECTIONS: Array<[string, string]> = [ ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], @@ -32,6 +103,15 @@ const ALPHA: Array<[string, string]> = [ ["linear-gradient(to right, currentColor, transparent)", "currentColor as a stop"], ] +const EASING: Array<[string, string]> = [ + ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "no easing: a straight mix"], + ["linear-gradient(to right, #ff5c8a, ease-in-out, #5cc8ff)", "ease-in-out holds both ends longer"], + ["linear-gradient(to right, #ff5c8a, ease-in, #5cc8ff)", "ease-in keeps the first colour"], + ["linear-gradient(to right, #ff5c8a, cubic-bezier(0.7, 0, 0.3, 1), #5cc8ff)", "any cubic-bezier()"], + ["linear-gradient(to top, black, transparent)", "a straight scrim: dense at the bottom, a hard edge at the top"], + ["linear-gradient(to top, black, ease-in-out, transparent)", "the same scrim eased"], +] + function List({ title, note, entries }: { title: string note: string @@ -63,9 +143,15 @@ export function Gradients() { note="Up to eight stops. Missing positions spread evenly, and a position that steps back snaps to the one before it." entries={STOPS} /> + +
diff --git a/packages/native/css/src/background.rs b/packages/native/css/src/background.rs index 611099a7..f666fac0 100644 --- a/packages/native/css/src/background.rs +++ b/packages/native/css/src/background.rs @@ -19,6 +19,76 @@ use lightningcss::values::position::{HorizontalPositionKeyword, VerticalPosition use crate::color::{self, ColorContext, Rgba}; use crate::CssError; +/// An easing between two stops: the control points `[x1, y1, x2, y2]` of a +/// cubic bezier from (0, 0) to (1, 1). All zero means none, a straight mix. +/// +/// CSS has no easing in gradients yet. This is the syntax the CSSWG proposal +/// (csswg-drafts issue 1332) uses: an `` in the place of a +/// colour hint, between two colour stops. +pub type Easing = [f32; 4]; + +/// Read one `` from CSS Easing 1. `linear` reads as none. +pub fn easing(text: &str) -> Option { + let lower = text.trim().to_ascii_lowercase(); + match lower.as_str() { + "linear" => return Some([0.0; 4]), + "ease" => return Some([0.25, 0.1, 0.25, 1.0]), + "ease-in" => return Some([0.42, 0.0, 1.0, 1.0]), + "ease-out" => return Some([0.0, 0.0, 0.58, 1.0]), + "ease-in-out" => return Some([0.42, 0.0, 0.58, 1.0]), + _ => {} + } + let inner = lower.strip_prefix("cubic-bezier(")?.strip_suffix(')')?; + let numbers: Vec = inner + .split(',') + .map(|n| n.trim().parse::().ok().filter(|n| n.is_finite())) + .collect::>()?; + let [x1, y1, x2, y2] = numbers[..] else { return None }; + let unit = 0.0..=1.0; + (unit.contains(&x1) && unit.contains(&x2)).then_some([x1, y1, x2, y2]) +} + +/// Split at the commas outside parentheses. +fn split_top_level(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut depth = 0i32; + let mut start = 0; + for (i, c) in text.char_indices() { + match c { + '(' => depth += 1, + ')' => depth = (depth - 1).max(0), + ',' if depth == 0 => { + out.push(&text[start..i]); + start = i + 1; + } + _ => {} + } + } + out.push(&text[start..]); + out +} + +/// Pull the easings out of a `linear-gradient()` so lightningcss can read +/// the rest. Returns the value without them, how many arguments stay, and +/// each easing with the index of the argument that follows it. +fn split_easings(value: &str) -> Option<(String, usize, Vec<(usize, Easing)>)> { + let open = value.find('(')?; + let close = value.rfind(')')?; + let head = &value[..open]; + if !head.trim().eq_ignore_ascii_case("linear-gradient") { + return None; + } + let mut kept = Vec::new(); + let mut easings = Vec::new(); + for piece in split_top_level(&value[open + 1..close]) { + match easing(piece) { + Some(easing) => easings.push((kept.len(), easing)), + None => kept.push(piece.trim()), + } + } + Some((format!("{head}({})", kept.join(", ")), kept.len(), easings)) +} + /// Where the line of a linear gradient points. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Line { @@ -39,6 +109,8 @@ pub struct Stop { /// Where between this stop and the next the mix is half way, as a /// fraction of that span. 0 means no hint. pub hint: f32, + /// The easing to the next stop. All zero is none. + pub easing: Easing, } /// A `linear-gradient()` ready to paint. @@ -76,7 +148,9 @@ pub struct Reading { /// Read one background value. `none` reads as `Ok(None)`. pub fn read(value: &str, context: &ColorContext) -> Result, CssError> { - let Ok(image) = Image::parse_string(value) else { + let (parsed, kept, easings) = + split_easings(value).unwrap_or_else(|| (value.to_string(), 0, Vec::new())); + let Ok(image) = Image::parse_string(&parsed) else { let reading = color::read(value, context)?; return Ok(Some(Reading { fill: Fill::Color(reading.color), @@ -88,7 +162,15 @@ pub fn read(value: &str, context: &ColorContext) -> Result, CssE Image::Gradient(gradient) => match *gradient { Gradient::Linear(linear) => { let line = line_of(&linear.direction); - let (stops, read_current_color) = fix_up(&linear.items, context, value)?; + // The direction, when written, is the one argument that is + // not an item. An easing sits after the item before it. + let offset = kept - linear.items.len(); + let easings = easings + .iter() + .map(|(index, easing)| (index.checked_sub(offset + 1), *easing)) + .collect::>(); + let (stops, read_current_color) = + fix_up(&linear.items, &easings, context, value)?; Ok(Some(Reading { fill: Fill::LinearGradient(LinearGradient { line, stops }), read_current_color, @@ -140,6 +222,7 @@ type Item = GradientItem; struct Pending { color: Option, position: Option, + easing: Easing, } /// Turn the parsed items into stops with positions, the way CSS Images 3 @@ -153,6 +236,7 @@ struct Pending { /// the stop after it. fn fix_up( items: &[Item], + easings: &[(Option, Easing)], context: &ColorContext, value: &str, ) -> Result<(Vec, bool), CssError> { @@ -169,19 +253,33 @@ fn fix_up( .as_ref() .map(|p| fraction(p, value)) .transpose()?, + easing: [0.0; 4], }); } GradientItem::Hint(position) => pending.push(Pending { color: None, position: Some(fraction(position, value)?), + easing: [0.0; 4], }), } } + let bad_value = || CssError::BadValue { + property: "background".to_string(), + value: value.to_string(), + }; if pending.len() < 2 { - return Err(CssError::BadValue { - property: "background".to_string(), - value: value.to_string(), - }); + return Err(bad_value()); + } + // An easing goes between two colour stops, one per pair, and not next + // to a hint, which already says where the half-way point is. + for (index, easing) in easings { + let Some(index) = *index else { return Err(bad_value()) }; + let both_colours = pending.get(index).is_some_and(|p| p.color.is_some()) + && pending.get(index + 1).is_some_and(|p| p.color.is_some()); + if !both_colours || pending[index].easing != [0.0; 4] { + return Err(bad_value()); + } + pending[index].easing = *easing; } let last = pending.len() - 1; @@ -216,7 +314,12 @@ fn fix_up( for (i, item) in pending.iter().enumerate() { let position = item.position.unwrap(); match item.color { - Some(color) => stops.push(Stop { color, position, hint: 0.0 }), + Some(color) => stops.push(Stop { + color, + position, + hint: 0.0, + easing: item.easing, + }), None => { let Some(previous) = stops.last_mut() else { continue }; let next = pending[i + 1..] @@ -293,6 +396,39 @@ mod tests { assert_eq!(gradient.stops[1].hint, 0.0); } + #[test] + fn reads_an_easing_between_two_stops() { + let read = gradient("linear-gradient(to right, red, ease-in-out, blue)"); + assert_eq!(read.stops.len(), 2); + assert_eq!(read.stops[0].easing, [0.42, 0.0, 0.58, 1.0]); + assert_eq!(read.stops[1].easing, [0.0; 4]); + + let read = + gradient("linear-gradient(red, cubic-bezier(0.5, 0, 1, 1.5), blue 80%, green)"); + assert_eq!(read.stops[0].easing, [0.5, 0.0, 1.0, 1.5]); + assert_eq!(read.stops[1].position, 0.8); + assert_eq!(read.stops[1].easing, [0.0; 4]); + + // `linear` is the straight mix, which is what no easing does. + let read = gradient("linear-gradient(red, linear, blue)"); + assert_eq!(read.stops[0].easing, [0.0; 4]); + } + + #[test] + fn an_easing_needs_a_stop_on_each_side() { + let context = ColorContext::default(); + for bad in [ + "linear-gradient(ease-in, red, blue)", + "linear-gradient(red, blue, ease-in)", + "linear-gradient(red, ease-in, ease-out, blue)", + "linear-gradient(red, ease-in, 30%, blue)", + "linear-gradient(red, 30%, ease-in, blue)", + "linear-gradient(red, cubic-bezier(2, 0, 1, 1), blue)", + ] { + assert!(read(bad, &context).is_err(), "{bad}"); + } + } + #[test] fn reads_every_direction() { assert_eq!(gradient("linear-gradient(red, blue)").line, Line::Angle(180.0)); diff --git a/packages/react/src/__tests__/styles.test.tsx b/packages/react/src/__tests__/styles.test.tsx index afe677c4..f11cecb2 100644 --- a/packages/react/src/__tests__/styles.test.tsx +++ b/packages/react/src/__tests__/styles.test.tsx @@ -1954,6 +1954,30 @@ describeNative("motion", () => { expect(plainB).toBeLessThan(40) }) + it("eases the mix between two gradient stops", () => { + const { render, renderer } = createTestRoot() + render( +
+
+
+
+
+ ) + // A quarter of the way along, ease-in is still mostly red, while the + // straight mix has given up a quarter of it. cubic-bezier(0, 1, 0, 1) + // jumps toward blue at once. + const [straightR] = renderer.pixelAt(60, 30) + const [easedR] = renderer.pixelAt(60, 80) + const [jumpedR] = renderer.pixelAt(60, 130) + expect(straightR).toBeGreaterThan(160) + expect(straightR).toBeLessThan(220) + expect(easedR).toBeGreaterThan(straightR + 20) + expect(jumpedR).toBeLessThan(straightR - 40) + // Both ends still land on the stops. + expect(renderer.pixelAt(12, 80)[0]).toBeGreaterThan(220) + expect(renderer.pixelAt(208, 80)[2]).toBeGreaterThan(220) + }) + it("cuts corners to the declared shape", () => { const { render, renderer } = createTestRoot() const box = { width: 100, height: 100, backgroundColor: "#ff0000" } From 71208b863804b04dc26ab137c8b4272af089f3ac Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 22:39:17 +0200 Subject: [PATCH 2/3] fix(native): paint a gradient easing as its half-point hint --- .changeset/eased-gradients.md | 10 ++--- packages/native/src/color.rs | 77 ++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/.changeset/eased-gradients.md b/.changeset/eased-gradients.md index 3060b72d..6afbb122 100644 --- a/.changeset/eased-gradients.md +++ b/.changeset/eased-gradients.md @@ -8,8 +8,8 @@ Ease the mix between two gradient stops. An `` between two colour stops bends the mix, following the CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black, ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and -`cubic-bezier()` are read. The shader solves the curve per fragment, so an -eased scrim stays one quad. A straight fade to transparent looks dense near -the solid stop and thin near the clear one; an eased one reads as one smooth -fall-off. The Windows shader also catches up with eight stops and corner -shapes. +`cubic-bezier()` are read. The easing paints as the GPUI colour hint whose +curve crosses one half at the same place, so the paint agrees with the easing +at both ends and at the half-way point. A straight fade to transparent looks +dense near the solid stop and thin near the clear one. An eased one reads as +one smooth fall-off. diff --git a/packages/native/src/color.rs b/packages/native/src/color.rs index 0ff1d0f5..3884d246 100644 --- a/packages/native/src/color.rs +++ b/packages/native/src/color.rs @@ -51,7 +51,7 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro .map(|stop| gpui::LinearColorStop { color: to_hsla(stop.color), percentage: stop.position, - hint: stop.hint, + hint: hint_for(stop), }) .collect(); gpui::linear_gradient_stops(line, &stops) @@ -59,6 +59,54 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro } } +/// The hint GPUI paints for one stop. +/// +/// GPUI has no easing in a gradient, but its hint moves the half-way point of +/// the mix along the CSS exponential curve. An easing becomes the hint where +/// its own curve crosses one half. The two curves then agree at both ends and +/// at the half-way point, which is as close as one number gets. +fn hint_for(stop: &gpuix_css::background::Stop) -> f32 { + if stop.hint != 0.0 || stop.easing == [0.0; 4] { + return stop.hint; + } + easing_half_point(stop.easing) +} + +/// The x at which a cubic bezier easing's output crosses one half. +/// +/// The curve runs from (0, 0) to (1, 1) with the control points +/// `[x1, y1, x2, y2]`. The output can overshoot, so this walks to the first +/// crossing and then bisects. +fn easing_half_point(easing: [f32; 4]) -> f32 { + let [x1, y1, x2, y2] = easing; + let at = |a: f32, b: f32, t: f32| { + let u = 1.0 - t; + 3.0 * u * u * t * a + 3.0 * u * t * t * b + t * t * t + }; + let y = |t: f32| at(y1, y2, t); + let mut low = 0.0f32; + let mut high = 1.0f32; + for step in 1..=64 { + let t = step as f32 / 64.0; + if y(t) >= 0.5 { + high = t; + low = t - 1.0 / 64.0; + break; + } + } + for _ in 0..24 { + let mid = (low + high) / 2.0; + if y(mid) < 0.5 { + low = mid; + } else { + high = mid; + } + } + let x = at(x1, x2, (low + high) / 2.0); + // The shader reads a hint of 0 or 1 as none, so keep the value inside. + x.clamp(0.001, 0.999) +} + /// Read a colour that depends on the element or the window. /// /// `currentColor` and `light-dark()` both need context, so this is the entry @@ -237,4 +285,31 @@ mod tests { Some(u32::from(rgba)) ); } + + #[test] + fn an_easing_becomes_the_hint_at_its_half_point() { + // ease-in-out is symmetric, so its half point is the middle, which is + // the same paint as no hint at all. + let middle = easing_half_point([0.42, 0.0, 0.58, 1.0]); + assert!((middle - 0.5).abs() < 0.01, "got {middle}"); + + // ease-in holds the first colour longer, so the half point sits late. + let late = easing_half_point([0.42, 0.0, 1.0, 1.0]); + assert!(late > 0.6, "got {late}"); + + // cubic-bezier(0, 1, 0, 1) jumps toward the second colour at once. + let early = easing_half_point([0.0, 1.0, 0.0, 1.0]); + assert!(early < 0.05, "got {early}"); + } + + #[test] + fn an_explicit_hint_wins_over_the_easing() { + let stop = gpuix_css::background::Stop { + color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + position: 0.0, + hint: 0.25, + easing: [0.42, 0.0, 1.0, 1.0], + }; + assert_eq!(hint_for(&stop), 0.25); + } } From 0830cdb00b86d6c2a8257a57fccf27bd7cb5390e Mon Sep 17 00:00:00 2001 From: "Mateo M." Date: Wed, 26 Aug 2026 23:02:55 +0200 Subject: [PATCH 3/3] fix(demo): move the blur header demo off the gradients branch --- examples/demo/gradients.tsx | 76 +------------------------------------ 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx index 876f51be..49530231 100644 --- a/examples/demo/gradients.tsx +++ b/examples/demo/gradients.tsx @@ -8,77 +8,6 @@ import React from "react" import { Grid, Panel, Sample, Swatch } from "./ui.js" -/// A sticky header over scrolling rows, with a progressive blur under it. -/// -/// The header box has `backdropFilter: blur() saturate()`, which blurs the -/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` -/// gradient that fades that blur out toward the bottom of the box. The -/// easing on the mask keeps the fall-off smooth. There is no scrim, only a -/// faint tint at the top so the large title stays readable over bright -/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, -/// so the page does not move with it. -export function StickyHeader() { - const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) - return ( - -
-
- {rows.map((row, i) => ( -
- {row} -
- ))} -
-
-
- Inbox -
-
- - ) -} - const DIRECTIONS: Array<[string, string]> = [ ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], @@ -145,13 +74,12 @@ export function Gradients() { /> -