Skip to main content

csskit_transform/
reduce_colors.rs

1use crate::prelude::*;
2use chromashift::{COLOR_EPSILON, ColorDistance, ColorSpace, Hex, Named, PerceptualRound, Srgb, ToAlpha, round_dp};
3use css_ast::{
4	CalcableValue, Color, ColorFunction, ColorMixFunction, CssTypes, HueInterpolationDirection,
5	InterpolationColorSpace, ToChromashift, VisitNode, Visitable,
6};
7use css_parse::{Arena, format_in};
8
9pub struct ReduceColors<'a, 'ctx, N: Visitable + NodeWithMetadata<CssMetadata>> {
10	pub transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>,
11}
12
13impl<'a, 'ctx, N> Transform<'a, 'ctx, CssMetadata, N, CssMinifierFeature> for ReduceColors<'a, 'ctx, N>
14where
15	N: Visitable + NodeWithMetadata<CssMetadata>,
16{
17	fn skips_subtree(metadata: &CssMetadata) -> bool {
18		!metadata.has_value_kinds(CssTypes::Color)
19	}
20
21	fn new(transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>) -> Self {
22		Self { transformer }
23	}
24}
25
26trait Shortest<'a> {
27	fn shortest(&self, arena: &'a Arena) -> Option<&'a str>;
28}
29
30impl<'a> Shortest<'a> for chromashift::Color {
31	fn shortest(&self, arena: &'a Arena) -> Option<&'a str> {
32		[
33			Some(format_in!(in arena, "{}", Hex::from(*self)).into_str()),
34			Named::try_from(*self).ok().map(|named| format_in!(in arena, "{named}").into_str()),
35			Some(format_in!(in arena, "{}", Srgb::from(*self).round()).into_str()),
36		]
37		.into_iter()
38		.flatten()
39		.min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
40	}
41}
42
43/// Formats a CSS alpha value (0–1) from chromashift's internal 0–100 representation.
44/// Returns `None` when the alpha is fully opaque (100.0).
45fn css_alpha(alpha: f32) -> Option<f64> {
46	if alpha >= 100.0 {
47		return None;
48	}
49	Some(round_dp(alpha as f64 / 100.0, 3))
50}
51
52/// Serialises a chromashift colour into valid CSS function syntax.
53///
54/// This produces the native-space representation (e.g. `lch(54.3 43.8 274.5 / 0.746)`) which the
55/// compact writer will then minify (removing leading zeros, collapsing whitespace around `/`, etc.).
56///
57/// Returns `None` for colour types that don't have a non-sRGB CSS function syntax (Hex, Named, Srgb, Hsv).
58trait ToCss<'a> {
59	fn to_css(&self, arena: &'a Arena) -> Option<&'a str>;
60}
61
62macro_rules! impl_to_css_3ch {
63	($ty:ident, $name:literal, $c1:ident, $c2:ident, $c3:ident) => {
64		impl<'a> ToCss<'a> for chromashift::$ty {
65			fn to_css(&self, arena: &'a Arena) -> Option<&'a str> {
66				let alpha = css_alpha(self.alpha);
67				if let Some(a) = alpha {
68					Some(
69						format_in!(in arena, concat!($name, "({} {} {} / {})"), self.$c1, self.$c2, self.$c3, a)
70							.into_str(),
71					)
72				} else {
73					Some(format_in!(in arena, concat!($name, "({} {} {})"), self.$c1, self.$c2, self.$c3).into_str())
74				}
75			}
76		}
77	};
78	($ty:ident, $name:literal, $c1:ident, $c2:ident: $suf2:literal, $c3:ident: $suf3:literal) => {
79		impl<'a> ToCss<'a> for chromashift::$ty {
80			fn to_css(&self, arena: &'a Arena) -> Option<&'a str> {
81				let alpha = css_alpha(self.alpha);
82				if let Some(a) = alpha {
83					Some(
84						format_in!(
85							in arena,
86							concat!($name, "({} {}", $suf2, " {}", $suf3, " / {})"),
87							self.$c1, self.$c2, self.$c3, a
88						)
89						.into_str(),
90					)
91				} else {
92					Some(
93						format_in!(
94							in arena,
95							concat!($name, "({} {}", $suf2, " {}", $suf3, ")"),
96							self.$c1, self.$c2, self.$c3
97						)
98						.into_str(),
99					)
100				}
101			}
102		}
103	};
104}
105
106macro_rules! impl_to_css_color_fn {
107	($ty:ident, $space:literal) => {
108		impl<'a> ToCss<'a> for chromashift::$ty {
109			fn to_css(&self, arena: &'a Arena) -> Option<&'a str> {
110				let alpha = css_alpha(self.alpha);
111				if let Some(a) = alpha {
112					Some(
113						format_in!(
114							in arena,
115							concat!("color(", $space, " {} {} {} / {})"),
116							self.red, self.green, self.blue, a
117						)
118						.into_str(),
119					)
120				} else {
121					Some(
122						format_in!(
123							in arena,
124							concat!("color(", $space, " {} {} {})"),
125							self.red, self.green, self.blue
126						)
127						.into_str(),
128					)
129				}
130			}
131		}
132	};
133}
134
135macro_rules! impl_to_css_xyz {
136	($ty:ident, $space:literal) => {
137		impl<'a> ToCss<'a> for chromashift::$ty {
138			fn to_css(&self, arena: &'a Arena) -> Option<&'a str> {
139				let alpha = css_alpha(self.alpha);
140				// CSS color(xyz-*) uses 0–1 scale; chromashift stores 0–100 internally.
141				// After dividing, re-apply round_dp(4) to eliminate float display artifacts
142				// (e.g. 18.76 / 100.0 = 0.18760000000000002 without this).
143				let x = round_dp(self.x / 100.0, 4);
144				let y = round_dp(self.y / 100.0, 4);
145				let z = round_dp(self.z / 100.0, 4);
146				if let Some(a) = alpha {
147					Some(format_in!(in arena, concat!("color(", $space, " {} {} {} / {})"), x, y, z, a).into_str())
148				} else {
149					Some(format_in!(in arena, concat!("color(", $space, " {} {} {})"), x, y, z).into_str())
150				}
151			}
152		}
153	};
154}
155
156impl_to_css_3ch!(Lab, "lab", lightness, a, b);
157impl_to_css_3ch!(Lch, "lch", lightness, chroma, hue);
158impl_to_css_3ch!(Oklab, "oklab", lightness, a, b);
159impl_to_css_3ch!(Oklch, "oklch", lightness, chroma, hue);
160impl_to_css_3ch!(Hsl, "hsl", hue, saturation: "%", lightness: "%");
161impl_to_css_3ch!(Hwb, "hwb", hue, whiteness: "%", blackness: "%");
162
163impl_to_css_color_fn!(DisplayP3, "display-p3");
164impl_to_css_color_fn!(LinearRgb, "srgb-linear");
165impl_to_css_color_fn!(A98Rgb, "a98-rgb");
166impl_to_css_color_fn!(ProphotoRgb, "prophoto-rgb");
167impl_to_css_color_fn!(Rec2020, "rec2020");
168
169impl_to_css_xyz!(XyzD50, "xyz-d50");
170impl_to_css_xyz!(XyzD65, "xyz-d65");
171
172impl<'a> ToCss<'a> for chromashift::Color {
173	fn to_css(&self, arena: &'a Arena) -> Option<&'a str> {
174		match self {
175			chromashift::Color::Lab(c) => c.to_css(arena),
176			chromashift::Color::Lch(c) => c.to_css(arena),
177			chromashift::Color::Oklab(c) => c.to_css(arena),
178			chromashift::Color::Oklch(c) => c.to_css(arena),
179			chromashift::Color::Hsl(c) => c.to_css(arena),
180			chromashift::Color::Hwb(c) => c.to_css(arena),
181			chromashift::Color::DisplayP3(c) => c.to_css(arena),
182			chromashift::Color::LinearRgb(c) => c.to_css(arena),
183			chromashift::Color::A98Rgb(c) => c.to_css(arena),
184			chromashift::Color::ProphotoRgb(c) => c.to_css(arena),
185			chromashift::Color::Rec2020(c) => c.to_css(arena),
186			chromashift::Color::XyzD50(c) => c.to_css(arena),
187			chromashift::Color::XyzD65(c) => c.to_css(arena),
188			// sRGB types don't need native-space CSS — they use Shortest
189			chromashift::Color::Hex(_)
190			| chromashift::Color::Named(_)
191			| chromashift::Color::Srgb(_)
192			| chromashift::Color::Hsv(_) => None,
193		}
194	}
195}
196
197#[visitor]
198impl<'a, 'ctx, N> Visit for ReduceColors<'a, 'ctx, N>
199where
200	N: Visitable + NodeWithMetadata<CssMetadata>,
201{
202	fn consider_node(&self, node: VisitNode) -> VisitFlow {
203		if node.node_id.is_some() && Self::skips_subtree(&node.subtree_metadata()) {
204			return VisitFlow::SKIP_CHILDREN;
205		}
206		VisitFlow::DESCEND
207	}
208
209	fn visit_color(&mut self, color: &Color) {
210		// color-mix() is handled by visit_color_mix_function
211		if let Color::Function(colorfn) = color
212			&& matches!(**colorfn, ColorFunction::ColorMix(_))
213		{
214			return;
215		}
216		let Some(chroma_color) = color.to_chromashift() else {
217			return;
218		};
219		let arena = self.transformer.alloc();
220		let len = color.to_span().len() as usize;
221
222		if chroma_color.in_gamut_of(ColorSpace::Srgb)
223			&& let Some(candidate) = chroma_color.shortest(arena)
224			&& candidate.len() < len
225		{
226			self.transformer.replace_parsed::<Color>(color.to_span(), candidate);
227			return;
228		}
229
230		// Try the native-space rounded form. This preserves the original colour space
231		// while reducing precision to perceptually safe levels.
232		let rounded = chroma_color.round();
233		if let Some(css) = rounded.to_css(arena)
234			&& css.len() < len
235		{
236			self.transformer.replace_parsed::<Color>(color.to_span(), css);
237		}
238	}
239
240	fn visit_color_mix_function<'b>(&mut self, mix: &ColorMixFunction<'b>) -> VisitFlow {
241		let outer_span = mix.to_span();
242		let outer_len = outer_span.len() as usize;
243		let arena = self.transformer.alloc();
244
245		let n = mix.parts.len();
246		let default_pct = 100.0 / n as f32;
247
248		// A percentage that is a substitution/math function (var()/calc()/...) can't be
249		// resolved statically, so none of the numeric reductions below are safe. Bail if any
250		// explicit percentage is not a plain literal.
251		let literal_pct = |pct: &CalcableValue<'b, css_ast::Percentage>| match pct {
252			CalcableValue::Literal(p) => Some(p.value()),
253			_ => None,
254		};
255		if (&mix.parts).into_iter().any(|(p, _)| p.percentage.as_ref().is_some_and(|pct| literal_pct(pct).is_none())) {
256			return VisitFlow::DESCEND;
257		}
258
259		// Resolve effective percentages
260		let explicit_sum: f32 =
261			(&mix.parts).into_iter().filter_map(|(p, _)| p.percentage.as_ref().and_then(&literal_pct)).sum();
262		let implicit_count = (&mix.parts).into_iter().filter(|(p, _)| p.percentage.is_none()).count();
263		let implicit_share = if implicit_count > 0 { (100.0 - explicit_sum) / implicit_count as f32 } else { 0.0 };
264		let pcts: Vec<f32> = (&mix.parts)
265			.into_iter()
266			.map(|(p, _)| p.percentage.as_ref().and_then(&literal_pct).unwrap_or(implicit_share))
267			.collect();
268		let sum: f32 = pcts.iter().sum();
269
270		// If exactly one part has all the weight (sum == 100 and all others are 0), collapse to it.
271		if sum == 100.0
272			&& let Some(dominant) = pcts.iter().position(|&p| p == 100.0)
273		{
274			{
275				let (part, _) = &mix.parts[dominant];
276				let chroma = part.color.to_chromashift();
277				let str = chroma.and_then(|c| c.shortest(arena)).unwrap_or_else(|| {
278					let span = part.color.to_span();
279					&self.transformer.source_text[span.start().0 as usize..span.end().0 as usize]
280				});
281				self.transformer.clear_pending_edits(outer_span);
282				self.transformer.replace_parsed::<Color>(outer_span, str);
283				return VisitFlow::SKIP_CHILDREN;
284			}
285		}
286
287		// All identical colors with alpha_mult == 1 (sum >= 100): collapse to first.
288		if sum >= 100.0 {
289			let chromata: Vec<_> = (&mix.parts).into_iter().map(|(p, _)| p.color.to_chromashift()).collect();
290			if chromata.iter().all(Option::is_some) {
291				let first = chromata[0].unwrap();
292				if chromata[1..].iter().all(|c| c.unwrap().delta_e(first) < COLOR_EPSILON) {
293					let (part, _) = &mix.parts[0];
294					let str = first.shortest(arena).unwrap_or_else(|| {
295						let span = part.color.to_span();
296						&self.transformer.source_text[span.start().0 as usize..span.end().0 as usize]
297					});
298					self.transformer.clear_pending_edits(outer_span);
299					self.transformer.replace_parsed::<Color>(outer_span, str);
300					return VisitFlow::SKIP_CHILDREN;
301				}
302			}
303		}
304
305		// Try static mix to reduce colors:
306		let all_known = (&mix.parts).into_iter().all(|(p, _)| p.color.to_chromashift().is_some());
307		if all_known && let Some(mixed) = mix.to_chromashift() {
308			let alpha_mult = (sum as f64 / 100.0).min(1.0);
309			let mixed_alpha = (mixed.to_alpha() as f64 / 100.0 * alpha_mult * 100.0) as f32;
310			let mixed = mixed.with_alpha(mixed_alpha);
311			let rounded = mixed.round();
312			let native_css = rounded.to_css(arena);
313			let srgb_css = if mixed.in_gamut_of(ColorSpace::Srgb) { mixed.shortest(arena) } else { None };
314			let candidate =
315				native_css.into_iter().chain(srgb_css).min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
316			if let Some(candidate) = candidate
317				&& candidate.len() < outer_len
318			{
319				self.transformer.replace_parsed::<Color>(outer_span, candidate);
320				return VisitFlow::SKIP_CHILDREN;
321			}
322		}
323
324		// Remove redundant percentages on an equal N-way split (partial optimisation).
325		if (sum - 100.0).abs() < 0.01 {
326			for ((part, _), &effective) in (&mix.parts).into_iter().zip(&pcts) {
327				if let Some(ref pct) = part.percentage
328					&& (effective - default_pct).abs() < 0.01
329				{
330					self.transformer.delete(pct.to_span());
331				}
332			}
333		}
334
335		// Remove redundant "shorter hue" (shorter is the default hue interpolation direction)
336		if let Some(ref interp) = mix.interpolation
337			&& let InterpolationColorSpace::Polar(_, Some(ref hue_method)) = interp.color_space
338			&& matches!(hue_method.direction, HueInterpolationDirection::Shorter(_))
339		{
340			self.transformer.delete(hue_method.to_span());
341		}
342		VisitFlow::DESCEND
343	}
344}
345
346#[cfg(test)]
347mod tests {
348	use crate::test_helpers::{assert_no_transform, assert_transform};
349	use css_ast::{CssAtomSet, StyleSheet};
350
351	#[test]
352	fn reduces_full_length_hex() {
353		assert_transform!(
354			CssMinifierFeature::ReduceColors,
355			CssAtomSet,
356			StyleSheet,
357			"body { color: #ffffff; }",
358			"body { color: #fff; }"
359		);
360	}
361
362	#[test]
363	fn prefers_shorthand_hex_over_keyword() {
364		assert_transform!(
365			CssMinifierFeature::ReduceColors,
366			CssAtomSet,
367			StyleSheet,
368			"body { color: #000000; }",
369			"body { color: #000; }"
370		);
371	}
372
373	#[test]
374	fn prefers_named_over_rgb() {
375		assert_transform!(
376			CssMinifierFeature::ReduceColors,
377			CssAtomSet,
378			StyleSheet,
379			"body { color: rgb(210, 180, 140); }",
380			"body { color: tan; }"
381		);
382	}
383
384	#[test]
385	fn shortens_alpha_hex() {
386		assert_transform!(
387			CssMinifierFeature::ReduceColors,
388			CssAtomSet,
389			StyleSheet,
390			"body { color: rgba(255, 0, 0, 0.5); }",
391			"body { color: #ff000080; }"
392		);
393	}
394
395	#[test]
396	fn no_transform_when_already_short() {
397		assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: red; }");
398	}
399
400	#[test]
401	fn no_transform_for_currentcolor() {
402		assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: currentcolor; }");
403	}
404
405	#[test]
406	fn reduces_color_srgb_function() {
407		assert_transform!(
408			CssMinifierFeature::ReduceColors,
409			CssAtomSet,
410			StyleSheet,
411			"a { color: color(srgb 1 0 0); }",
412			"a { color: red; }"
413		);
414	}
415
416	#[test]
417	fn reduces_in_gamut_display_p3_to_shortest() {
418		assert_transform!(
419			CssMinifierFeature::ReduceColors,
420			CssAtomSet,
421			StyleSheet,
422			"a { color: color(display-p3 0.5 0.5 0.5); }",
423			"a { color: gray; }"
424		);
425	}
426
427	#[test]
428	fn no_transform_for_out_of_gamut_display_p3() {
429		assert_no_transform!(
430			CssMinifierFeature::ReduceColors,
431			CssAtomSet,
432			StyleSheet,
433			"a { color: color(display-p3 1 0 0); }"
434		);
435	}
436	#[test]
437
438	fn color_mix_100_percent_first() {
439		assert_transform!(
440			CssMinifierFeature::ReduceColors,
441			CssAtomSet,
442			StyleSheet,
443			"a { color: color-mix(in srgb, red 100%, blue); }",
444			"a { color: red; }"
445		);
446	}
447
448	#[test]
449	fn color_mix_0_percent_first() {
450		assert_transform!(
451			CssMinifierFeature::ReduceColors,
452			CssAtomSet,
453			StyleSheet,
454			"a { color: color-mix(in srgb, red 0%, blue); }",
455			"a { color: #00f; }"
456		);
457	}
458
459	#[test]
460	fn color_mix_all_zero_percent() {
461		// Per CSS Color 5 §3.3: sum of 0% → transparent black.
462		assert_transform!(
463			CssMinifierFeature::ReduceColors,
464			CssAtomSet,
465			StyleSheet,
466			"a { color: color-mix(in srgb, red 0%, green 0%, blue 0%); }",
467			"a { color: #0000; }"
468		);
469	}
470
471	#[test]
472	fn color_mix_same_color_both_sides() {
473		assert_transform!(
474			CssMinifierFeature::ReduceColors,
475			CssAtomSet,
476			StyleSheet,
477			"a { color: color-mix(in srgb, red, red); }",
478			"a { color: red; }"
479		);
480	}
481
482	#[test]
483	fn color_mix_removes_redundant_50_50() {
484		assert_transform!(
485			CssMinifierFeature::ReduceColors,
486			CssAtomSet,
487			StyleSheet,
488			"a { color: color-mix(in srgb, currentcolor 50%, red 50%); }",
489			"a { color: color-mix(in srgb, currentcolor, red); }"
490		);
491	}
492
493	#[test]
494	fn color_mix_removes_redundant_equal_n_way_split() {
495		assert_transform!(
496			CssMinifierFeature::ReduceColors,
497			CssAtomSet,
498			StyleSheet,
499			"a { color: color-mix(in srgb, currentcolor 33.33%, red 33.33%, blue 33.33%); }",
500			"a { color: color-mix(in srgb, currentcolor, red, blue); }"
501		);
502	}
503
504	#[test]
505	fn color_mix_removes_single_redundant_50() {
506		assert_transform!(
507			CssMinifierFeature::ReduceColors,
508			CssAtomSet,
509			StyleSheet,
510			"a { color: color-mix(in srgb, currentcolor 50%, red); }",
511			"a { color: color-mix(in srgb, currentcolor, red); }"
512		);
513	}
514
515	#[test]
516	fn color_mix_removes_shorter_hue() {
517		assert_transform!(
518			CssMinifierFeature::ReduceColors,
519			CssAtomSet,
520			StyleSheet,
521			"a { color: color-mix(in oklch shorter hue, currentcolor, red); }",
522			"a { color: color-mix(in oklch, currentcolor, red); }"
523		);
524	}
525
526	#[test]
527	fn color_mix_keeps_longer_hue() {
528		assert_no_transform!(
529			CssMinifierFeature::ReduceColors,
530			CssAtomSet,
531			StyleSheet,
532			"a { color: color-mix(in oklch longer hue,currentcolor,red); }"
533		);
534	}
535
536	#[test]
537	fn color_mix_no_transform_when_already_compact() {
538		assert_no_transform!(
539			CssMinifierFeature::ReduceColors,
540			CssAtomSet,
541			StyleSheet,
542			"a { color: color-mix(in oklch longer hue,currentcolor,red); }"
543		);
544	}
545
546	#[test]
547	fn color_mix_minifies_inner_colors() {
548		assert_transform!(
549			CssMinifierFeature::ReduceColors,
550			CssAtomSet,
551			StyleSheet,
552			"a { color: color-mix(in oklch, rgba(255, 255, 255, 1), currentcolor); }",
553			"a { color: color-mix(in oklch, #fff, currentcolor); }"
554		);
555	}
556
557	#[test]
558	fn color_mix_minifies_inner_rgb_to_named() {
559		assert_transform!(
560			CssMinifierFeature::ReduceColors,
561			CssAtomSet,
562			StyleSheet,
563			"a { color: color-mix(in srgb, hsl(0, 100%, 50%), currentcolor); }",
564			"a { color: color-mix(in srgb, red, currentcolor); }"
565		);
566	}
567
568	#[test]
569	fn color_mix_mixes_static_colors() {
570		assert_transform!(
571			CssMinifierFeature::ReduceColors,
572			CssAtomSet,
573			StyleSheet,
574			"a { color: color-mix(in srgb, red, blue); }",
575			"a { color: purple; }"
576		);
577	}
578
579	#[test]
580	fn color_mix_normalizes_percentages_over_100() {
581		// 80% + 40% = 120%, normalizes to 66.67%/33.33%, giving rgb(170, 0, 85) = #a05
582		assert_transform!(
583			CssMinifierFeature::ReduceColors,
584			CssAtomSet,
585			StyleSheet,
586			"a { color: color-mix(in srgb, red 80%, blue 40%); }",
587			"a { color: #a05; }"
588		);
589	}
590
591	#[test]
592	fn color_mix_alpha_multiplier_under_100() {
593		// 30% + 30% = 60%, alpha_mult = 0.6, result is semi-transparent purple
594		assert_transform!(
595			CssMinifierFeature::ReduceColors,
596			CssAtomSet,
597			StyleSheet,
598			"a { color: color-mix(in srgb, red 30%, blue 30%); }",
599			"a { color: #80008099; }"
600		);
601	}
602
603	#[test]
604	fn color_mix_no_100_shortcircuit_when_both_explicit() {
605		// red 100% + blue 50% sums to 150%, must normalize to 67/33 — not short-circuit to red
606		assert_transform!(
607			CssMinifierFeature::ReduceColors,
608			CssAtomSet,
609			StyleSheet,
610			"a { color: color-mix(in srgb, red 100%, blue 50%); }",
611			"a { color: #a05; }"
612		);
613	}
614
615	#[test]
616	fn color_mix_oklch_out_of_gamut_uses_native_space() {
617		// oklch mix of lime+blue is out of sRGB gamut — resolved to oklch(), not hex.
618		// Perceptual rounding: L/C at 3dp, hue at 1dp.
619		assert_transform!(
620			CssMinifierFeature::ReduceColors,
621			CssAtomSet,
622			StyleSheet,
623			"a { color: color-mix(in oklch, lime, blue); }",
624			"a { color: oklch(0.659 0.304 203.3); }"
625		);
626	}
627
628	#[test]
629	fn color_mix_none_channel_adopts_other() {
630		assert_transform!(
631			CssMinifierFeature::ReduceColors,
632			CssAtomSet,
633			StyleSheet,
634			"a { color: color-mix(in srgb, rgb(none 0 0) 50%, rgb(200 0 0) 50%); }",
635			"a { color: #c80000; }"
636		);
637	}
638
639	#[test]
640	fn rgb_none_channel_resolves_to_zero() {
641		assert_transform!(
642			CssMinifierFeature::ReduceColors,
643			CssAtomSet,
644			StyleSheet,
645			"a { color: rgb(none 128 0); }",
646			"a { color: green; }"
647		);
648	}
649
650	#[test]
651	fn relative_rgb_static_channels_minified() {
652		assert_transform!(
653			CssMinifierFeature::ReduceColors,
654			CssAtomSet,
655			StyleSheet,
656			"a { color: rgb(from red 200 g b); }",
657			"a { color: #c80000; }"
658		);
659	}
660
661	#[test]
662	fn relative_rgb_all_keywords_passthrough() {
663		assert_transform!(
664			CssMinifierFeature::ReduceColors,
665			CssAtomSet,
666			StyleSheet,
667			"a { color: rgb(from red r g b); }",
668			"a { color: red; }"
669		);
670	}
671
672	#[test]
673	fn relative_rgb_all_static_minified() {
674		assert_transform!(
675			CssMinifierFeature::ReduceColors,
676			CssAtomSet,
677			StyleSheet,
678			"a { color: rgb(from blue 255 255 0); }",
679			"a { color: #ff0; }"
680		);
681	}
682
683	#[test]
684	fn relative_hsl_keywords_passthrough() {
685		assert_transform!(
686			CssMinifierFeature::ReduceColors,
687			CssAtomSet,
688			StyleSheet,
689			"a { color: hsl(from green h s l); }",
690			"a { color: green; }"
691		);
692	}
693
694	#[test]
695	fn relative_oklch_static_produces_named() {
696		assert_transform!(
697			CssMinifierFeature::ReduceColors,
698			CssAtomSet,
699			StyleSheet,
700			"a { color: oklch(from red l c h); }",
701			"a { color: red; }"
702		);
703	}
704}