color/dynamic.rs
1// Copyright 2024 the Color Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CSS colors and syntax.
5
6use crate::{
7 cache_key::{BitEq, BitHash},
8 color::{add_alpha, fixup_hues_for_interpolate, split_alpha},
9 AlphaColor, Chromaticity, ColorSpace, ColorSpaceLayout, ColorSpaceTag, Flags, HueDirection,
10 LinearSrgb, Missing,
11};
12use core::hash::{Hash, Hasher};
13
14/// A color with a [color space tag] decided at runtime.
15///
16/// This type is roughly equivalent to [`AlphaColor`] except with a tag
17/// for color space as opposed being determined at compile time. It can
18/// also represent missing components, which are a feature of the CSS
19/// Color 4 spec.
20///
21/// Missing components are mostly useful for interpolation, and in that
22/// context take the value of the other color being interpolated. For
23/// example, interpolating a color in [Oklch] with `oklch(none 0 none)`
24/// fades the color saturation, ending in a gray with the same lightness.
25///
26/// In other contexts, missing colors are interpreted as a zero value.
27/// When manipulating components directly, setting them nonzero when the
28/// corresponding missing flag is set may yield unexpected results.
29///
30/// [color space tag]: ColorSpaceTag
31/// [Oklch]: crate::Oklch
32#[derive(Clone, Copy, Debug)]
33#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
34pub struct DynamicColor {
35 /// The color space.
36 pub cs: ColorSpaceTag,
37 /// The state of this color, tracking whether it has missing components and how it was
38 /// constructed. See the documentation of [`Flags`] for more information.
39 pub flags: Flags,
40 /// The components.
41 ///
42 /// The first three components are interpreted according to the
43 /// color space tag. The fourth component is alpha, interpreted
44 /// as separate alpha.
45 pub components: [f32; 4],
46}
47
48/// An intermediate struct used for interpolating between colors.
49///
50/// This is the return value of [`DynamicColor::interpolate`].
51#[derive(Clone, Copy)]
52#[expect(
53 missing_debug_implementations,
54 reason = "it's an intermediate struct, only used for eval"
55)]
56pub struct Interpolator {
57 premul1: [f32; 3],
58 alpha1: f32,
59 delta_premul: [f32; 3],
60 delta_alpha: f32,
61 cs: ColorSpaceTag,
62 missing: Missing,
63}
64
65impl DynamicColor {
66 /// Convert to `AlphaColor` with a static color space.
67 ///
68 /// Missing components are interpreted as 0.
69 #[must_use]
70 pub fn to_alpha_color<CS: ColorSpace>(self) -> AlphaColor<CS> {
71 if let Some(cs) = CS::TAG {
72 AlphaColor::new(self.convert(cs).components)
73 } else {
74 self.to_alpha_color::<LinearSrgb>().convert()
75 }
76 }
77
78 /// Convert from `AlphaColor`.
79 #[must_use]
80 pub fn from_alpha_color<CS: ColorSpace>(color: AlphaColor<CS>) -> Self {
81 if let Some(cs) = CS::TAG {
82 Self {
83 cs,
84 flags: Flags::default(),
85 components: color.components,
86 }
87 } else {
88 Self::from_alpha_color(color.convert::<LinearSrgb>())
89 }
90 }
91
92 /// The const-generic parameter `ABSOLUTE` indicates whether the conversion performs chromatic
93 /// adaptation. When `ABSOLUTE` is `true`, no chromatic adaptation is performed.
94 fn convert_impl<const ABSOLUTE: bool>(self, cs: ColorSpaceTag) -> Self {
95 if self.cs == cs {
96 // Note: §12 suggests that changing powerless to missing happens
97 // even when the color is already in the interpolation color space,
98 // but Chrome and color.js don't seem do to that.
99 self
100 } else {
101 let (opaque, alpha) = split_alpha(self.components);
102 let mut components = if ABSOLUTE {
103 add_alpha(self.cs.convert_absolute(cs, opaque), alpha)
104 } else {
105 add_alpha(self.cs.convert(cs, opaque), alpha)
106 };
107 // Reference: §12.2 of Color 4 spec
108 let missing = if !self.flags.missing().is_empty() {
109 if self.cs.same_analogous(cs) {
110 for (i, component) in components.iter_mut().enumerate() {
111 if self.flags.missing().contains(i) {
112 *component = 0.0;
113 }
114 }
115 self.flags.missing()
116 } else {
117 let mut missing = self.flags.missing() & Missing::single(3);
118 if self.cs.h_missing(self.flags.missing()) {
119 cs.set_h_missing(&mut missing, &mut components);
120 }
121 if self.cs.c_missing(self.flags.missing()) {
122 cs.set_c_missing(&mut missing, &mut components);
123 }
124 if self.cs.l_missing(self.flags.missing()) {
125 cs.set_l_missing(&mut missing, &mut components);
126 }
127 missing
128 }
129 } else {
130 Missing::default()
131 };
132 let mut result = Self {
133 cs,
134 flags: Flags::from_missing(missing),
135 components,
136 };
137 result.powerless_to_missing();
138 result
139 }
140 }
141
142 #[must_use]
143 /// Convert to a different color space.
144 pub fn convert(self, cs: ColorSpaceTag) -> Self {
145 self.convert_impl::<false>(cs)
146 }
147
148 #[must_use]
149 /// Convert to a different color space, without chromatic adaptation.
150 ///
151 /// For most use-cases you should consider using the chromatically-adapting
152 /// [`DynamicColor::convert`] instead. See the documentation on
153 /// [`ColorSpace::convert_absolute`] for more information.
154 pub fn convert_absolute(self, cs: ColorSpaceTag) -> Self {
155 self.convert_impl::<true>(cs)
156 }
157
158 #[must_use]
159 /// Chromatically adapt the color between the given white point chromaticities.
160 ///
161 /// The color is assumed to be under a reference white point of `from` and is chromatically
162 /// adapted to the given white point `to`. The linear Bradford transform is used to perform the
163 /// chromatic adaptation.
164 pub fn chromatically_adapt(self, from: Chromaticity, to: Chromaticity) -> Self {
165 if from == to {
166 return self;
167 }
168
169 // Treat missing components as zero, as per CSS Color Module Level 4 § 4.4.
170 let (opaque, alpha) = split_alpha(self.zero_missing_components().components);
171 let components = add_alpha(self.cs.chromatically_adapt(opaque, from, to), alpha);
172 Self {
173 cs: self.cs,
174 // After chromatically adapting the color, components may no longer be missing. Don't
175 // forward the flags.
176 flags: Flags::default(),
177 components,
178 }
179 }
180
181 /// Set any missing components to zero.
182 ///
183 /// We have a soft invariant that any bit set in the missing bitflag has
184 /// a corresponding component which is 0. This method restores that
185 /// invariant after manipulation which might invalidate it.
186 fn zero_missing_components(mut self) -> Self {
187 if !self.flags.missing().is_empty() {
188 for (i, component) in self.components.iter_mut().enumerate() {
189 if self.flags.missing().contains(i) {
190 *component = 0.0;
191 }
192 }
193 }
194 self
195 }
196
197 /// Multiply alpha by the given factor.
198 ///
199 /// If the alpha channel is missing, then the new alpha channel
200 /// will be ignored and the color returned unchanged.
201 #[must_use]
202 pub const fn multiply_alpha(self, rhs: f32) -> Self {
203 if self.flags.missing().contains(3) {
204 self
205 } else {
206 let (opaque, alpha) = split_alpha(self.components);
207 Self {
208 cs: self.cs,
209 flags: Flags::from_missing(self.flags.missing()),
210 components: add_alpha(opaque, alpha * rhs),
211 }
212 }
213 }
214
215 /// Set the alpha channel.
216 ///
217 /// This replaces the existing alpha channel. To scale or
218 /// or otherwise modify the existing alpha channel, use
219 /// [`DynamicColor::multiply_alpha`] or [`DynamicColor::map`].
220 ///
221 /// If the alpha channel is missing, then the new alpha channel
222 /// will be ignored and the color returned unchanged.
223 ///
224 /// ```
225 /// # use color::{parse_color, Srgb};
226 /// let c = parse_color("lavenderblush").unwrap().with_alpha(0.7);
227 /// assert_eq!(0.7, c.to_alpha_color::<Srgb>().split().1);
228 /// ```
229 #[must_use]
230 pub const fn with_alpha(self, alpha: f32) -> Self {
231 if self.flags.missing().contains(3) {
232 self
233 } else {
234 let (opaque, _alpha) = split_alpha(self.components);
235 Self {
236 cs: self.cs,
237 flags: Flags::from_missing(self.flags.missing()),
238 components: add_alpha(opaque, alpha),
239 }
240 }
241 }
242
243 /// Scale the chroma by the given amount.
244 ///
245 /// See [`ColorSpace::scale_chroma`] for more details.
246 #[must_use]
247 pub fn scale_chroma(self, scale: f32) -> Self {
248 let (opaque, alpha) = split_alpha(self.components);
249 let components = self.cs.scale_chroma(opaque, scale);
250
251 let mut flags = self.flags;
252 flags.discard_name();
253 Self {
254 cs: self.cs,
255 flags,
256 components: add_alpha(components, alpha),
257 }
258 .zero_missing_components()
259 }
260
261 /// Clip the color's components to fit within the natural gamut of the color space, and clamp
262 /// the color's alpha to be in the range `[0, 1]`.
263 ///
264 /// See [`ColorSpace::clip`] for more details.
265 #[must_use]
266 pub fn clip(self) -> Self {
267 let (opaque, alpha) = split_alpha(self.components);
268 let components = self.cs.clip(opaque);
269 let alpha = alpha.clamp(0., 1.);
270 Self {
271 cs: self.cs,
272 flags: self.flags,
273 components: add_alpha(components, alpha),
274 }
275 }
276
277 fn premultiply_split(self) -> ([f32; 3], f32) {
278 // Reference: §12.3 of Color 4 spec
279 let (opaque, alpha) = split_alpha(self.components);
280 let premul = if alpha == 1.0 || self.flags.missing().contains(3) {
281 opaque
282 } else {
283 self.cs.layout().scale(opaque, alpha)
284 };
285 (premul, alpha)
286 }
287
288 fn powerless_to_missing(&mut self) {
289 // Note: the spec seems vague on the details of what this should do,
290 // and there is some controversy in discussion threads. For example,
291 // in Lab-like spaces, if L is 0 do the other components become powerless?
292
293 // Note: we use hard-coded epsilons to check for approximate equality here, but these do
294 // not account for the normal value range of components. It might be somewhat more correct
295 // to, e.g., consider `0.000_01` approximately equal to `0` for a component with the
296 // natural range `0-100`, but not for a component with the natural range `0-0.5`.
297
298 match self.cs {
299 // See CSS Color Module level 4 § 7, § 9.3, and § 9.4 (HSL, LCH, Oklch).
300 ColorSpaceTag::Hsl | ColorSpaceTag::Lch | ColorSpaceTag::Oklch
301 if self.components[1] < 1e-6 =>
302 {
303 let mut missing = self.flags.missing();
304 self.cs.set_h_missing(&mut missing, &mut self.components);
305 self.flags.set_missing(missing);
306 }
307
308 // See CSS Color Module level 4 § 8 (HWB).
309 ColorSpaceTag::Hwb if self.components[1] + self.components[2] > 100. - 1e-4 => {
310 let mut missing = self.flags.missing();
311 self.cs.set_h_missing(&mut missing, &mut self.components);
312 self.flags.set_missing(missing);
313 }
314 _ => {}
315 }
316 }
317
318 /// Interpolate two colors.
319 ///
320 /// The colors are interpolated linearly from `self` to `other` in the color space given by
321 /// `cs`. When interpolating in a cylindrical color space, the hue can be interpolated in
322 /// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the
323 /// hue is interpolated.
324 ///
325 /// The interpolation proceeds according to [CSS Color Module Level 4 § 12][css-sec].
326 ///
327 /// This method does a bunch of precomputation, resulting in an [`Interpolator`] object that
328 /// can be evaluated at various `t` values.
329 ///
330 /// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation
331 ///
332 /// # Example
333 ///
334 /// ```rust
335 /// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Srgb};
336 ///
337 /// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
338 /// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
339 ///
340 /// let interp = start.interpolate(end, ColorSpaceTag::Hsl, HueDirection::Increasing);
341 /// let mid = interp.eval(0.5);
342 /// assert_eq!(mid.cs, ColorSpaceTag::Hsl);
343 /// assert!((mid.components[0] - 60.).abs() < 0.01);
344 /// ```
345 pub fn interpolate(
346 self,
347 other: Self,
348 cs: ColorSpaceTag,
349 direction: HueDirection,
350 ) -> Interpolator {
351 let mut a = self.convert(cs);
352 let mut b = other.convert(cs);
353 let a_missing = a.flags.missing();
354 let b_missing = b.flags.missing();
355 let missing = a_missing & b_missing;
356 if a_missing != b_missing {
357 for i in 0..4 {
358 if (a_missing & !b_missing).contains(i) {
359 a.components[i] = b.components[i];
360 } else if (!a_missing & b_missing).contains(i) {
361 b.components[i] = a.components[i];
362 }
363 }
364 }
365 let (premul1, alpha1) = a.premultiply_split();
366 let (mut premul2, alpha2) = b.premultiply_split();
367 fixup_hues_for_interpolate(premul1, &mut premul2, cs.layout(), direction);
368 let delta_premul = [
369 premul2[0] - premul1[0],
370 premul2[1] - premul1[1],
371 premul2[2] - premul1[2],
372 ];
373 Interpolator {
374 premul1,
375 alpha1,
376 delta_premul,
377 delta_alpha: alpha2 - alpha1,
378 cs,
379 missing,
380 }
381 }
382
383 /// Compute the relative luminance of the color.
384 ///
385 /// This can be useful for choosing contrasting colors, and follows the
386 /// [WCAG 2.1 spec].
387 ///
388 /// Note that this method only considers the opaque color, not the alpha.
389 /// Blending semi-transparent colors will reduce contrast, and that
390 /// should also be taken into account.
391 ///
392 /// [WCAG 2.1 spec]: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
393 #[must_use]
394 pub fn relative_luminance(self) -> f32 {
395 let [r, g, b, _] = self.convert(ColorSpaceTag::LinearSrgb).components;
396 0.2126 * r + 0.7152 * g + 0.0722 * b
397 }
398
399 /// Map components.
400 #[must_use]
401 pub fn map(self, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
402 let [x, y, z, a] = self.components;
403
404 let mut flags = self.flags;
405 flags.discard_name();
406 Self {
407 cs: self.cs,
408 flags,
409 components: f(x, y, z, a),
410 }
411 .zero_missing_components()
412 }
413
414 /// Map components in a given color space.
415 #[must_use]
416 pub fn map_in(self, cs: ColorSpaceTag, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
417 self.convert(cs).map(f).convert(self.cs)
418 }
419
420 /// Map the lightness of the color.
421 ///
422 /// In a color space that naturally has a lightness component, map that value.
423 /// Otherwise, do the mapping in [Oklab]. The lightness range is normalized so
424 /// that 1.0 is white. That is the normal range for Oklab but differs from the
425 /// range in [Lab], [Lch], and [Hsl].
426 ///
427 /// [Oklab]: crate::Oklab
428 /// [Lab]: crate::Lab
429 /// [Lch]: crate::Lch
430 /// [Hsl]: crate::Hsl
431 #[must_use]
432 pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
433 match self.cs {
434 ColorSpaceTag::Lab | ColorSpaceTag::Lch => {
435 self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a])
436 }
437 ColorSpaceTag::Oklab | ColorSpaceTag::Oklch => {
438 self.map(|l, c1, c2, a| [f(l), c1, c2, a])
439 }
440 ColorSpaceTag::Hsl => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]),
441 _ => self.map_in(ColorSpaceTag::Oklab, |l, a, b, alpha| [f(l), a, b, alpha]),
442 }
443 }
444
445 /// Map the hue of the color.
446 ///
447 /// In a color space that naturally has a hue component, map that value.
448 /// Otherwise, do the mapping in [Oklch]. The hue is in degrees.
449 ///
450 /// [Oklch]: crate::Oklch
451 #[must_use]
452 pub fn map_hue(self, f: impl Fn(f32) -> f32) -> Self {
453 match self.cs.layout() {
454 ColorSpaceLayout::HueFirst => self.map(|h, c1, c2, a| [f(h), c1, c2, a]),
455 ColorSpaceLayout::HueThird => self.map(|c0, c1, h, a| [c0, c1, f(h), a]),
456 _ => self.map_in(ColorSpaceTag::Oklch, |l, c, h, a| [l, c, f(h), a]),
457 }
458 }
459}
460
461impl PartialEq for DynamicColor {
462 /// Equality is not perceptual, but requires the component values to be equal.
463 ///
464 /// See also [`CacheKey`](crate::cache_key::CacheKey).
465 fn eq(&self, other: &Self) -> bool {
466 // Same as the derive implementation, but we want a doc comment.
467 self.cs == other.cs && self.flags == other.flags && self.components == other.components
468 }
469}
470
471impl BitEq for DynamicColor {
472 fn bit_eq(&self, other: &Self) -> bool {
473 self.cs == other.cs
474 && self.flags == other.flags
475 && self.components.bit_eq(&other.components)
476 }
477}
478
479impl BitHash for DynamicColor {
480 fn bit_hash<H: Hasher>(&self, state: &mut H) {
481 self.cs.hash(state);
482 self.flags.hash(state);
483 self.components.bit_hash(state);
484 }
485}
486
487/// Note that the conversion is only lossless for color spaces that have a corresponding [tag](ColorSpaceTag).
488/// This is why we have this additional trait bound. See also
489/// <https://github.com/linebender/color/pull/155> for more discussion.
490impl<CS: ColorSpace> From<AlphaColor<CS>> for DynamicColor
491where
492 ColorSpaceTag: From<CS>,
493{
494 fn from(value: AlphaColor<CS>) -> Self {
495 const {
496 assert!(
497 CS::TAG.is_some(),
498 "this trait can only be implemented for colors with a tag"
499 );
500 }
501
502 Self::from_alpha_color(value)
503 }
504}
505
506impl Interpolator {
507 /// Evaluate the color ramp at the given point.
508 ///
509 /// Typically `t` ranges between 0 and 1, but that is not enforced,
510 /// so extrapolation is also possible.
511 pub fn eval(&self, t: f32) -> DynamicColor {
512 let premul = [
513 self.premul1[0] + t * self.delta_premul[0],
514 self.premul1[1] + t * self.delta_premul[1],
515 self.premul1[2] + t * self.delta_premul[2],
516 ];
517 let alpha = self.alpha1 + t * self.delta_alpha;
518 let opaque = if alpha == 0.0 || alpha == 1.0 {
519 premul
520 } else {
521 self.cs.layout().scale(premul, 1.0 / alpha)
522 };
523 let components = add_alpha(opaque, alpha);
524 DynamicColor {
525 cs: self.cs,
526 flags: Flags::from_missing(self.missing),
527 components,
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use crate::{parse_color, ColorSpaceTag, DynamicColor, Missing};
535
536 // `DynamicColor` was carefully packed. Ensure its size doesn't accidentally change.
537 const _: () = if size_of::<DynamicColor>() != 20 {
538 panic!("`DynamicColor` size changed");
539 };
540
541 #[test]
542 fn missing_alpha() {
543 let c = parse_color("oklab(0.5 0.2 0 / none)").unwrap();
544 assert_eq!(0., c.components[3]);
545 assert_eq!(Missing::single(3), c.flags.missing());
546
547 // Alpha is missing, so we shouldn't be able to get an alpha added.
548 let c2 = c.with_alpha(0.5);
549 assert_eq!(0., c2.components[3]);
550 assert_eq!(Missing::single(3), c2.flags.missing());
551
552 let c3 = c.multiply_alpha(0.2);
553 assert_eq!(0., c3.components[3]);
554 assert_eq!(Missing::single(3), c3.flags.missing());
555 }
556
557 #[test]
558 fn preserves_rgb_missingness() {
559 let c = parse_color("color(srgb 0.5 none 0)").unwrap();
560 assert_eq!(
561 c.convert(ColorSpaceTag::XyzD65).flags.missing(),
562 Missing::single(1)
563 );
564 }
565
566 #[test]
567 fn drops_missingness_when_not_analogous() {
568 let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
569 assert!(c.convert(ColorSpaceTag::Srgb).flags.missing().is_empty());
570 }
571
572 #[test]
573 fn preserves_hue_missingness() {
574 let c = parse_color("oklch(0.2 0.3 none)").unwrap();
575 assert_eq!(
576 c.convert(ColorSpaceTag::Hsl).flags.missing(),
577 Missing::single(0)
578 );
579 }
580
581 #[test]
582 fn preserves_lightness_missingness() {
583 let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
584 assert_eq!(
585 c.convert(ColorSpaceTag::Hsl).flags.missing(),
586 Missing::single(2)
587 );
588 }
589
590 #[test]
591 fn preserves_saturation_missingness() {
592 let c = parse_color("oklch(0.2 none 240)").unwrap();
593 assert_eq!(c.flags.missing(), Missing::single(1));
594
595 // As saturation is missing, it is effectively 0, meaning the color is achromatic and hue
596 // is powerless. § 4.4.1 says hue must be set missing after conversion.
597 assert_eq!(
598 c.convert(ColorSpaceTag::Hsl).flags.missing(),
599 Missing::single(0) | Missing::single(1)
600 );
601 }
602
603 #[test]
604 fn achromatic_sets_hue_powerless() {
605 let c = parse_color("oklab(0.2 0 0)").unwrap();
606
607 // As the color is achromatic, the hue is powerless. § 4.4.1 says hue must be set missing
608 // after conversion.
609 assert_eq!(
610 c.convert(ColorSpaceTag::Hsl).flags.missing(),
611 Missing::single(0)
612 );
613 }
614
615 #[test]
616 fn powerless_components() {
617 static COLORS_AND_POWERLESS: &[(&str, &[usize])] = &[
618 // Grayscale HWB results in powerless hue...
619 ("hwb(240 80 20)", &[0]),
620 ("hwb(240 79.9999999 19.9999999)", &[0]),
621 // ... also if the grayscale is specified out of gamut...
622 ("hwb(240 120 200)", &[0]),
623 // ... but near-grayscale HWB does not result in powerless hue...
624 ("hwb(240 79.99 20)", &[]),
625 // ... and colorful colors don't either.
626 ("hwb(240 20 15)", &[]),
627 // Unsaturated hue-saturation-lightness-like colors result in powerless hue...
628 ("hsl(240 0 50)", &[0]),
629 ("hsl(240 0.0000001 50)", &[0]),
630 // ... also if the saturation is negative...
631 ("hsl(240 -0.2 50)", &[0]),
632 // ... but near-unsaturated hue-saturation-lightness-like colors do not result
633 // in powerless hue...
634 ("hsl(240 0.01 50)", &[]),
635 // ... and colorful colors don't either.
636 ("hsl(240 0.6 50)", &[]),
637 // In lab-like spaces, zero lightness does not (currently) result in powerless
638 // components.
639 ("lab(0 0.4 -0.3)", &[]),
640 ("oklab(0 0.4 -0.3)", &[]),
641 // sRGB (and in other rectangular spaces) never have powerless components.
642 ("color(srgb 0 0 0)", &[]),
643 ("color(srgb 1 1 1)", &[]),
644 ("color(srgb 500 -200 20)", &[]),
645 ];
646
647 for (color, powerless) in COLORS_AND_POWERLESS {
648 let mut c = parse_color(color).unwrap();
649 c.powerless_to_missing();
650 for idx in *powerless {
651 assert!(
652 c.flags.missing().contains(*idx),
653 "Expected color `{color}` to have the following powerless components: {powerless:?}"
654 );
655 }
656 }
657 }
658}