Repository: facelessuser/coloraide (language: python)

Issue:
CAM16/HCT conversion issue
So, I've realized a couple of things after running with CAM16 and HCT for a bit. We need to make some adjustments.
When working through CAM16 and the derived HCT, I had not realized how important the hue actually is with achromatic colors.

With most of the LCh-like color spaces, when converting to an achromatic color, the actual hue value doesn't matter that much simply because chroma is so small. When chroma is small, like very small, the impact hue has in the calculation is so minimal, you can get away with ignoring it. But with CAM16 (and HCT which is derived from it) the chroma at times can be quite large. In the SDR region, it can get up to 2.x, maybe bigger. This value is significant enough that the hue actually has some impact.

We calculated the achromatic response in CAM16 and HCT but that response exists mainly in the 209.5x hue range. In other hue ranges, the colors are not quite achromatic, and the colors below that line, aren't necessarily achromatic as well.

For instance, consider this color in HCT, we cannot reproduce it. 

```py
>>> hct = Color('#80807f').convert('hct')
>>> hct
color(--hct none 1.805 53.557 / 1)
>>> hct.convert('srgb').to_string(hex=True)
#808080
```

So, what does this mean? It means we need to restrict the achromatic response to the given colors with a hue of `209.5x`, give or take. It means that we need to restrict the achromatic `none` replacement as close to that response line as possible, and we should not assume colors below that response line are achromatic.

This has slightly bigger implications. Right now, we replace `none` with zero in almost every color space, but moving forward, that will not be the case. CAM16 JMh and HCT will both need to replace `none` with something close to the actual hue needed. I already have this roughly working locally:

```python
>>> hct = Color('#80807f').convert('hct')
>>> hct
color(--hct 181.65 1.805 53.557 / 1)
>>> hct.convert('srgb').to_string(hex=True)
'#80807f'
```

And then when we use an actual achromatic color:

```python
>>> hct = Color('#808080').convert('hct')
>>> hct
color(--hct none 1.8977 53.585 / 1)
>>> hct.to_string()
'color(--hct 209.54 1.8977 53.585)'
>>> hct.to_string(hex=True)
'color(--hct 209.54 1.8977 53.585)'
```


ACEScct gamut handling and NaN
ACEScct is built on the AP1 primaries of ACES and can encode the entire gamut of ACES 2065-1:

```py
>>> Color('aces2065-1', [0, 0, 0]).convert('acescct')
color(--acescct 0.07291 0.07291 0.07291 / 1)
>>> Color('aces2065-1', [65504, 65504, 65504]).convert('acescct')
color(--acescct 1.468 1.468 1.468 / 1)
```
Now, we say that the gamut of ACES 2065-1 is [0, 65504], but the spec says its range is [-65504, 65504], but then says you should avoid clamping values above 1. This is to handle HDR colors and the headroom is enormous. Is the lower end of its gamut really 0 or is it -65504? Regardless, ACES 2065-1 seems like more of a storage space for colors than one that people actively work in.

Here's the problem with ACEScct. It is a logarithmic encoding used often for color grading as film often has a logarithmic scale in relation to light. As can be noted above, the ACES value of `[0, 0, 0]` (which is the same as `black`) equates to `color(--acescct 0.07291 0.07291 0.07291 / 1)` in ACEScct. This is potentially a problem when dealing with NaN values. Particularly how we handle them when we need to process the value. We always process the value by setting it zero when we absolutely have to, but it would appear that this is out of the lower gamut bound of ACEScct. It is not ideal to resolve a NaN with an out of bound value. It would seem a better approach would be to replace it with the lowest in gamut value. But we'd have to do this in a lot of places and would need the infrastructure to do so.

Now, the question is whether this is really technically out of gamut. Is ACEScct even a gamut bound space? It's meant to hold ACES, but then they also say:

> ACEScct uses values above 1.0 and below 0.0 to encode the entire range of ACES values. ACEScct values should not be clamped except as part of color correction needed to produce a desired artistic intent.

So, is this color space not gamut bound? Or is the gamut larger than we think?

I can see a couple resolutions:

- NaN replacement has the infrastructure to replace a given channel with zero or the minimum value of the channel, whichever is greater. Not my first choice as it would add a bit of overhead throughout.
- We actually don't really understand the intent of the gamut or if the encoding even has such limits. This real limit could just be a storage type limit. In that case, we relax the gamut to an appropriate limit or specify the space as unbounded. This may o or may not also apply to ACEScc and ACEScg.
- Accept that NaN translates to zero and spaces that do not conform well are accepted outliers.
- ...something else I haven't thought of yet.
ACEScct gamut handling and NaN
ACEScct is built on the AP1 primaries of ACES and can encode the entire gamut of ACES 2065-1:

```py
>>> Color('aces2065-1', [0, 0, 0]).convert('acescct')
color(--acescct 0.07291 0.07291 0.07291 / 1)
>>> Color('aces2065-1', [65504, 65504, 65504]).convert('acescct')
color(--acescct 1.468 1.468 1.468 / 1)
```
Now, we say that the gamut of ACES 2065-1 is [0, 65504], but the spec says its range is [-65504, 65504], but then says you should avoid clamping values above 1. This is to handle HDR colors and the headroom is enormous. Is the lower end of its gamut really 0 or is it -65504? Regardless, ACES 2065-1 seems like more of a storage space for colors than one that people actively work in.

Here's the problem with ACEScct. It is a logarithmic encoding used often for color grading as film often has a logarithmic scale in relation to light. As can be noted above, the ACES value of `[0, 0, 0]` (which is the same as `black`) equates to `color(--acescct 0.07291 0.07291 0.07291 / 1)` in ACEScct. This is potentially a problem when dealing with NaN values. Particularly how we handle them when we need to process the value. We always process the value by setting it zero when we absolutely have to, but it would appear that this is out of the lower gamut bound of ACEScct. It is not ideal to resolve a NaN with an out of bound value. It would seem a better approach would be to replace it with the lowest in gamut value. But we'd have to do this in a lot of places and would need the infrastructure to do so.

Now, the question is whether this is really technically out of gamut. Is ACEScct even a gamut bound space? It's meant to hold ACES, but then they also say:

> ACEScct uses values above 1.0 and below 0.0 to encode the entire range of ACES values. ACEScct values should not be clamped except as part of color correction needed to produce a desired artistic intent.

So, is this color space not gamut bound? Or is the gamut larger than we think?

I can see a couple resolutions:

- NaN replacement has the infrastructure to replace a given channel with zero or the minimum value of the channel, whichever is greater. Not my first choice as it would add a bit of overhead throughout.
- We actually don't really understand the intent of the gamut or if the encoding even has such limits. This real limit could just be a storage type limit. In that case, we relax the gamut to an appropriate limit or specify the space as unbounded. This may o or may not also apply to ACEScc and ACEScg.
- Accept that NaN translates to zero and spaces that do not conform well are accepted outliers.
- ...something else I haven't thought of yet.


Discussion / hints:
We mapped everything out in relation to lightness, chroma, and hue. The hue goes kind of wild when you get really close to black, so we don't bother to plot that. We map a fairly accurate response from about J = 0.5 to well into the HDR range. You can see the red line (our calculated response) overlaid on the black line (the actual response). Under J = 0.5, we just extrapolate the hue out as it matters far less when chroma and lightness are so small.

![achroma-response](https://user-images.githubusercontent.com/1055125/224859053-9d592a6a-b6eb-4c5d-a8ea-1b6791703910.png)

With an experimental patch in place, if we graph a slice of the CAM16 JMh color space, we can see the differences before and after. It looks much better and solves the odd issue. Unfortunately, you can't just say `Color('cam16-jmh', [40, 0, 0])` when you want gray as the color space just doesn't work that way when discounting is enabled.

Before:
![jmh-old](https://user-images.githubusercontent.com/1055125/224858338-9b7bc81b-8570-4ff3-a11c-0bb3493dcbec.png)

New:
![jmh-new](https://user-images.githubusercontent.com/1055125/224858354-c1b4e005-2930-42a6-b338-ede3356877dd.png)

None of this is really necessary if discounting is enabled, but since it is not, we must.



Fix patch:
diff --git a/.pyspelling.yml b/.pyspelling.yml
index 4cb51db92..708b849e9 100644
--- a/.pyspelling.yml
+++ b/.pyspelling.yml
@@ -22,6 +22,12 @@ matrix:
       - '.md-social__link'
       - '.swatch[title]'
       - '.playground .gamut'
+  - pyspelling.filters.context:
+      context_visible_first: true
+      delimiters:
+      # Ignore possessive endings
+      - open: '(?<=\w)''s(?!\w)'
+        close: '\b'
   - pyspelling.filters.url:
 
 - name: markdown
diff --git a/coloraide/__meta__.py b/coloraide/__meta__.py
index 1fc93d81c..b550ddab3 100644
--- a/coloraide/__meta__.py
+++ b/coloraide/__meta__.py
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(1, 8, 2, "final")
+__version_info__ = Version(2, 0, 0, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/coloraide/algebra.py b/coloraide/algebra.py
index 29427ce90..ea3bc6ee8 100644
--- a/coloraide/algebra.py
+++ b/coloraide/algebra.py
@@ -165,6 +165,22 @@ def nlog(value: float) -> float:
     return math.copysign(1, value) * math.log(abs(value))
 
 
+def rect_to_polar(a: float, b: float) -> tuple[float, float]:
+    """Take rectangular coordinates and make them polar."""
+
+    c = math.sqrt(a ** 2 + b ** 2)
+    h = math.degrees(math.atan2(b, a)) % 360
+    return c, h
+
+
+def polar_to_rect(c: float, h: float) -> tuple[float, float]:
+    """Take rectangular coordinates and make them polar."""
+
+    a = c * math.cos(math.radians(h))
+    b = c * math.sin(math.radians(h))
+    return a, b
+
+
 ################################
 # Interpolation and splines
 ################################
diff --git a/coloraide/channels.py b/coloraide/channels.py
index 04c71c4de..d21929ae2 100644
--- a/coloraide/channels.py
+++ b/coloraide/channels.py
@@ -17,6 +17,7 @@ class Channel(str):
     bound: bool
     flags: int
     limit: tuple[float | None, float | None]
+    nans: float
 
     def __new__(
         cls,
@@ -26,7 +27,8 @@ def __new__(
         mirror_range: bool = False,
         bound: bool = False,
         flags: int = 0,
-        limit: tuple[float | None, float | None] = (None, None)
+        limit: tuple[float | None, float | None] = (None, None),
+        nans: float = 0.0
     ) -> 'Channel':
         """Initialize."""
 
@@ -39,5 +41,6 @@ def __new__(
         obj.bound = bound
         obj.flags = flags
         obj.limit = limit
+        obj.nans = nans
 
         return obj
diff --git a/coloraide/color.py b/coloraide/color.py
index aefa18b43..53b04f4e5 100644
--- a/coloraide/color.py
+++ b/coloraide/color.py
@@ -3,6 +3,7 @@
 import abc
 import functools
 import random
+import math
 from . import distance
 from . import convert
 from . import gamut
@@ -451,23 +452,30 @@ def random(cls, space: str, *, limits: Sequence[Sequence[float] | None] | None =
             coords.append(random.uniform(a, b))
 
         # Create the color
-        return cls(space, coords)
+        obj = cls(space, coords)
+        if hasattr(obj._space, 'hue_index'):
+            obj.normalize()
+        return obj
 
-    def to_dict(self) -> Mapping[str, Any]:
+    def to_dict(self, *, nans: bool = True) -> Mapping[str, Any]:
         """Return color as a data object."""
 
-        return {'space': self.space(), 'coords': self[:-1], 'alpha': self[-1]}
+        return {'space': self.space(), 'coords': self.coords(nans=nans), 'alpha': self.alpha(nans=nans)}
 
-    def normalize(self) -> Color:
+    def normalize(self, *, nans: bool = True) -> Color:
         """Normalize the color."""
 
-        self[:] = self._space.normalize(self[:])
+        self[:-1] = self.coords(nans=False)
+        if nans and hasattr(self._space, 'hue_index') and self.is_achromatic():
+            i = self._space.hue_index()
+            self[i] = alg.NaN
+        self[-1] = alg.no_nan(self[-1])
         return self
 
-    def is_nan(self, name: str) -> bool:
+    def is_nan(self, name: str) -> bool:  # pragma: no cover
         """Check if channel is NaN."""
 
-        return alg.is_nan(self.get(name))
+        return math.isnan(self.get(name))
 
     def _handle_color_input(self, color: ColorInput) -> Color:
         """Handle color input."""
@@ -500,26 +508,47 @@ def clone(self) -> Color:
 
         return self.new(self.space(), self[:-1], self[-1])
 
-    def convert(self, space: str, *, fit: bool | str = False, in_place: bool = False) -> Color:
+    def convert(
+        self,
+        space: str,
+        *, fit: bool | str = False,
+        in_place: bool = False,
+        norm: bool = True
+    ) -> Color:
         """Convert to color space."""
 
+        # Convert the color and then fit it.
         if fit:
             method = None if not isinstance(fit, str) else fit
             if not self.in_gamut(space, tolerance=0.0):
-                converted = self.convert(space, in_place=in_place)
+                converted = self.convert(space, in_place=in_place, norm=norm)
                 return converted.fit(space, method=method)
 
+        # Nothing to do, just return the color with no alterations.
         if space == self.space():
             return self if in_place else self.clone()
 
+        # Actually convert the color
         c, coords = convert.convert(self, space)
-        coords.append(self[-1])
         this = self if in_place else self.clone()
         this._space = c
-        this._coords = coords
+        this._coords[:-1] = coords
+
+        # Normalize achromatic colors, but skip if we internally don't need this.
+        if norm and hasattr(this._space, 'hue_index') and this.is_achromatic():
+            this[this._space.hue_index()] = alg.NaN
 
         return this
 
+    def is_achromatic(self) -> bool:
+        """Test if color is achromatic."""
+
+        value = self._space.is_achromatic(self.coords(nans=False))
+        if value is None:
+            xyz = self.convert('xyz-d65')
+            return bool(xyz._space.is_achromatic(xyz[:-1]))
+        return value
+
     def mutate(
         self,
         color: ColorInput,
@@ -537,6 +566,8 @@ def update(
         color: ColorInput,
         data: VectorLike | None = None,
         alpha: float = util.DEF_ALPHA,
+        *,
+        norm: bool = True,
         **kwargs: Any
     ) -> Color:
         """Update the existing color space with the provided color."""
@@ -544,7 +575,17 @@ def update(
         space = self.space()
         self._space, self._coords = self._parse(color, data=data, alpha=alpha, **kwargs)
         if self._space.NAME != space:
-            self.convert(space, in_place=True)
+            self.convert(space, in_place=True, norm=norm)
+        return self
+
+    def _hotswap(self, color: Color) -> Color:
+        """
+        Hot swap a color object.
+
+        We expect it to be a color object, no special parsing, we just want to go fast.
+        """
+
+        self._space, self._coords = self.CS_MAP[color.space()], color[:]
         return self
 
     def to_string(self, **kwargs: Any) -> str:
@@ -586,7 +627,7 @@ def xy(self) -> Vector:
         coords = self.chromatic_adaptation(
             xyz._space.WHITE,
             self._space.WHITE,
-            xyz[:-1]
+            xyz.coords(nans=False)
         )
         return util.xyz_to_xyY(coords, self._space.white())[:2]
 
@@ -615,7 +656,7 @@ def clip(self, space: str | None = None) -> Color:
             space = self.space()
 
         # Convert to desired space
-        c = self.convert(space, in_place=True)
+        c = self.convert(space, in_place=True, norm=False)
         gamut.clip_channels(c)
 
         # Adjust "this" color
@@ -648,10 +689,15 @@ def fit(
             raise ValueError("'{}' gamut mapping is not currently supported".format(method))
 
         # Convert to desired space
-        self.convert(space, in_place=True)
+        self.convert(space, in_place=True, norm=False)
+
+        # If within gamut, just normalize hue range by calling clip.
+        if self.in_gamut(tolerance=0):
+            gamut.clip_channels(self)
 
-        # Call the appropriate gamut mapping algorithm
-        mapping.fit(self, **kwargs)
+        # Perform gamut mapping.
+        else:
+            mapping.fit(self, **kwargs)
 
         # Convert back to the original color space
         return self.convert(orig_space, in_place=True)
@@ -663,12 +709,15 @@ def in_gamut(self, space: str | None = None, *, tolerance: float = util.DEF_FIT_
             space = self.space()
 
         # Check if gamut is in the provided space
-        c = self.convert(space) if space is not None and space != self.space() else self
+        c = self.convert(space, norm=False) if space is not None and space != self.space() else self
 
         # Check the color space specified for gamut checking.
         # If it proves to be in gamut, we will then test if the current
         # space is constrained properly.
-        if c._space.GAMUT_CHECK is not None and not c.convert(c._space.GAMUT_CHECK).in_gamut(tolerance=tolerance):
+        if (
+            c._space.GAMUT_CHECK is not None and
+            not c.convert(c._space.GAMUT_CHECK, norm=False).in_gamut(tolerance=tolerance)
+        ):
             return False
 
         return gamut.verify(c, tolerance)
@@ -708,7 +757,7 @@ def mix(
         if i.domain:
             i.domain = interpolate.normalize_domain(i.domain)
         mixed = i(percent)
-        return self.mutate(mixed) if in_place else mixed
+        return self._hotswap(mixed) if in_place else mixed
 
     @classmethod
     def steps(
@@ -776,22 +825,30 @@ def filter(  # noqa: A003
         amount: float | None = None,
         *,
         space: str | None = None,
+        out_space: str | None = None,
         in_place: bool = False,
         **kwargs: Any
     ) -> Color:
         """Filter."""
 
-        return filters.filters(self, name, amount, space, in_place, **kwargs)
+        return filters.filters(self, name, amount, space, out_space, in_place, **kwargs)
 
     def harmony(
         self,
         name: str,
         *,
-        space: str | None = None
+        space: str | None = None,
+        out_space: str | None = None
     ) -> list[Color]:
         """Acquire the specified color harmonies."""
 
-        return harmonies.harmonize(self, name, space)
+        if space is None:
+            space = self.HARMONY
+
+        if out_space is None:
+            out_space = space
+
+        return [c.convert(out_space, in_place=True) for c in harmonies.harmonize(self, name, space)]
 
     def compose(
         self,
@@ -810,13 +867,8 @@ def compose(
         else:
             bcolor = [self._handle_color_input(backdrop)]
 
-        color = compositing.compose(self, bcolor, blend, operator, space)
-
-        if out_space is None:
-            out_space = self.space()
-
-        color.convert(out_space, in_place=True)
-        return self.mutate(color) if in_place else color
+        color = compositing.compose(self, bcolor, blend, operator, space, out_space)
+        return self._hotswap(color) if in_place else color
 
     def delta_e(
         self,
@@ -855,7 +907,7 @@ def closest(
     def luminance(self) -> float:
         """Get color's luminance."""
 
-        return self.convert("xyz-d65")['y']
+        return self.convert("xyz-d65").get('y', nans=False)
 
     def contrast(self, color: ColorInput, method: str | None = None) -> float:
         """Compare the contrast ratio of this color and the provided color."""
@@ -864,14 +916,14 @@ def contrast(self, color: ColorInput, method: str | None = None) -> float:
         return contrast.contrast(method, self, color)
 
     @overload
-    def get(self, name: str) -> float:  # noqa: D105
+    def get(self, name: str, *, nans: bool = True) -> float:  # noqa: D105
         ...
 
     @overload
-    def get(self, name: list[str] | tuple[str, ...]) -> list[float]:  # noqa: D105
+    def get(self, name: list[str] | tuple[str, ...], *, nans: bool = True) -> list[float]:  # noqa: D105
         ...
 
-    def get(self, name: str | list[str] | tuple[str, ...]) -> float | list[float]:
+    def get(self, name: str | list[str] | tuple[str, ...], *, nans: bool = True) -> float | list[float]:
         """Get channel."""
 
         # Handle single channel
@@ -879,8 +931,17 @@ def get(self, name: str | list[str] | tuple[str, ...]) -> float | list[float]:
             # Handle space.channel
             if '.' in name:
                 space, channel = name.split('.', 1)
-                return self.convert(space)[channel]
-            return self[name]
+                if nans:
+                    return self.convert(space)[channel]
+                else:
+                    obj = self.convert(space, norm=nans)
+                    i = obj._space.get_channel_index(channel)
+                    return obj._space.resolve_channel(i, obj._coords)
+            elif nans:
+                return self[name]
+            else:
+                i = self._space.get_channel_index(name)
+                return self._space.resolve_channel(i, self._coords)
 
         # Handle list of channels
         else:
@@ -892,15 +953,21 @@ def get(self, name: str | list[str] | tuple[str, ...]) -> float | list[float]:
                 # Handle space.channel
                 space, channel = n.split('.', 1) if '.' in n else (original_space, n)
                 if space != current_space:
-                    obj = self if space == original_space else self.convert(space)
+                    obj = self if space == original_space else self.convert(space, norm=nans)
                     current_space = space
-                values.append(obj[channel])
+                if nans:
+                    values.append(obj[channel])
+                else:
+                    i = obj._space.get_channel_index(channel)
+                    values.append(obj._space.resolve_channel(i, obj._coords))
             return values
 
     def set(  # noqa: A003
         self,
         name: str | dict[str, float | Callable[..., float]],
-        value: float | Callable[..., float] | None = None
+        value: float | Callable[..., float] | None = None,
+        *,
+        nans: bool = True
     ) -> Color:
         """Set channel."""
 
@@ -915,13 +982,16 @@ def set(  # noqa: A003
             obj = self.clone()
 
             for k, v in name.items():
-
                 # Handle space.channel
                 space, channel = k.split('.', 1) if '.' in k else (original_space, k)
                 if space != current_space:
-                    obj.convert(space, in_place=True)
+                    obj.convert(space, in_place=True, norm=nans)
                     current_space = space
-                obj[channel] = v(obj[channel]) if callable(v) else v
+                if not callable(v):
+                    obj[channel] = v
+                else:
+                    i = obj._space.get_channel_index(channel)
+                    obj[channel] = v(obj[i] if nans else obj._space.resolve_channel(i, obj._coords))
 
             # Update the original color
             self.update(obj)
@@ -934,15 +1004,39 @@ def set(  # noqa: A003
             # Handle space.channel
             if '.' in name:
                 space, channel = name.split('.', 1)
-                obj = self.convert(space)
-                obj[channel] = value(obj[channel]) if callable(value) else value
+                obj = self.convert(space, norm=nans)
+                if not callable(value):
+                    obj[channel] = value
+                else:
+                    i = obj._space.get_channel_index(channel)
+                    obj[channel] = value(obj[i] if nans else obj._space.resolve_channel(i, obj._coords))
                 return self.update(obj)
 
             # Handle a function that modifies the value or a direct value
-            self[name] = value(self[name]) if callable(value) else value
+            if not callable(value):
+                self[name] = value
+            else:
+                i = self._space.get_channel_index(name)
+                self[name] = value(self[i] if nans else self._space.resolve_channel(i, self._coords))
 
         return self
 
+    def coords(self, *, nans: bool = True) -> Vector:
+        """Get the color channels and optionally remove undefined values."""
+
+        if nans:
+            return self[:-1]
+        else:
+            return [self._space.resolve_channel(index, self._coords) for index in range(len(self._coords) - 1)]
+
+    def alpha(self, *, nans: bool = True) -> float:
+        """Get the alpha channel."""
+
+        if nans:
+            return self[-1]
+        else:
+            return self._space.resolve_channel(-1, self._coords)
+
 
 Color.register(
     [
diff --git a/coloraide/compositing/__init__.py b/coloraide/compositing/__init__.py
index e8ba090e2..b777a212b 100644
--- a/coloraide/compositing/__init__.py
+++ b/coloraide/compositing/__init__.py
@@ -4,6 +4,7 @@
 https://www.w3.org/TR/compositing/
 """
 from __future__ import annotations
+from .. spaces import RGBish
 from . import porter_duff
 from . import blend_modes
 from .. import algebra as alg
@@ -40,10 +41,10 @@ def apply_compositing(
     """Perform the actual blending."""
 
     # Get the color coordinates
-    csa = alg.no_nan(color1[-1])
-    cba = alg.no_nan(color2[-1])
-    coords1 = alg.no_nans(color1[:-1])
-    coords2 = alg.no_nans(color2[:-1])
+    csa = color1.alpha(nans=False)
+    cba = color2.alpha(nans=False)
+    coords1 = color1.coords(nans=False)
+    coords2 = color2.coords(nans=False)
 
     # Setup compositing
     compositor = None  # type: porter_duff.PorterDuff | None
@@ -79,7 +80,8 @@ def compose(
     backdrop: list[Color],
     blend: str | bool = True,
     operator: str | bool = True,
-    space: str | None = None
+    space: str | None = None,
+    out_space: str | None = None
 ) -> Color:
     """Blend colors using the specified blend mode."""
 
@@ -89,12 +91,16 @@ def compose(
         blender = blend_modes.get_blender(blend)
     elif blend is True:
         blender = blend_modes.get_blender('normal')
-    is_seperable = blender is not None and isinstance(blender, blend_modes.NonSeperableBlend)
 
     # If we are doing non-separable, we are converting to a special space that
     # can only be done from sRGB, so we have to force sRGB anyway.
-    if space is None or is_seperable:
+    if space is None:
         space = 'srgb'
+    if out_space is None:
+        out_space = space
+
+    if not isinstance(color.CS_MAP[space], RGBish):
+        raise ValueError("Can only compose in an RGBish color space, not {}".format(type(color.CS_MAP[space])))
 
     if not backdrop:
         return color
@@ -109,4 +115,4 @@ def compose(
 
     src = color.convert(space)
 
-    return apply_compositing(src, dest, blender, operator)
+    return apply_compositing(src, dest, blender, operator).convert(out_space, in_place=True)
diff --git a/coloraide/contrast/lstar.py b/coloraide/contrast/lstar.py
index 2c038a2c3..74c3ecdc5 100644
--- a/coloraide/contrast/lstar.py
+++ b/coloraide/contrast/lstar.py
@@ -21,8 +21,8 @@ class LstarContrast(ColorContrast):
     def contrast(self, color1: Color, color2: Color, **kwargs: Any) -> float:
         """Contrast."""
 
-        l1 = color1.get('lch-d65.lightness')
-        l2 = color2.get('lch-d65.lightness')
+        l1 = color1.get('lch-d65.lightness', nans=False)
+        l2 = color2.get('lch-d65.lightness', nans=False)
 
         if l1 > l2:
             l2, l1 = l1, l2
diff --git a/coloraide/convert.py b/coloraide/convert.py
index 2e6e84dd2..977dcdd16 100644
--- a/coloraide/convert.py
+++ b/coloraide/convert.py
@@ -1,6 +1,5 @@
 """Convert the color."""
 from __future__ import annotations
-from . import algebra as alg
 from .types import Vector
 from typing import TYPE_CHECKING
 
@@ -128,9 +127,9 @@ def convert(color: Color, space: str) -> tuple[Space, Vector]:
     chain = color._get_convert_chain(color._space, space)  # type: ignore[attr-defined]
 
     # Get coordinates and convert NaN values to 0
-    coords = alg.no_nans(color[:-1])
+    coords = color.coords(nans=False)
 
-    # Navigate the conversion chain translated the coordinates along the way.
+    # Navigate the conversion chain translating the coordinates along the way.
     # Perform chromatic adaption if needed (a conversion to or from XYZ D65).
     last = color._space
     for a, b, direction, adapt in chain:
diff --git a/coloraide/css/serialize.py b/coloraide/css/serialize.py
index 438dd4189..0fb188218 100644
--- a/coloraide/css/serialize.py
+++ b/coloraide/css/serialize.py
@@ -1,6 +1,7 @@
 """String serialization."""
 from __future__ import annotations
 import re
+import math
 from .. import util
 from .. import algebra as alg
 from .color_names import to_name
@@ -19,7 +20,11 @@
 EMPTY = ''
 
 
-def named_color(obj: 'Color', alpha: bool | None, fit: str | bool) -> str | None:
+def named_color(
+    obj: 'Color',
+    alpha: bool | None,
+    fit: str | bool
+) -> str | None:
     """Get the CSS color name."""
 
     a = get_alpha(obj, alpha, False, False)
@@ -94,18 +99,28 @@ def color_function(
     )
 
 
-def get_coords(obj: 'Color', fit: str | bool, none: bool, legacy: bool) -> Vector:
+def get_coords(
+    obj: 'Color',
+    fit: str | bool,
+    none: bool,
+    legacy: bool
+) -> Vector:
     """Get the coordinates."""
 
-    coords = obj.fit(method=None if not isinstance(fit, str) else fit)[:-1] if fit else obj[:-1]
-    return alg.no_nans(coords) if legacy or not none else coords
+    color = (obj.fit(method=None if not isinstance(fit, str) else fit) if fit else obj)
+    return color.coords(nans=False if legacy or not none else True)
 
 
-def get_alpha(obj: 'Color', alpha: bool | None, none: bool, legacy: bool) -> float | None:
+def get_alpha(
+    obj: 'Color',
+    alpha: bool | None,
+    none: bool,
+    legacy: bool
+) -> float | None:
     """Get the alpha if required."""
 
-    a = alg.no_nan(obj[-1]) if not none or legacy else obj[-1]
-    alpha = alpha is not False and (alpha is True or a < 1.0 or alg.is_nan(a))
+    a = obj.alpha(nans=False if not none or legacy else True)
+    alpha = alpha is not False and (alpha is True or a < 1.0 or math.isnan(a))
     return None if not alpha else a
 
 
@@ -114,7 +129,7 @@ def hexadecimal(
     alpha: bool | None = None,
     fit: str | bool = True,
     upper: bool = False,
-    compress: bool = False
+    compress: bool = False,
 ) -> str:
     """Get the hex `RGB` value."""
 
@@ -159,7 +174,7 @@ def serialize_css(
     compress: bool = False,
     name: bool = False,
     legacy: bool = False,
-    scale: float = 1.0,
+    scale: float = 1.0
 ) -> str:
     """Convert color to CSS."""
 
diff --git a/coloraide/distance/__init__.py b/coloraide/distance/__init__.py
index 240d94666..8567a9a83 100644
--- a/coloraide/distance/__init__.py
+++ b/coloraide/distance/__init__.py
@@ -16,9 +16,8 @@ def closest(color: Color, colors: Sequence[ColorInput], method: str | None = Non
     if method is None:
         method = color.DELTA_E
 
-    try:
-        algorithm = color.DE_MAP[method]
-    except KeyError:
+    algorithm = color.DE_MAP.get(method)
+    if not algorithm:
         raise ValueError("'{}' is not currently a supported distancing algorithm.".format(method))
 
     lowest = alg.INF
@@ -43,8 +42,8 @@ def distance_euclidean(color: Color, sample: Color, space: str = "lab-d65") -> f
     https://en.wikipedia.org/wiki/Euclidean_distance
     """
 
-    coords1 = alg.no_nans(color.convert(space)[:-1])
-    coords2 = alg.no_nans(sample.convert(space)[:-1])
+    coords1 = color.convert(space, norm=False).coords(nans=False)
+    coords2 = sample.convert(space, norm=False).coords(nans=False)
 
     return math.sqrt(sum((x - y) ** 2.0 for x, y in zip(coords1, coords2)))
 
diff --git a/coloraide/distance/delta_e_2000.py b/coloraide/distance/delta_e_2000.py
index a8067a888..bc6ea05ad 100644
--- a/coloraide/distance/delta_e_2000.py
+++ b/coloraide/distance/delta_e_2000.py
@@ -1,7 +1,6 @@
 """Delta E 2000."""
 from __future__ import annotations
 import math
-from .. import algebra as alg
 from ..distance import DeltaE
 from typing import TYPE_CHECKING, Any
 
@@ -44,8 +43,8 @@ def distance(
         http://www2.ece.rochester.edu/~gsharma/ciede2000/ciede2000noteCRNA.pdf
         """
 
-        l1, a1, b1 = alg.no_nans(color.convert(cls.LAB)[:-1])
-        l2, a2, b2 = alg.no_nans(sample.convert(cls.LAB)[:-1])
+        l1, a1, b1 = color.convert(cls.LAB).coords(nans=False)
+        l2, a2, b2 = sample.convert(cls.LAB).coords(nans=False)
 
         # Equation (2)
         c1 = math.sqrt(a1 ** 2 + b1 ** 2)
diff --git a/coloraide/distance/delta_e_94.py b/coloraide/distance/delta_e_94.py
index 8fea3d2c7..ae8df4674 100644
--- a/coloraide/distance/delta_e_94.py
+++ b/coloraide/distance/delta_e_94.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 from ..distance import DeltaE
 import math
-from .. import algebra as alg
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -50,8 +49,8 @@ def distance(
         if k2 is None:
             k2 = self.k2
 
-        l1, a1, b1 = alg.no_nans(color.convert("lab")[:-1])
-        l2, a2, b2 = alg.no_nans(sample.convert("lab")[:-1])
+        l1, a1, b1 = color.convert("lab").coords(nans=False)
+        l2, a2, b2 = sample.convert("lab").coords(nans=False)
 
         # Equation (5)
         c1 = math.sqrt(a1 ** 2 + b1 ** 2)
diff --git a/coloraide/distance/delta_e_cam16.py b/coloraide/distance/delta_e_cam16.py
index bac3b6cc3..f3a00e23e 100644
--- a/coloraide/distance/delta_e_cam16.py
+++ b/coloraide/distance/delta_e_cam16.py
@@ -6,7 +6,6 @@
 from __future__ import annotations
 import math
 from ..distance import DeltaE
-from .. import algebra as alg
 from typing import Any, TYPE_CHECKING
 from ..spaces.cam16_ucs import COEFFICENTS
 
@@ -24,8 +23,8 @@ def distance(self, color: Color, sample: Color, model: str = 'ucs', **kwargs: An
 
         space = 'cam16-{}'.format(model)
         kl = COEFFICENTS[model][0]
-        j1, a1, b1 = alg.no_nans(color.convert(space)[:-1])
-        j2, a2, b2 = alg.no_nans(sample.convert(space)[:-1])
+        j1, a1, b1 = color.convert(space).coords(nans=False)
+        j2, a2, b2 = sample.convert(space).coords(nans=False)
 
         dj = j1 - j2
         da = a1 - a2
diff --git a/coloraide/distance/delta_e_cmc.py b/coloraide/distance/delta_e_cmc.py
index 5e98d182f..5250e4322 100644
--- a/coloraide/distance/delta_e_cmc.py
+++ b/coloraide/distance/delta_e_cmc.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 from ..distance import DeltaE
 import math
-from .. import algebra as alg
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -44,8 +43,8 @@ def distance(
         if c is None:
             c = self.c
 
-        l1, a1, b1 = alg.no_nans(color.convert("lab")[:-1])
-        l2, a2, b2 = alg.no_nans(sample.convert("lab")[:-1])
+        l1, a1, b1 = color.convert("lab").coords(nans=False)
+        l2, a2, b2 = sample.convert("lab").coords(nans=False)
 
         # Equation (3)
         c1 = math.sqrt(a1 ** 2 + b1 ** 2)
diff --git a/coloraide/distance/delta_e_hct.py b/coloraide/distance/delta_e_hct.py
index 94bdba544..9a7bb2f95 100644
--- a/coloraide/distance/delta_e_hct.py
+++ b/coloraide/distance/delta_e_hct.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 import math
 from ..distance import DeltaE
-from .. import algebra as alg
 from ..spaces.cam16_ucs import COEFFICENTS
 from ..types import Vector
 from typing import Any, TYPE_CHECKING
@@ -32,8 +31,8 @@ class DEHCT(DeltaE):
     def distance(self, color: Color, sample: Color, **kwargs: Any) -> float:
         """Delta E HCT color distance formula."""
 
-        h1, c1, t1 = alg.no_nans(color.convert('hct')[:-1])
-        h2, c2, t2 = alg.no_nans(sample.convert('hct')[:-1])
+        h1, c1, t1 = color.convert('hct', norm=False).coords(nans=False)
+        h2, c2, t2 = sample.convert('hct', norm=False).coords(nans=False)
 
         a1, b1 = convert_ucs_ab(c1, h1)
         a2, b2 = convert_ucs_ab(c2, h2)
diff --git a/coloraide/distance/delta_e_hyab.py b/coloraide/distance/delta_e_hyab.py
index c197ab818..551b0c03c 100644
--- a/coloraide/distance/delta_e_hyab.py
+++ b/coloraide/distance/delta_e_hyab.py
@@ -2,7 +2,6 @@
 from __future__ import annotations
 from ..distance import DeltaE
 import math
-from .. import algebra as alg
 from ..spaces import Labish
 from typing import TYPE_CHECKING, Any
 
@@ -36,8 +35,8 @@ def distance(self, color: Color, sample: Color, space: str | None = None, **kwar
         if not isinstance(color._space, Labish):
             raise ValueError("The space '{}' is not a 'lab-ish' color space and cannot use HyAB".format(space))
 
-        names = color._space.labish_names()
-        l1, a1, b1 = alg.no_nans(color.get(names))
-        l2, a2, b2 = alg.no_nans(sample.get(names))
+        names = color._space.names()
+        l1, a1, b1 = color.get(names, nans=False)
+        l2, a2, b2 = sample.get(names, nans=False)
 
         return abs(l1 - l2) + math.sqrt((a1 - a2) ** 2 + (b1 - b2) ** 2)
diff --git a/coloraide/distance/delta_e_itp.py b/coloraide/distance/delta_e_itp.py
index 55740d311..26c3f2b6e 100644
--- a/coloraide/distance/delta_e_itp.py
+++ b/coloraide/distance/delta_e_itp.py
@@ -6,7 +6,6 @@
 from __future__ import annotations
 from ..distance import DeltaE
 import math
-from .. import algebra as alg
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -29,8 +28,8 @@ def distance(self, color: Color, sample: Color, scalar: float | None = None, **k
         if scalar is None:
             scalar = self.scalar
 
-        i1, t1, p1 = alg.no_nans(color.convert('ictcp')[:-1])
-        i2, t2, p2 = alg.no_nans(sample.convert('ictcp')[:-1])
+        i1, t1, p1 = color.convert('ictcp').coords(nans=False)
+        i2, t2, p2 = sample.convert('ictcp').coords(nans=False)
 
         # Equation (1)
         return scalar * math.sqrt((i1 - i2) ** 2 + 0.25 * (t1 - t2) ** 2 + (p1 - p2) ** 2)
diff --git a/coloraide/distance/delta_e_z.py b/coloraide/distance/delta_e_z.py
index 85f69ce83..7f224048c 100644
--- a/coloraide/distance/delta_e_z.py
+++ b/coloraide/distance/delta_e_z.py
@@ -6,7 +6,6 @@
 from __future__ import annotations
 from ..distance import DeltaE
 import math
-from .. import algebra as alg
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:  # pragma: no cover
@@ -21,8 +20,8 @@ class DEZ(DeltaE):
     def distance(self, color: Color, sample: Color, **kwargs: Any) -> float:
         """Delta E z color distance formula."""
 
-        jz1, az1, bz1 = alg.no_nans(color.convert('jzazbz')[:-1])
-        jz2, az2, bz2 = alg.no_nans(sample.convert('jzazbz')[:-1])
+        jz1, az1, bz1 = color.convert('jzazbz').coords(nans=False)
+        jz2, az2, bz2 = sample.convert('jzazbz').coords(nans=False)
 
         cz1 = math.sqrt(az1 ** 2 + bz1 ** 2)
         cz2 = math.sqrt(az2 ** 2 + bz2 ** 2)
diff --git a/coloraide/filters/__init__.py b/coloraide/filters/__init__.py
index 6e96bfd82..48397f192 100644
--- a/coloraide/filters/__init__.py
+++ b/coloraide/filters/__init__.py
@@ -25,6 +25,7 @@ def filters(
     name: str,
     amount: float | None = None,
     space: str | None = None,
+    out_space: str | None = None,
     in_place: bool = False,
     **kwargs: Any
 ) -> Color:
@@ -42,7 +43,9 @@ def filters(
             "The '{}' only supports filtering in the {} spaces, not '{}'".format(name, str(f.ALLOWED_SPACES), space)
         )
 
-    current = color.space()
-    c = color.convert(space, in_place=in_place)
+    if out_space is None:
+        out_space = space
+
+    c = color.convert(space, in_place=in_place, norm=False).normalize()
     f.filter(c, amount, **kwargs)
-    return c.convert(current, in_place=True)
+    return c.convert(out_space, in_place=True)
diff --git a/coloraide/filters/cvd.py b/coloraide/filters/cvd.py
index cb4791476..2d07a30c2 100644
--- a/coloraide/filters/cvd.py
+++ b/coloraide/filters/cvd.py
@@ -223,7 +223,7 @@ class Protan(Filter):
     VIENOT = VIENOT_PROTAN
     MACHADO = MACHADO_PROTAN
 
-    def __init__(self, severe: str = 'vienot', anomalous: str = 'machado') -> None:
+    def __init__(self, severe: str = 'vienot', anomalous: str = 'machado', **kwargs: Any) -> None:
         """Initialize."""
 
         self.severe = severe
@@ -290,7 +290,7 @@ class Tritan(Protan):
     VIENOT = VIENOT_TRITAN
     MACHADO = MACHADO_TRITAN
 
-    def __init__(self, severe: str = 'brettel', anomalous: str = 'brettel') -> None:
+    def __init__(self, severe: str = 'brettel', anomalous: str = 'brettel', **kwargs: Any) -> None:
         """Initialize."""
 
         super().__init__(severe, anomalous)
diff --git a/coloraide/gamut/__init__.py b/coloraide/gamut/__init__.py
index 30a96b738..803e5b68c 100644
--- a/coloraide/gamut/__init__.py
+++ b/coloraide/gamut/__init__.py
@@ -11,7 +11,7 @@
     from ..color import Color
 
 
-def clip_channels(color: Color) -> None:
+def clip_channels(color: Color, nans: bool = True) -> None:
     """Clip channels."""
 
     for i, value in enumerate(color[:-1]):
diff --git a/coloraide/gamut/fit_lch_chroma.py b/coloraide/gamut/fit_lch_chroma.py
index 22db5d988..c17393f30 100644
--- a/coloraide/gamut/fit_lch_chroma.py
+++ b/coloraide/gamut/fit_lch_chroma.py
@@ -1,11 +1,16 @@
 """Fit by compressing chroma in LCh."""
 from __future__ import annotations
 from ..gamut import Fit, clip_channels
+from ..cat import WHITES
+from .. import util
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:  # pragma: no cover
     from ..color import Color
 
+WHITE = util.xy_to_xyz(WHITES['2deg']['D65'])
+BLACK = [0, 0, 0]
+
 
 class LChChroma(Fit):
     """
@@ -37,29 +42,25 @@ class LChChroma(Fit):
     def fit(self, color: Color, **kwargs: Any) -> None:
         """Gamut mapping via CIELCh chroma."""
 
-        # If within gamut, just normalize hues by calling clip
-        if color.in_gamut(tolerance=0):
-            clip_channels(color)
-            return
-
         space = color.space()
-        mapcolor = color.convert(self.SPACE)
-        lightness = mapcolor['lightness']
+        mapcolor = color.convert(self.SPACE, norm=False)
+        l, c = mapcolor._space.indexes()[:2]  # type: ignore[attr-defined]
+        lightness = mapcolor[l]
         sdr = color._space.DYNAMIC_RANGE == 'sdr'
 
         # Return white or black if lightness is out of dynamic range for lightness.
         # Extreme light case only applies to SDR, but dark case applies to all ranges.
         if sdr and lightness >= self.MAX_LIGHTNESS:
-            clip_channels(color.update('srgb', [1.0, 1.0, 1.0], mapcolor[-1]))
+            clip_channels(color.update('xyz-d65', WHITE, mapcolor[-1]))
             return
         elif lightness <= self.MIN_LIGHTNESS:
-            clip_channels(color.update('srgb', [0.0, 0.0, 0.0], mapcolor[-1]))
+            clip_channels(color.update('xyz-d65', BLACK, mapcolor[-1]))
             return
 
         # Set initial chroma boundaries
         low = 0.0
-        high = mapcolor['chroma']
-        clip_channels(color.update(mapcolor))
+        high = mapcolor[c]
+        clip_channels(color._hotswap(mapcolor.convert(space, norm=False)))
 
         # Adjust chroma if we are not under the JND yet.
         if mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS) >= self.LIMIT:
@@ -69,13 +70,13 @@ def fit(self, color: Color, **kwargs: Any) -> None:
             # If high and low get too close to converging,
             # we need to quit in order to prevent infinite looping.
             while (high - low) > self.MIN_CONVERGENCE:
-                mapcolor['chroma'] = (high + low) * 0.5
+                mapcolor[c] = (high + low) * 0.5
 
                 # Avoid doing expensive delta E checks if in gamut
                 if lower_in_gamut and mapcolor.in_gamut(space, tolerance=0):
-                    low = mapcolor['chroma']
+                    low = mapcolor[c]
                 else:
-                    clip_channels(color.update(mapcolor))
+                    clip_channels(color._hotswap(mapcolor.convert(space, norm=False)))
                     de = mapcolor.delta_e(color, method=self.DE, **self.DE_OPTIONS)
                     if de < self.LIMIT:
                         # Kick out as soon as we are close enough to the JND.
@@ -88,7 +89,8 @@ def fit(self, color: Color, **kwargs: Any) -> None:
                         # chroma to get as close to the JND as possible.
                         if lower_in_gamut:
                             lower_in_gamut = False
-                        low = mapcolor['chroma']
+                        low = mapcolor[c]
                     else:
                         # We are still outside the gamut and outside the JND
-                        high = mapcolor['chroma']
+                        high = mapcolor[c]
+        color.normalize()
diff --git a/coloraide/harmonies.py b/coloraide/harmonies.py
index 000a738b4..285e64fec 100644
--- a/coloraide/harmonies.py
+++ b/coloraide/harmonies.py
@@ -3,11 +3,16 @@
 from abc import ABCMeta, abstractmethod
 from . import algebra as alg
 from .spaces import Cylindrical
-from typing import TYPE_CHECKING
+from .cat import WHITES
+from . import util
+from typing import TYPE_CHECKING, Any  # noqa: F401
 
 if TYPE_CHECKING:  # pragma: no cover
     from .color import Color
 
+WHITE = util.xy_to_xyz(WHITES['2deg']['D65'])
+BLACK = [0, 0, 0]
+
 
 def adjust_hue(hue: float, deg: float) -> float:
     """Adjust hue by the given degree."""
@@ -19,7 +24,7 @@ class Harmony(metaclass=ABCMeta):
     """Color harmony."""
 
     @abstractmethod
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
 
@@ -50,59 +55,61 @@ class Monochromatic(Harmony):
     RANGE = 12
     STEPS = 5
 
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
-        if space is None:
-            space = color.HARMONY
-
-        orig_space = color.space()
-        color0 = color.convert(space).normalize()
+        color1 = color.convert(space, norm=False).normalize()
 
-        if not isinstance(color0._space, Cylindrical):
+        if not isinstance(color1._space, Cylindrical):
             raise ValueError('Color space must be cylindrical')
 
         # Trim off black and white unless the color is achromatic,
         # But always trim duplicate target color from left side.
-        if not color0.is_nan('hue'):
+        if not color1.is_nan('hue'):
             ltrim, rtrim = slice(1, -1, None), slice(None, -1, None)
         else:
             ltrim, rtrim = slice(None, -1, None), slice(None, None, None)
 
         # Create black and white so we can generate tints and shades
         # Ensure hue and alpha is masked so we don't interpolate them.
-        w = color.new('srgb', [1, 1, 1], alg.NaN).convert(space, in_place=True).mask(['hue', 'alpha'], in_place=True)
-        b = color.new('srgb', [0, 0, 0], alg.NaN).convert(space, in_place=True).mask(['hue', 'alpha'], in_place=True)
+        w = color.new('xyz-d65', WHITE, alg.NaN)
+        w.convert(space, in_place=True, norm=False).mask(['hue', 'alpha'], in_place=True)
+        b = color.new('xyz-d65', BLACK, alg.NaN)
+        b.convert(space, in_place=True, norm=False).mask(['hue', 'alpha'], in_place=True)
 
         # Calculate how many tints and shades we need to generate
-        db = b.delta_e(color0, method=self.DELTA_E)
-        dw = w.delta_e(color0, method=self.DELTA_E)
+        db = b.delta_e(color1, method=self.DELTA_E)
+        dw = w.delta_e(color1, method=self.DELTA_E)
         steps_w = int(alg.round_half_up((dw / (db + dw)) * self.RANGE))
         steps_b = self.RANGE - steps_w
 
+        kwargs = {
+            'space': space,
+            'method': 'linear',
+            'out_space': space
+        }  # type: dict[str, Any]
+
         # Very close to black or is black, no need to interpolate from black to current color
         if steps_b <= 1:
             left = []
             if steps_b == 1:
-                left.extend(color.steps([b, color], steps=steps_b, space=space, out_space=orig_space, method='linear'))
+                left.extend(color.steps([b, color], steps=steps_b, **kwargs))
             steps = min(self.RANGE - (1 + steps_b), steps_w)
-            right = color.steps([color0, w], steps=steps, space=space, out_space=orig_space, method='linear')[rtrim]
+            right = color.steps([color1, w], steps=steps, **kwargs)[rtrim]
 
         # Very close to white or is white, no need to interpolate from current color to white
         elif steps_w <= 1:
             right = []
             if steps_w == 1:
-                right.extend(
-                    color.steps([color0, w], steps=steps_w, space=space, out_space=orig_space, method='linear')
-                )
+                right.extend(color.steps([color1, w], steps=steps_w, **kwargs))
             steps = min(self.RANGE - (1 + steps_w), steps_b)
             right.insert(0, color.clone())
-            left = color.steps([b, color], steps=steps, space=space, out_space=orig_space, method='linear')[ltrim]
+            left = color.steps([b, color], steps=steps, **kwargs)[ltrim]
 
+        # Anything else in between
         else:
-            # Anything else in between
-            left = color.steps([b, color], steps=steps_b, space=space, out_space=orig_space, method='linear')[ltrim]
-            right = color.steps([color0, w], steps=steps_w, space=space, out_space=orig_space, method='linear')[rtrim]
+            left = color.steps([b, color], steps=steps_b, **kwargs)[ltrim]
+            right = color.steps([color1, w], steps=steps_w, **kwargs)[rtrim]
 
         # Extract a subset of the results
         len_l = len(left)
@@ -121,27 +128,25 @@ class Geometric(Harmony):
 
     COUNT = 0
 
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
-        if space is None:
-            space = color.HARMONY
-
-        orig_space = color.space()
-        color0 = color.convert(space)
+        color1 = color.convert(space, norm=False).normalize()
 
-        if not isinstance(color0._space, Cylindrical):
+        if not isinstance(color1._space, Cylindrical):
             raise ValueError('Color space must be cylindrical')
 
-        name = color0._space.hue_name()
+        name = color1._space.hue_name()
 
         degree = current = 360.0 / self.COUNT
-        colors = [color]
+        colors = []
         for _ in range(self.COUNT - 1):
+            obj = color1.clone()
             colors.append(
-                color0.clone().set(name, lambda x: adjust_hue(x, current)).convert(orig_space, in_place=True)
+                obj.set(name, lambda x: adjust_hue(x, current))
             )
             current += degree
+        colors.insert(0, color1)
         return colors
 
 
@@ -166,88 +171,56 @@ class TetradicSquare(Geometric):
 class SplitComplementary(Harmony):
     """Split Complementary colors."""
 
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
-        if space is None:
-            space = color.HARMONY
+        color1 = color.convert(space, norm=False).normalize()
 
-        orig_space = color.space()
-        color0 = color.convert(space)
-
-        if not isinstance(color0._space, Cylindrical):
+        if not isinstance(color1._space, Cylindrical):
             raise ValueError('Color space must be cylindrical')
 
-        name = color0._space.hue_name()
+        name = color1._space.hue_name()
 
-        color2 = color0.clone()
-        color3 = color0.clone()
-        color2.set(name, lambda x: adjust_hue(x, 210))
-        color3.set(name, lambda x: adjust_hue(x, -210))
-        return [
-            color,
-            color2.convert(orig_space, in_place=True),
-            color3.convert(orig_space, in_place=True)
-        ]
+        color2 = color1.clone().set(name, lambda x: adjust_hue(x, 210))
+        color3 = color1.clone().set(name, lambda x: adjust_hue(x, -210))
+        return [color1, color2, color3]
 
 
 class Analogous(Harmony):
     """Analogous colors."""
 
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
-        if space is None:
-            space = color.HARMONY
-
-        orig_space = color.space()
-        color0 = color.convert(space)
+        color1 = color.convert(space, norm=False).normalize()
 
-        if not isinstance(color0._space, Cylindrical):
+        if not isinstance(color1._space, Cylindrical):
             raise ValueError('Color space must be cylindrical')
 
-        name = color0._space.hue_name()
+        name = color1._space.hue_name()
 
-        color2 = color0.clone()
-        color3 = color0.clone()
-        color2.set(name, lambda x: adjust_hue(x, 30))
-        color3.set(name, lambda x: adjust_hue(x, -30))
-        return [
-            color,
-            color2.convert(orig_space, in_place=True),
-            color3.convert(orig_space, in_place=True)
-        ]
+        color2 = color1.clone().set(name, lambda x: adjust_hue(x, 30))
+        color3 = color1.clone().set(name, lambda x: adjust_hue(x, -30))
+        return [color1, color2, color3]
 
 
 class TetradicRect(Harmony):
     """Tetradic (rectangular) colors."""
 
-    def harmonize(self, color: Color, space: str | None) -> list[Color]:
+    def harmonize(self, color: Color, space: str) -> list[Color]:
         """Get color harmonies."""
 
-        if space is None:
-            space = color.HARMONY
-
-        orig_space = color.space()
-        color0 = color.convert(space)
+        color1 = color.convert(space, norm=False).normalize()
 
-        if not isinstance(color0._space, Cylindrical):
+        if not isinstance(color1._space, Cylindrical):
             raise ValueError('Color space must be cylindrical')
 
-        name = color0._space.hue_name()
+        name = color1._space.hue_name()
 
-        color2 = color0.clone()
-        color3 = color0.clone()
-        color4 = color0.clone()
-        color2.set(name, lambda x: adjust_hue(x, 30))
-        color3.set(name, lambda x: adjust_hue(x, 180))
-        color4.set(name, lambda x: adjust_hue(x, 210))
-        return [
-            color,
-            color2.convert(orig_space, in_place=True),
-            color3.convert(orig_space, in_place=True),
-            color4.convert(orig_space, in_place=True)
-        ]
+        color2 = color1.clone().set(name, lambda x: adjust_hue(x, 30))
+        color3 = color1.clone().set(name, lambda x: adjust_hue(x, 180))
+        color4 = color1.clone().set(name, lambda x: adjust_hue(x, 210))
+        return [color1, color2, color3, color4]
 
 
 SUPPORTED = {
@@ -261,12 +234,11 @@ def harmonize(self, color: Color, space: str | None) -> list[Color]:
 }  # type: dict[str, Harmony]
 
 
-def harmonize(color: Color, name: str, space: str | None) -> list[Color]:
+def harmonize(color: Color, name: str, space: str) -> list[Color]:
     """Get specified color harmonies."""
 
-    try:
-        h = SUPPORTED[name]
-    except KeyError:
+    h = SUPPORTED.get(name)
+    if not h:
         raise ValueError("The color harmony '{}' cannot be found".format(name))
 
     return h.harmonize(color, space)
diff --git a/coloraide/interpolate/__init__.py b/coloraide/interpolate/__init__.py
index 9dc2ce704..44588130c 100644
--- a/coloraide/interpolate/__init__.py
+++ b/coloraide/interpolate/__init__.py
@@ -19,7 +19,7 @@
 from abc import ABCMeta, abstractmethod
 from .. import util
 from .. import algebra as alg
-from ..spaces import Cylindrical
+from .. spaces import HSVish, HSLish, Cylindrical, RGBish, LChish, Labish
 from ..types import Vector, ColorInput, Plugin
 from typing import Callable, Sequence, Mapping, Any, TYPE_CHECKING
 
@@ -269,10 +269,7 @@ def begin(self, point: float, first: float, last: float, index: int) -> Color:
 
         # Create the color and ensure it is in the correct color space.
         color = self.create(self.space, coords[:-1], coords[-1])
-        if self.out_space != color.space():
-            color.convert(self.out_space, in_place=True)
-
-        return color
+        return color.convert(self.out_space, in_place=True)
 
     def ease(self, t: float, channel_index: int) -> float:
         """Provide a progression time and channel index."""
@@ -444,15 +441,6 @@ def process_mapping(
     return {aliases.get(k, k): v for k, v in progress.items()}
 
 
-def normalize_color(color: Color) -> None:
-    """Normalize color."""
-
-    # Adjust to color to space and ensure it fits
-    if not color._space.EXTENDED_RANGE:
-        if not color.in_gamut():
-            color.fit()
-
-
 def adjust_shorter(h1: float, h2: float, offset: float) -> tuple[float, float]:
     """Adjust the given hues."""
 
@@ -540,6 +528,77 @@ def normalize_hue(
     return color2, offset
 
 
+def carryforward_convert(color: Color, space: str) -> None:  # pragma: no cover
+    """Carry forward undefined values during conversion."""
+
+    cs1 = color._space
+    cs2 = color.CS_MAP[space]
+    channels = {'r': False, 'g': False, 'b': False, 'h': False, 'c': False, 'l': False, 'v': False}
+    carry = []
+
+    # Gather undefined channels
+    if isinstance(cs1, RGBish):
+        for i, name in zip(cs1.indexes(), ('r', 'g', 'b')):
+            if alg.is_nan(color[i]):
+                channels[name] = True
+    elif isinstance(cs1, LChish):
+        for i, name in zip(cs1.indexes(), ('l', 'c', 'h')):
+            if alg.is_nan(color[i]):
+                channels[name] = True
+    elif isinstance(cs1, Labish):
+        if alg.is_nan(color[cs1.indexes()[0]]):
+            channels['l'] = True
+    elif isinstance(cs1, HSLish):
+        for i, name in zip(cs1.indexes(), ('h', 'c', 'l')):
+            if alg.is_nan(color[i]):
+                channels[name] = True
+    elif isinstance(cs1, HSVish):
+        for i, name in zip(cs1.indexes(), ('h', 'c', 'v')):
+            if alg.is_nan(color[i]):
+                channels[name] = True
+    elif isinstance(cs1, Cylindrical):
+        if alg.is_nan(color[cs1.hue_index()]):
+            channels['h'] = True
+
+    # Carry alpha forward if undefined
+    if alg.is_nan(color[-1]):
+        carry.append(-1)
+
+    # Channels that need to be carried forward
+    if isinstance(cs2, RGBish):
+        indexes = cs2.indexes()
+        for e, name in enumerate(('r', 'g', 'b')):
+            if channels[name]:
+                carry.append(indexes[e])
+    elif isinstance(cs2, Labish):
+        indexes = cs2.indexes()
+        if channels['l']:
+            carry.append(indexes[0])
+    elif isinstance(cs2, LChish):
+        indexes = cs2.indexes()
+        for e, name in enumerate(('l', 'c', 'h')):
+            if channels[name]:
+                carry.append(indexes[e])
+    elif isinstance(cs2, HSLish):
+        indexes = cs2.indexes()
+        for e, name in enumerate(('h', 'c', 'l')):
+            if channels[name]:
+                carry.append(indexes[e])
+    elif isinstance(cs2, HSVish):
+        indexes = cs2.indexes()
+        for e, name in enumerate(('h', 'c', 'v')):
+            if channels[name]:
+                carry.append(indexes[e])
+    elif isinstance(cs2, Cylindrical):
+        if channels['h']:
+            carry.append(cs2.hue_index())
+
+    # Convert the color space
+    color.convert(space, in_place=True)
+    for i in carry:
+        color[i] = alg.NaN
+
+
 def interpolator(
     interpolator: str,
     create: type[Color],
@@ -575,23 +634,30 @@ def interpolator(
         raise ValueError('Cannot have an easing function as the first item in an interpolation list')
 
     if out_space is None:
-        out_space = current.space()
+        out_space = space
+
+    # Adjust to space
+    if space != current.space():
+        if kwargs.get('_carryforward', False):  # pragma: no cover
+            carryforward_convert(current, space)
+        else:
+            current.convert(space, in_place=True)
 
-    current.convert(space, in_place=True)
     offset = 0.0
     hue_index = current._space.hue_index() if isinstance(current._space, Cylindrical) else -1
-    normalize_color(current)
-    norm = current[:]
+
+    # Normalize hue
+    norm_coords = current[:]
     fallback = None
     if hue_index >= 0:
-        h = norm[hue_index]
-        norm, offset = normalize_hue(norm, None, hue_index, offset, hue, fallback)
+        h = norm_coords[hue_index]
+        norm_coords, offset = normalize_hue(norm_coords, None, hue_index, offset, hue, fallback)
         if not alg.is_nan(h):
             fallback = h
 
     easing = None  # type: Any
     easings = []  # type: Any
-    coords = [norm]
+    coords = [norm_coords]
 
     i = 0
     for x in colors[1:]:
@@ -609,18 +675,23 @@ def interpolator(
             color = current._handle_color_input(x)
             stops[i] = None
 
-        # Adjust to color to space and ensure it fits
-        color = color.convert(space)
-        normalize_color(color)
-        norm = color[:]
+        # Adjust color to space
+        if space != color.space():
+            if kwargs.get('_carryforward', False):  # pragma: no cover
+                carryforward_convert(color, space)
+            else:
+                color.convert(space, in_place=True)
+
+        # Normalize the hue
+        norm_coords = color[:]
         if hue_index >= 0:
-            h = norm[hue_index]
-            norm, offset = normalize_hue(current[:], norm, hue_index, offset, hue, fallback)
+            h = norm_coords[hue_index]
+            norm_coords, offset = normalize_hue(current[:], norm_coords, hue_index, offset, hue, fallback)
             if not alg.is_nan(h):
                 fallback = h
 
         # Create an entry interpolating the current color and the next color
-        coords.append(norm)
+        coords.append(norm_coords)
         easings.append(easing if easing is not None else progress)
 
         # The "next" color is now the "current" color
diff --git a/coloraide/interpolate/bspline.py b/coloraide/interpolate/bspline.py
index 50f3ec56c..ac2e417f5 100644
--- a/coloraide/interpolate/bspline.py
+++ b/coloraide/interpolate/bspline.py
@@ -27,7 +27,7 @@ def handle_undefined(self) -> None:
         Additionally, any undefined value have a new control point generated via
         linear interpolation. This is the only approach to provide a non-bias, non-breaking
         way to handle things like achromatic hues in a cylindrical space. It also balances
-        not cylindrical values. Since the B-spline needs a a continual path and since we
+        non cylindrical values. Since the B-spline needs a a continual path and since we
         have a sliding window that takes into account 4 points at a time, we must consider
         a more broad context than what is done in piecewise linear.
         """
@@ -190,5 +190,6 @@ def interpolator(
             progress,
             premultiplied,
             extrapolate,
-            domain
+            domain,
+            **kwargs
         )
diff --git a/coloraide/interpolate/bspline_natural.py b/coloraide/interpolate/bspline_natural.py
index 1977f2f37..683fa69fc 100644
--- a/coloraide/interpolate/bspline_natural.py
+++ b/coloraide/interpolate/bspline_natural.py
@@ -67,5 +67,6 @@ def interpolator(
             progress,
             premultiplied,
             extrapolate,
-            domain
+            domain,
+            **kwargs
         )
diff --git a/coloraide/interpolate/catmull_rom.py b/coloraide/interpolate/catmull_rom.py
index 43d5f8e72..b603f9b9d 100644
--- a/coloraide/interpolate/catmull_rom.py
+++ b/coloraide/interpolate/catmull_rom.py
@@ -57,5 +57,6 @@ def interpolator(
             progress,
             premultiplied,
             extrapolate,
-            domain
+            domain,
+            **kwargs
         )
diff --git a/coloraide/interpolate/linear.py b/coloraide/interpolate/linear.py
index b16ca2173..70ca6da6c 100644
--- a/coloraide/interpolate/linear.py
+++ b/coloraide/interpolate/linear.py
@@ -119,5 +119,6 @@ def interpolator(
             progress,
             premultiplied,
             extrapolate,
-            domain
+            domain,
+            **kwargs
         )
diff --git a/coloraide/interpolate/monotone.py b/coloraide/interpolate/monotone.py
index 924f3d2c3..bd000c90c 100644
--- a/coloraide/interpolate/monotone.py
+++ b/coloraide/interpolate/monotone.py
@@ -53,5 +53,6 @@ def interpolator(
             progress,
             premultiplied,
             extrapolate,
-            domain
+            domain,
+            **kwargs
         )
diff --git a/coloraide/spaces/__init__.py b/coloraide/spaces/__init__.py
index d3fea79bb..9dfb0dfa2 100644
--- a/coloraide/spaces/__init__.py
+++ b/coloraide/spaces/__init__.py
@@ -3,9 +3,10 @@
 from abc import ABCMeta, abstractmethod
 from ..channels import Channel
 from ..css import serialize
-from .. import algebra as alg
+from ..util import deprecated
 from ..types import VectorLike, Vector, Plugin
-from typing import Any, cast, TYPE_CHECKING
+from typing import Any, TYPE_CHECKING
+import math
 
 if TYPE_CHECKING:  # pragma: no cover
     from ..color import Color
@@ -22,50 +23,101 @@ def hue_name(self) -> str:
     def hue_index(self) -> int:  # pragma: no cover
         """Get hue index."""
 
-        return cast(Space, self).get_channel_index(self.hue_name())
+        return self.get_channel_index(self.hue_name())  # type: ignore
 
-    def achromatic_hue(self) -> float:
-        """
-        Ideal achromatic hue.
 
-        Normally, we assume 0 when a cylindrical color space has a powerless hue.
-        For most color spaces, the hue has little affect when the color is achromatic,
-        but on rare occasions, a color space algorithm may require a specific hue
-        in order to more accurately translate an achromatic hue, CAM16 JMh (without
-        discounting) being an example. Color spaces internally handle this during
-        conversion, but there are times such as when plotting where knowing the
-        hue can be useful.
-        """
+class RGBish:
+    """RGB-ish space."""
 
-        return 0.0
+    def names(self) -> tuple[str, ...]:
+        """Return RGB-ish names in order R G B."""
+
+        return self.channels[:-1]  # type: ignore
+
+    def indexes(self) -> list[int]:
+        """Return the index of RGB-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]  # type: ignore
+
+
+class HSLish(Cylindrical):
+    """HSL-ish space."""
+
+    def names(self) -> tuple[str, ...]:
+        """Return HSL-ish names in order H S L."""
+
+        return self.channels[:-1]  # type: ignore
+
+    def indexes(self) -> list[int]:
+        """Return the index of HSL-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]  # type: ignore
+
+
+class HSVish(Cylindrical):
+    """HSV-ish space."""
+
+    def names(self) -> tuple[str, ...]:
+        """Return HSV-ish names in order H S V."""
+
+        return self.channels[:-1]  # type: ignore
+
+    def indexes(self) -> list[int]:
+        """Return the index of HSV-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]  # type: ignore
 
 
 class Labish:
     """Lab-ish color spaces."""
 
-    def labish_names(self) -> tuple[str, ...]:
+    @deprecated("Please use 'names' instead.")
+    def labish_names(self) -> tuple[str, ...]:  # pragma: no cover
         """Return Lab-ish names in the order L a b."""
 
-        return cast(Space, self).channels[:-1]
+        return self.names()
 
+    @deprecated("Please use 'indexes' instead.")
     def labish_indexes(self) -> list[int]:  # pragma: no cover
         """Return the index of the Lab-ish channels."""
 
-        return [cast(Space, self).get_channel_index(name) for name in self.labish_names()]
+        return self.indexes()
+
+    def names(self) -> tuple[str, ...]:
+        """Return Lab-ish names in the order L a b."""
+
+        return self.channels[:-1]  # type: ignore
+
+    def indexes(self) -> list[int]:
+        """Return the index of the Lab-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]  # type: ignore
 
 
 class LChish(Cylindrical):
     """LCh-ish color spaces."""
 
+    @deprecated("Please use 'names' instead.")
     def lchish_names(self) -> tuple[str, ...]:  # pragma: no cover
         """Return LCh-ish names in the order L c h."""
 
-        return cast(Space, self).channels[:-1]
+        return self.names()
 
+    @deprecated("Please use 'indexes' instead.")
     def lchish_indexes(self) -> list[int]:  # pragma: no cover
         """Return the index of the Lab-ish channels."""
 
-        return [cast(Space, self).get_channel_index(name) for name in self.lchish_names()]
+        return self.indexes()
+
+    def names(self) -> tuple[str, ...]:
+        """Return LCh-ish names in the order L c h."""
+
+        return self.channels[:-1]  # type: ignore
+
+    def indexes(self) -> list[int]:
+        """Return the index of the Lab-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]  # type: ignore
 
 
 alpha_channel = Channel('alpha', 0.0, 1.0, bound=True, limit=(0.0, 1.0))
@@ -126,11 +178,22 @@ def get_channel_index(self, name: str) -> int:
 
         return self.channels.index(self.CHANNEL_ALIASES.get(name, name))
 
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
+
     def _serialize(self) -> tuple[str, ...]:
         """Get the serialized name."""
 
         return self._color_ids
 
+    def is_achromatic(self, coords: Vector) -> bool | None:  # pragma: no cover
+        """Check if color is achromatic."""
+
+        return None
+
     @classmethod
     def white(cls) -> VectorLike:
         """Get the white color for this color space."""
@@ -166,11 +229,6 @@ def to_string(
             none=none
         )
 
-    def normalize(self, coords: Vector) -> Vector:
-        """Process coordinates and adjust any channels to null/NaN if required."""
-
-        return alg.no_nans(coords)
-
     def match(
         self,
         string: str,
diff --git a/coloraide/spaces/acescct.py b/coloraide/spaces/acescct.py
index b62d68c96..3e04da7f1 100644
--- a/coloraide/spaces/acescct.py
+++ b/coloraide/spaces/acescct.py
@@ -53,9 +53,9 @@ class ACEScct(sRGB):
     SERIALIZE = ("--acescct",)  # type: tuple[str, ...]
     WHITE = (0.32168, 0.33767)
     CHANNELS = (
-        Channel("r", CCT_MIN, CCT_MAX, bound=True),
-        Channel("g", CCT_MIN, CCT_MAX, bound=True),
-        Channel("b", CCT_MIN, CCT_MAX, bound=True)
+        Channel("r", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN),
+        Channel("g", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN),
+        Channel("b", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN)
     )
     DYNAMIC_RANGE = 'hdr'
 
diff --git a/coloraide/spaces/achromatic.py b/coloraide/spaces/achromatic.py
new file mode 100644
index 000000000..c098d99b6
--- /dev/null
+++ b/coloraide/spaces/achromatic.py
@@ -0,0 +1,190 @@
+"""Tools for dynamic achromatic response."""
+from __future__ import annotations
+from .. import algebra as alg
+import bisect
+from typing import Any
+from ..types import Vector, VectorLike
+from abc import ABCMeta, abstractmethod
+import math
+
+
+class Achromatic(metaclass=ABCMeta):
+    """Calculate a spline that follows a color's achromatic response."""
+
+    L_IDX = 0
+    C_IDX = 1
+    H_IDX = 2
+
+    def __init__(
+        self,
+        data: list[Vector] | None = None,
+        threshold_upper: float = float('inf'),
+        threshold_lower: float = float('inf'),
+        threshold_cutoff: float = float('inf'),
+        spline: str = 'linear',
+        mirror: bool = False,
+        **kwargs: Any
+    ) -> None:
+        """
+        Initialize.
+
+        `tuning`: Either a dictionary of `low`, `mid`, and `high`, each specifying a start, end, step, and scale
+            used to build a portion of the spline representing the achromatic response or a list containing the
+            the pre-calculated spline points.
+        `threshold_upper`: threshold above achromatic curve.
+        `threshold_lower`: threshold below achromatic curve.
+        `threshold_cutoff`: threshold of chroma above which we will assume colors are not achromatic.
+        `spline`: spline type.
+        `mirror`: Mirror response across lightness axis at zero.
+        """
+
+        self.mirror = mirror
+        self.threshold_upper = threshold_upper
+        self.threshold_lower = threshold_lower
+        self.threshold_cutoff = threshold_cutoff
+
+        self.domain = []  # type: list[float]
+        self.min_colorfulness = 1e10
+        self.min_lightness = 1e10
+        self.spline_type = spline
+
+        # Create a spline that maps the achromatic range for the SDR range
+        if data is not None:
+            self.setup_achromatic_response(data, **kwargs)
+
+    def dump(self) -> list[VectorLike] | None:  # pragma: no cover
+        """Dump data points."""
+
+        if self.spline_type == 'linear':
+            return list(zip(*self.spline.points))
+        else:
+            # Strip off the data points used to coerce the spline through the end.
+            return list(zip(*self.spline.points))[1:-1]
+
+    @abstractmethod
+    def convert(self, coords: Vector, **kwargs: Any) -> Vector:
+        """Convert an sRGB color to the desired space."""
+
+    def calc_achromatic_response(
+        self,
+        parameters: list[tuple[int, int, int, float]],
+        **kwargs: Any
+    ) -> None:  # pragma: no cover
+        """
+        Calculate the achromatic response.
+
+        Used to precalculate the best response.
+        """
+
+        points = []  # type: list[list[float]]
+        for segment in parameters:
+            start, end, step, scale = segment
+            for p in range(start, end, step):
+                color = self.convert([p / scale] * 3, **kwargs)
+                l, c, h = color[self.L_IDX], color[self.C_IDX], color[self.H_IDX]
+                if l < self.min_lightness:
+                    self.min_lightness = l
+                if c < self.min_colorfulness:
+                    self.min_colorfulness = c
+                self.domain.append(l)
+                points.append([l, c, h % 360])
+        self.spline = alg.interpolate(points, method=self.spline_type)
+        self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360
+        self.ihue = (self.hue - 180) % 360
+
+    def setup_achromatic_response(
+        self,
+        tuning: list[Vector],
+        **kwargs: Any
+    ) -> None:
+        """Setup the achromatic response."""
+
+        points = []  # type: list[list[float]]
+        for entry in tuning:
+            l, c, h = entry
+            if l < self.min_lightness:
+                self.min_lightness = l
+            if c < self.min_colorfulness:
+                self.min_colorfulness = c
+            points.append([l, c, h])
+            self.domain.append(l)
+        self.spline = alg.interpolate(points, method=self.spline_type)
+        self.hue = self.convert([1] * 3, **kwargs)[self.H_IDX] % 360
+        self.ihue = (self.hue - 180) % 360
+
+    def scale(self, point: float) -> float:
+        """Scale the lightness to match the range."""
+
+        if point <= self.domain[0]:
+            point = (point - self.domain[0]) / (self.domain[-1] - self.domain[0])
+        elif point >= self.domain[-1]:
+            point = 1.0 + (point - self.domain[-1]) / (self.domain[-1] - self.domain[0])
+        else:
+            regions = len(self.domain) - 1
+            size = (1 / regions)
+            index = 0
+            adjusted = 0.0
+            index = bisect.bisect(self.domain, point) - 1
+            a, b = self.domain[index:index + 2]
+            l = b - a
+            adjusted = ((point - a) / l) if l else 0.0
+            point = size * index + (adjusted * size)
+        return point
+
+    def get_ideal_chroma(self, l: float) -> float:
+        """Get the ideal chroma."""
+
+        if math.isnan(l):
+            return 0.0
+
+        elif self.mirror and l < 0.0:
+            return self.spline(self.scale(abs(l)))[1]
+
+        return self.spline(self.scale(l))[1]
+
+    def get_ideal_hue(self, l: float) -> float:
+        """Get the ideal chroma."""
+
+        if math.isnan(l):
+            return 0.0
+
+        elif self.mirror and l < 0.0:
+            return (self.spline(self.scale(abs(l)))[2] - 180) % 360
+
+        return self.spline(self.scale(l))[2]
+
+    def get_ideal_ab(self, l: float) -> tuple[float, float]:
+        """Get the ideal rectangular form of chroma and hue, the components a and b."""
+
+        if math.isnan(l):
+            return 0.0, 0.0
+
+        return alg.polar_to_rect(self.get_ideal_chroma(l), self.get_ideal_hue(l))
+
+    def test(self, l: float, c: float, h: float) -> bool:
+        """Test if the current color is achromatic."""
+
+        # If colorfulness is past this limit, we'd have to have a lightness
+        # so high, that our test has already broken down.
+        if c > self.threshold_cutoff or (not self.mirror and l < 0.0):
+            return False
+
+        # If we are higher than 1, we are extrapolating;
+        # otherwise, use the spline.
+        flip = self.mirror and l < 0.0
+        la = abs(l)
+        point = self.scale(la if flip else l)
+        if la < self.min_lightness and c < self.min_colorfulness:  # pragma: no cover
+            return True
+        else:
+            c2, h2 = self.spline(point)[1:]
+            if flip:
+                h2 = (h2 - 180) % 360
+        diff = c2 - c
+        hdiff = abs(h % 360 - h2)
+        if hdiff > 180:  # pragma: no cover
+            hdiff = 360 - hdiff
+        return (
+            ((diff >= 0 and diff < self.threshold_upper) or (diff < 0 and abs(diff) < self.threshold_lower)) and
+            (c2 < 1e-5 or hdiff < 0.01)
+        )
diff --git a/coloraide/spaces/cam16.py b/coloraide/spaces/cam16.py
index 97803ebfa..ece9e1e91 100644
--- a/coloraide/spaces/cam16.py
+++ b/coloraide/spaces/cam16.py
@@ -8,343 +8,13 @@
 """
 from __future__ import annotations
 import math
-import bisect
+from .cam16_jmh import CAM16JMh
 from ..spaces import Space, Labish
 from ..cat import WHITES
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from .. import util
+from ..types import Vector
 from .. import algebra as alg
-from ..types import Vector, VectorLike
-
-# CAT16
-M16 = [
-    [0.401288, 0.650173, -0.051461],
-    [-0.250268, 1.204414, 0.045854],
-    [-0.002079, 0.048952, 0.953127]
-]
-
-MI6_INV = alg.inv(M16)
-
-M1 = [
-    [460.0, 451.0, 288.0],
-    [460.0, -891.0, -261.0],
-    [460.0, -220.0, -6300.0]
-]
-
-ADAPTED_COEF = 0.42
-ADAPTED_COEF_INV = 1 / ADAPTED_COEF
-
-SURROUND = {
-    'dark': (0.8, 0.525, 0.8),
-    'dim': (0.9, 0.59, 0.9),
-    'average': (1, 0.69, 1)
-}
-
-HUE_QUADRATURE = {
-    # Red, Yellow, Green, Blue, Red
-    "h": (20.14, 90.00, 164.25, 237.53, 380.14),
-    "e": (0.8, 0.7, 1.0, 1.2, 0.8),
-    "H": (0.0, 100.0, 200.0, 300.0, 400.0)
-}
-
-
-def hue_quadrature(h: float) -> float:
-    """
-    Hue to hue quadrature.
-
-    https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324
-    """
-
-    hp = util.constrain_hue(h)
-    if hp <= HUE_QUADRATURE['h'][0]:
-        hp += 360
-
-    i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1
-    hi, hii = HUE_QUADRATURE['h'][i:i + 2]
-    ei, eii = HUE_QUADRATURE['e'][i:i + 2]
-    Hi = HUE_QUADRATURE['H'][i]
-
-    t = (hp - hi) / ei
-    return Hi + (100 * t) / (t + (hii - hp) / eii)
-
-
-def inv_hue_quadrature(H: float) -> float:
-    """Hue quadrature to hue."""
-
-    Hp = (H % 400 + 400) % 400
-    i = math.floor(0.01 * Hp)
-    Hp = Hp % 100
-    hi, hii = HUE_QUADRATURE['h'][i:i + 2]
-    ei, eii = HUE_QUADRATURE['e'][i:i + 2]
-
-    return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii))
-
-
-def adapt(coords: Vector, fl: float) -> Vector:
-    """Adapt the coordinates."""
-
-    adapted = []
-    for c in coords:
-        x = alg.npow(fl * c * 0.01, ADAPTED_COEF)
-        adapted.append(400 * math.copysign(x, c) / (x + 27.13))
-    return adapted
-
-
-def unadapt(adapted: Vector, fl: float) -> Vector:
-    """Remove adaptation from coordinates."""
-
-    coords = []
-    constant = 100 / fl * math.pow(27.13, ADAPTED_COEF_INV)
-    for c in adapted:
-        cabs = abs(c)
-        coords.append(math.copysign(constant * alg.npow(cabs / (400 - cabs), ADAPTED_COEF_INV), c))
-    return coords
-
-
-class Environment:
-    """
-    Class to calculate and contain any required environmental data (viewing conditions included).
-
-    Usage Guidelines for CIECAM97s (Nathan Moroney)
-    https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf
-
-    ref_white: The reference white XYZ. We assume XYZ is in the range 0 - 1 as that is how ColorAide
-        handles XYZ everywhere else. It will be scaled up to 0 - 100.
-
-    adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2.
-        The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance,
-        and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1.
-        For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%).
-        This results in `La = E / π * 0.2`.
-
-    background_luminance: The background is the region immediately surrounding the stimulus and
-        for images is the neighboring portion of the image. Generally, this value is set to a value of 20.
-        This implicitly assumes a gray world assumption.
-
-    surround: The surround is categorical and is defined based on the relationship between the relative
-        luminance of the surround and the luminance of the scene or image white. While there are 4 defined
-        surrounds, usually just `average`, `dim`, and `dark` are used.
-
-        Dark    | 0%        | Viewing film projected in a dark room
-        Dim     | 0% to 20% | Viewing television
-        Average | > 20%     | Viewing surface colors
-
-    discounting: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted.
-    """
-
-    def __init__(
-        self,
-        ref_white: VectorLike,
-        adapting_luminance: float,
-        background_luminance: float,
-        surround: str,
-        discounting: bool
-    ):
-        """
-        Initialize environmental viewing conditions.
-
-        Using the specified viewing conditions, and general environmental data,
-        initialize anything that we can ahead of time to speed up the process.
-        """
-
-        self.discounting = discounting
-        self.ref_white = util.xy_to_xyz(ref_white)
-        self.surround = surround
-        xyz_w = alg.multiply(self.ref_white, 100, dims=alg.D1_SC)
-
-        # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits)
-        self.la = adapting_luminance
-        # The relative luminance of the nearby background
-        self.yb = background_luminance
-        # Absolute luminance of the reference white.
-        yw = xyz_w[1]
-
-        # Cone response for reference white
-        rgb_w = alg.dot(M16, xyz_w)
-
-        # Surround: dark, dim, and average
-        f, self.c, self.nc = SURROUND[self.surround]
-
-        k = 1 / (5 * self.la + 1)
-        k4 = k ** 4
-
-        # Factor of luminance level adaptation
-        self.fl = (k4 * self.la + 0.1 * (1 - k4) * (1 - k4) * math.pow(5 * self.la, 1 / 3))
-        self.fl_root = math.pow(self.fl, 0.25)
-
-        self.n = self.yb / yw
-        self.z = 1.48 + math.sqrt(self.n)
-        self.nbb = 0.725 * math.pow(self.n, -0.2)
-        self.ncb = self.nbb
-
-        # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted)
-        d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not discounting else 1
-        self.d_rgb = [alg.lerp(1, yw / coord, d) for coord in rgb_w]
-        self.d_rgb_inv = [1 / coord for coord in self.d_rgb]
-
-        # Achromatic response
-        rgb_cw = alg.multiply(rgb_w, self.d_rgb, dims=alg.D1)
-        rgb_aw = adapt(rgb_cw, self.fl)
-        self.a_w = self.nbb * (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2])
-
-
-def cam16_to_xyz_d65(
-    J: float | None = None,
-    C: float | None = None,
-    h: float | None = None,
-    s: float | None = None,
-    Q: float | None = None,
-    M: float | None = None,
-    H: float | None = None,
-    env: Environment | None = None
-) -> Vector:
-    """
-    From CAM16 to XYZ.
-
-    Reverse calculation can actually be obtained from a small subset of the CAM16 components
-    Really, only one suitable value is needed for each type of attribute: (lightness/brightness),
-    (chroma/colorfulness/saturation), (hue/hue quadrature). If more than one for a given
-    category is given, we will fail as we have no idea which is the right one to use. Also,
-    if none are given, we must fail as well as there is nothing to calculate with.
-    """
-
-    # These check ensure one, and only one attribute for a given category is provided.
-    # Unfortunately, `mypy` is not smart enough to tell which one is not `None`,
-    # so we have to we must test each again later, but it is still faster than calling `cast()`.
-    if not ((J is not None) ^ (Q is not None)):
-        raise ValueError("Conversion requires one and only one: 'J' or 'Q'")
-
-    if not ((C is not None) ^ (M is not None) ^ (s is not None)):
-        raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'")
-
-    # Hue is absolutely required
-    if not ((h is not None) ^ (H is not None)):
-        raise ValueError("Conversion requires one and only one: 'h' or 'H'")
-
-    # We need viewing conditions
-    if env is None:
-        raise ValueError("No viewing conditions/environment provided")
-
-    # Black
-    if J == 0.0 or Q == 0.0:
-        return [0.0, 0.0, 0.0]
-
-    # Break hue into Cartesian components
-    h_rad = 0.0
-    if h is not None:
-        h_rad = math.radians(h)
-    elif H is not None:
-        h_rad = math.radians(inv_hue_quadrature(H))
-    cos_h = math.cos(h_rad)
-    sin_h = math.sin(h_rad)
-
-    # Calculate `J_root` from one of the lightness derived coordinates.
-    J_root = 0.0
-    if J is not None:
-        J_root = alg.nth_root(J, 2) * 0.1
-    elif Q is not None:
-        J_root = 0.25 * env.c * Q / ((env.a_w + 4) * env.fl_root)
-
-    # Calculate the `t` value from one of the chroma derived coordinates
-    alpha = 0.0
-    if C is not None:
-        alpha = C / J_root
-    elif M is not None:
-        alpha = (M / env.fl_root) / J_root
-    elif s is not None:
-        alpha = 0.0004 * (s ** 2) * (env.a_w + 4) / env.c
-    t = alg.npow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9)
-
-    # Eccentricity
-    et = 0.25 * (math.cos(h_rad + 2) + 3.8)
-
-    # Achromatic response
-    A = env.a_w * alg.npow(J_root, 2 / env.c / env.z)
-
-    # Calculate red-green and yellow-blue components
-    p1 = 5e4 / 13 * env.nc * env.ncb * et
-    p2 = A / env.nbb
-    r = 23 * (p2 + 0.305) * t / (23 * p1 + t * (11 * cos_h + 108 * sin_h))
-    a = r * cos_h
-    b = r * sin_h
-
-    # Calculate back from cone response to XYZ
-    rgb_c = unadapt(alg.multiply(alg.dot(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl)
-    return alg.divide(
-        alg.dot(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1),
-        100,
-        dims=alg.D1_SC
-    )
-
-
-def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector:
-    """From XYZ to CAM16."""
-
-    # Cone response
-    rgb_a = adapt(
-        alg.multiply(
-            alg.dot(M16, alg.multiply(xyzd65, 100, dims=alg.D1_SC), dims=alg.D2_D1),
-            env.d_rgb,
-            dims=alg.D1
-        ),
-        env.fl
-    )
-
-    # Red-green and yellow-blue components
-    a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11
-    b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9
-    h_rad = math.atan2(b, a)
-
-    # Eccentricity
-    et = 0.25 * (math.cos(h_rad + 2) + 3.8)
-
-    t = (
-        5e4 / 13 * env.nc * env.ncb * et * math.sqrt(a ** 2 + b ** 2) /
-        (rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305)
-    )
-    alpha = alg.npow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73)
-
-    # Achromatic response
-    A = env.nbb * (2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2])
-
-    J_root = alg.npow(A / env.a_w, 0.5 * env.c * env.z)
-
-    # Lightness
-    J = 100 * alg.npow(J_root, 2)
-
-    # Brightness
-    Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root)
-
-    # Chroma
-    C = alpha * J_root
-
-    # Colorfulness
-    M = C * env.fl_root
-
-    # Hue
-    h = util.constrain_hue(math.degrees(h_rad))
-
-    # Hue quadrature
-    H = hue_quadrature(h)
-
-    # Saturation
-    s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2)
-
-    return [J, C, h, s, Q, M, H]
-
-
-def xyz_d65_to_cam16_jmh(xyzd65: Vector, env: Environment) -> Vector:
-    """XYZ to CAM16 JMh."""
-
-    cam16 = xyz_d65_to_cam16(xyzd65, env)
-    return [cam16[0], cam16[5], cam16[2]]
-
-
-def cam16_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector:
-    """CAM16 JMh to XYZ."""
-
-    J, M, h = jmh
-    return cam16_to_xyz_d65(J=J, M=M, h=h, env=env)
 
 
 def cam16_jmh_to_cam16_jab(jmh: Vector) -> Vector:
@@ -362,32 +32,16 @@ def cam16_jab_to_cam16_jmh(jab: Vector) -> Vector:
     """Translate a CAM16 Jab to JMh of the same viewing conditions."""
 
     J, a, b = jab
-    if J <= 0.0:
-        J = a = b = 0.0
     M = math.sqrt(a ** 2 + b ** 2)
     h = math.degrees(math.atan2(b, a))
 
     return [J, M, util.constrain_hue(h)]
 
 
-def xyz_d65_to_cam16_jab(xyzd65: Vector, env: Environment) -> Vector:
-    """XYZ to CAM16 Jab."""
-
-    jmh = xyz_d65_to_cam16_jmh(xyzd65, env)
-    return cam16_jmh_to_cam16_jab(jmh)
-
-
-def cam16_jab_to_xyz_d65(jab: Vector, env: Environment) -> Vector:
-    """CAM16 Jab to XYZ."""
-
-    jmh = cam16_jab_to_cam16_jmh(jab)
-    return cam16_jmh_to_xyz_d65(jmh, env=env)
-
-
 class CAM16(Labish, Space):
     """CAM16 class (Jab)."""
 
-    BASE = "xyz-d65"
+    BASE = "cam16-jmh"
     NAME = "cam16"
     SERIALIZE = ("--cam16",)
     CHANNELS = (
@@ -399,15 +53,34 @@ class CAM16(Labish, Space):
         "lightness": "j"
     }
     WHITE = WHITES['2deg']['D65']
-    # Assuming sRGB which has a lux of 64
-    ENV = Environment(WHITE, 64 / math.pi * 0.2, 20, 'average', False)
+    # Use the same environment as CAM16JMh
+    ENV = CAM16JMh.ENV
+    ACHROMATIC = CAM16JMh.ACHROMATIC
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
+
+        if index in (1, 2):
+            if not math.isnan(coords[index]):
+                return coords[index]
+
+            return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1]
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
+
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        m, h = alg.rect_to_polar(coords[1], coords[2])
+        return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], m, h)
 
     def to_base(self, coords: Vector) -> Vector:
-        """To XYZ from CAM16."""
+        """To CAM16 JMh from CAM16."""
 
-        return cam16_jab_to_xyz_d65(coords, env=self.ENV)
+        return cam16_jab_to_cam16_jmh(coords)
 
     def from_base(self, coords: Vector) -> Vector:
-        """From XYZ to CAM16."""
+        """From CAM16 JMh to CAM16."""
 
-        return xyz_d65_to_cam16_jab(coords, env=self.ENV)
+        return cam16_jmh_to_cam16_jab(coords)
diff --git a/coloraide/spaces/cam16_jmh.py b/coloraide/spaces/cam16_jmh.py
index 159bc7bb1..8f7434848 100644
--- a/coloraide/spaces/cam16_jmh.py
+++ b/coloraide/spaces/cam16_jmh.py
@@ -7,200 +7,580 @@
 https://doi.org/10.1002/col.22131
 """
 from __future__ import annotations
+import math
 import bisect
+from .. import util
+from .. import algebra as alg
 from ..spaces import Space, LChish
-from .cam16 import Environment, CAM16, xyz_d65_to_cam16_jmh, cam16_jab_to_cam16_jmh, cam16_jmh_to_cam16_jab
-from .srgb_linear import lin_srgb_to_xyz
-from .srgb import lin_srgb
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .. import algebra as alg
-from ..types import Vector
-
-
-class Achromatic:
+from .achromatic import Achromatic as _Achromatic
+from .srgb_linear import lin_srgb_to_xyz
+from .srgb import lin_srgb
+from ..types import Vector, VectorLike
+from typing import Any
+
+# CAT16
+M16 = [
+    [0.401288, 0.650173, -0.051461],
+    [-0.250268, 1.204414, 0.045854],
+    [-0.002079, 0.048952, 0.953127]
+]
+
+MI6_INV = alg.inv(M16)
+
+M1 = [
+    [460.0, 451.0, 288.0],
+    [460.0, -891.0, -261.0],
+    [460.0, -220.0, -6300.0]
+]
+
+ADAPTED_COEF = 0.42
+ADAPTED_COEF_INV = 1 / ADAPTED_COEF
+
+SURROUND = {
+    'dark': (0.8, 0.525, 0.8),
+    'dim': (0.9, 0.59, 0.9),
+    'average': (1, 0.69, 1)
+}
+
+HUE_QUADRATURE = {
+    # Red, Yellow, Green, Blue, Red
+    "h": (20.14, 90.00, 164.25, 237.53, 380.14),
+    "e": (0.8, 0.7, 1.0, 1.2, 0.8),
+    "H": (0.0, 100.0, 200.0, 300.0, 400.0)
+}
+
+ACHROMATIC_RESPONSE = [
+    [0.5197637353103578, 0.10742101902546783, 209.53702017876432],
+    [0.7653410752577902, 0.1428525220483835, 209.53699586564701],
+    [0.9597225272459006, 0.1674639325209385, 209.5369779985614],
+    [1.1268800051398011, 0.18685008110835863, 209.5369633468102],
+    [1.2763267654963262, 0.2030639541700499, 209.53695070009815],
+    [1.8790377054651906, 0.2606154320772664, 209.53690294574977],
+    [2.3559753209404573, 0.2998668559412108, 209.53686786031403],
+    [2.7660324716814633, 0.3305058430743194, 209.536839093849],
+    [3.1325805945142164, 0.3559891904115732, 209.53681426762876],
+    [3.4678031173571573, 0.37799038189311784, 209.53679218878383],
+    [3.77902862541972, 0.3974595006625645, 209.53677216077193],
+    [4.071081036208187, 0.41499276041597405, 209.53675373607226],
+    [4.35987940988476, 0.4317047383578854, 209.53673583657556],
+    [4.653923368359191, 0.44814375233486375, 209.53671791170922],
+    [7.833618587426542, 0.5996289140723938, 209.53653852944092],
+    [11.379933581738413, 0.73419775817062, 209.5363590374096],
+    [15.226965773377025, 0.8572590366765755, 209.53617955383942],
+    [19.331425543357224, 0.9717687075452716, 209.53600014937],
+    [23.662223781347393, 1.0795574256327514, 209.5358208710024],
+    [28.195712492409086, 1.181856050998015, 209.5356417521982],
+    [32.91316356733728, 1.2795417603936534, 209.53546281794735],
+    [37.799294675334956, 1.3732674055098613, 209.53528408756415],
+    [42.841343691205466, 1.4635354939944947, 209.5351055763844],
+    [48.028455187972774, 1.5507433232034882, 209.53492729681835],
+    [53.35125605180922, 1.6352119585004592, 209.53474925909882],
+    [58.80155160494701, 1.7172056265103606, 209.53457147176655],
+    [64.37210171522501, 1.7969451471063824, 209.5343939420231],
+    [70.05645182832635, 1.8746175094579132, 209.53421667601953],
+    [75.8488028092011, 1.95038286970608, 209.53403967901312],
+    [81.74390888832978, 2.0243797747220165, 209.53386295555885],
+    [87.73699639870503, 2.0967291349022923, 209.53368650960735],
+    [93.82369818202439, 2.1675372954936383, 209.53351034459058],
+    [100.0, 2.2368984457707195, 209.5333344635329],
+    [106.26219627903436, 2.304896533525034, 209.53315886906827],
+    [112.60685320678104, 2.3716068043005607, 209.5329835635155],
+    [119.03077768851209, 2.437097052049621, 209.53280854892182],
+    [125.53099102420309, 2.501428645093027, 209.53263382708093],
+    [132.1047064258276, 2.5646573751376196, 209.532459399577],
+    [138.74930968646495, 2.6268341655120837, 209.53228526779512],
+    [145.46234245739691, 2.688005666324115, 209.5321114329661],
+    [152.24148769946316, 2.748214758003532, 209.5319378961533],
+    [159.08455695969053, 2.807500980008346, 209.53176465830455],
+    [165.98947919010237, 2.865900897958051, 209.53159172021918],
+    [172.95429087732893, 2.92344841973748, 209.53141908260915],
+    [179.97712729256875, 2.980175069050156, 209.531246746073],
+    [187.05621470411134, 3.0361102232687336, 209.5310747111302],
+    [194.18986342088985, 3.0912813211607015, 209.53090297820907],
+    [201.3764615567875, 3.145714045061113, 209.5307315476577],
+    [208.6144694227416, 3.199432481261756, 209.53056041976922],
+    [215.90241446789472, 3.252459261745779, 209.5303895947692],
+    [223.23888670275272, 3.3048156898733514, 209.53021907281337],
+    [230.62253454702386, 3.356521852200883, 209.53004885402044],
+    [238.05206105290918, 3.4075967182797573, 209.52987893845008],
+    [245.52622046139462, 3.4580582299851543, 209.52970932613027],
+    [253.04381505480674, 3.5079233817057034, 209.52954001701477],
+    [260.6036922737095, 3.5572082925096393, 209.52937101105175],
+    [268.20474207032214, 3.605928271271525, 209.52920230813686],
+    [275.8458944741247, 3.654097875574637, 209.52903390813748],
+    [283.52611734829725, 3.7017309651178785, 209.52886581088453],
+    [291.24441431821003, 3.748840750239339, 209.52869801618866],
+    [298.9998228553806, 3.795439836103318, 209.52853052381926],
+    [306.7914125022303, 3.841540263013781, 209.52836333353144],
+    [314.61828322461554, 3.8871535432700086, 209.52819644505487],
+    [322.479563880563, 3.9322906949207233, 209.52802985809194],
+    [330.37441079487513, 3.9769622727358533, 209.5278635723298],
+    [338.30200643038665, 4.021178396670985, 209.52769758743247],
+    [346.2615581476034, 4.064948778071929, 209.52753190304955],
+    [354.2522970453064, 4.108282743840222, 209.52736651881136],
+    [362.2734768754491, 4.151189258746347, 209.52720143432805],
+    [370.32437302633326, 4.1936769460683285, 209.52703664919864],
+    [378.40428156863675, 4.235754106704258, 209.52687216301322],
+    [386.51251835937717, 4.277428736903083, 209.5267079753345],
+    [394.648418199364, 4.3187085447228295, 209.52654408572644],
+    [402.8113340400954, 4.359600965341976, 209.52638049373465],
+    [411.00063623642933, 4.400113175309799, 209.52621719889768],
+    [419.2157118416773, 4.440252105831943, 209.52605420073294],
+    [427.45596394207547, 4.480024455165198, 209.52589149876238],
+    [435.7208110278373, 4.519436700202084, 209.52572909248678],
+    [444.00968639824373, 4.558495107300929, 209.52556698141063],
+    [452.32203759843054, 4.597205742430248, 209.5254051650081],
+    [460.65732588572826, 4.635574480668458, 209.52524364277477],
+    [469.01502572358646, 4.67360701512636, 209.52508241417655],
+    [477.39462430127537, 4.711308865316018, 209.52492147868517],
+    [485.79562107768834, 4.7486853850221635, 209.5247608357571],
+    [494.2175273477133, 4.7857417697075455, 209.52460048484275],
+    [502.65986582975273, 4.822483063478878, 209.52444042540557],
+    [511.12217027307815, 4.858914165665327, 209.52428065686505],
+    [519.603985083808, 4.895039837005332, 209.52412117867837],
+    [528.1048649683817, 4.9308647055023425, 209.52396199027322],
+    [536.6243745934894, 4.966393271948785, 209.52380309107085],
+    [545.162088261489, 5.001629915149562, 209.52364448049883],
+    [553.7175896004151, 5.036578896863699, 209.52348615798226],
+    [562.2904712677357, 5.071244366487241, 209.52332812293528],
+    [570.8803346670916, 5.105630365484719, 209.52317037476547],
+    [579.4867896772784, 5.139740831594337, 209.5230129128839],
+    [588.1094543928114, 5.173579602816207, 209.52285573670093],
+    [596.7479548754266, 5.20715042120039, 209.5226988456209],
+    [605.4019249159434, 5.240456936444621, 209.52254223903395],
+    [614.0710058059254, 5.273502709313967, 209.5223859163477],
+    [622.7548461186354, 5.306291214893902, 209.52222987695998],
+    [631.4531014987923, 5.338825845688801, 209.52207412025294],
+    [640.1654344606808, 5.371109914568736, 209.52191864562857],
+    [648.8915141941957, 5.403146657580354, 209.52176345246957],
+    [657.6310163784137, 5.434939236625218, 209.52160854017026],
+    [666.3836230023238, 5.466490742017985, 209.52145390810907],
+    [675.1490221923619, 5.497804194921783, 209.5212995556808],
+    [683.9269080464271, 5.528882549682189, 209.52114548225762],
+    [692.7169804740557, 5.559728696047723, 209.52099168723132],
+    [701.5189450424705, 5.590345461302892, 209.5208381699748],
+    [710.3325128282281, 5.620735612298761, 209.52068492987144],
+    [719.1574002741951, 5.6509018574012, 209.52053196630135],
+    [727.9933290516212, 5.680846848355491, 209.52037927864566],
+    [736.8400259270661, 5.710573182071791, 209.52022686627365],
+    [745.6972226339658, 5.74008340233323, 209.5200747285655],
+    [754.5646557486314, 5.769380001437249, 209.51992286490193],
+    [763.4420665704857, 5.79846542176912, 209.51977127465065],
+    [772.3292010063437, 5.827342057308235, 209.519619957194]]  # type: list[Vector]
+
+
+class Achromatic(_Achromatic):
     """
     Test if color is achromatic.
 
     Should work quite well through the SDR range. Can reasonably handle HDR range out to 3
     which is far enough for anything practical.
-
     We use a spline mainly to quickly fit the line in a way we do not have to analyze and tune.
     """
 
-    CONVERTER = staticmethod(xyz_d65_to_cam16_jmh)
     L_IDX = 0
     C_IDX = 1
     H_IDX = 2
 
     def __init__(
         self,
-        tuning: dict[str, tuple[int, int, int, float]],
-        threshold: float,
+        data: list[Vector] | None = None,
+        threshold_upper: float = 0.0,
+        threshold_lower: float = 0.0,
+        threshold_cutoff: float = float('inf'),
+        spline: str = 'linear',
+        mirror: bool = False,
+        *,
         env: Environment,
-        spline: str
+        **kwargs: Any
     ) -> None:
         """Initialize."""
 
-        self.threshold = threshold
-
-        # Create a spline that maps the achromatic range for the SDR range
-        points = []  # type: list[list[float]]
-        self.domain = []  # type: list[float]
-        self.max_colorfulness = 1e-10
-        self.spline = None
-        if not env.discounting:
-            self.iter_achromatic_response(env, points, *tuning['low'])
-            self.iter_achromatic_response(env, points, *tuning['mid'])
-            self.iter_achromatic_response(env, points, *tuning['high'])
-            self.max_colorfulness = round(self.max_colorfulness, 3) + 1
-            self.spline = alg.interpolate(points, method=spline)
-        # Transform seems to favor a particular achromatic hue, capture the one at white
-        # to replace achromatic NaN hues with.
-        self.hue = self.CONVERTER(lin_srgb_to_xyz(lin_srgb([1] * 3)), env)[self.H_IDX]
-
-    def iter_achromatic_response(
+        super().__init__(data, threshold_upper, threshold_lower, threshold_cutoff, spline, mirror, env=env, **kwargs)
+
+    def calc_achromatic_response(  # type: ignore[override]
         self,
+        parameters: list[tuple[int, int, int, float]],
+        *,
         env: Environment,
-        points: list[list[float]],
-        start: int,
-        end: int,
-        step: int,
-        scale: float
-    ) -> None:
+        **kwargs: Any
+    ) -> None:  # pragma: no cover
+        """Precalculate the achromatic response."""
+
+        super().calc_achromatic_response(parameters, env=env, **kwargs)
+
+    def convert(self, coords: Vector, *, env: Environment, **kwargs: Any) -> Vector:  # type: ignore[override]
+        """Convert to the target color space."""
+
+        return xyz_d65_to_cam16_jmh(lin_srgb_to_xyz(lin_srgb(coords)), env)
+
+
+def hue_quadrature(h: float) -> float:
+    """
+    Hue to hue quadrature.
+
+    https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324
+    """
+
+    hp = util.constrain_hue(h)
+    if hp <= HUE_QUADRATURE['h'][0]:
+        hp += 360
+
+    i = bisect.bisect_left(HUE_QUADRATURE['h'], hp) - 1
+    hi, hii = HUE_QUADRATURE['h'][i:i + 2]
+    ei, eii = HUE_QUADRATURE['e'][i:i + 2]
+    Hi = HUE_QUADRATURE['H'][i]
+
+    t = (hp - hi) / ei
+    return Hi + (100 * t) / (t + (hii - hp) / eii)
+
+
+def inv_hue_quadrature(H: float) -> float:
+    """Hue quadrature to hue."""
+
+    Hp = (H % 400 + 400) % 400
+    i = math.floor(0.01 * Hp)
+    Hp = Hp % 100
+    hi, hii = HUE_QUADRATURE['h'][i:i + 2]
+    ei, eii = HUE_QUADRATURE['e'][i:i + 2]
+
+    return util.constrain_hue((Hp * (eii * hi - ei * hii) - 100 * hi * eii) / (Hp * (eii - ei) - 100 * eii))
+
+
+def adapt(coords: Vector, fl: float) -> Vector:
+    """Adapt the coordinates."""
+
+    adapted = []
+    for c in coords:
+        x = alg.npow(fl * c * 0.01, ADAPTED_COEF)
+        adapted.append(400 * math.copysign(x, c) / (x + 27.13))
+    return adapted
+
+
+def unadapt(adapted: Vector, fl: float) -> Vector:
+    """Remove adaptation from coordinates."""
+
+    coords = []
+    constant = 100 / fl * math.pow(27.13, ADAPTED_COEF_INV)
+    for c in adapted:
+        cabs = abs(c)
+        coords.append(math.copysign(constant * alg.npow(cabs / (400 - cabs), ADAPTED_COEF_INV), c))
+    return coords
+
+
+class Environment:
+    """
+    Class to calculate and contain any required environmental data (viewing conditions included).
+
+    Usage Guidelines for CIECAM97s (Nathan Moroney)
+    https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf
+
+    ref_white: The reference white XYZ. We assume XYZ is in the range 0 - 1 as that is how ColorAide
+        handles XYZ everywhere else. It will be scaled up to 0 - 100.
+
+    adapting_luminance: This is the the luminance of the adapting field. The units are in cd/m2.
+        The equation is `L = (E * R) / π`, where `E` is the illuminance in lux, `R` is the reflectance,
+        and `L` is the luminance. If we assume a perfectly reflecting diffuser, `R` is assumed as 1.
+        For the "gray world" assumption, we must also divide by 5 (or multiply by 0.2 - 20%).
+        This results in `La = E / π * 0.2`.
+
+    background_luminance: The background is the region immediately surrounding the stimulus and
+        for images is the neighboring portion of the image. Generally, this value is set to a value of 20.
+        This implicitly assumes a gray world assumption.
+
+    surround: The surround is categorical and is defined based on the relationship between the relative
+        luminance of the surround and the luminance of the scene or image white. While there are 4 defined
+        surrounds, usually just `average`, `dim`, and `dark` are used.
+
+        Dark    | 0%        | Viewing film projected in a dark room
+        Dim     | 0% to 20% | Viewing television
+        Average | > 20%     | Viewing surface colors
+
+    discounting: Whether we are discounting the illuminance. Done when eye is assumed to be fully adapted.
+    """
+
+    def __init__(
+        self,
+        ref_white: VectorLike,
+        adapting_luminance: float,
+        background_luminance: float,
+        surround: str,
+        discounting: bool
+    ):
         """
-        Iterate the achromatic response of the space.
+        Initialize environmental viewing conditions.
 
-        Save points of lightness vs colorfulness. Also, track the domain.
+        Using the specified viewing conditions, and general environmental data,
+        initialize anything that we can ahead of time to speed up the process.
         """
 
-        for p in range(start, end, step):
-            c = self.CONVERTER(lin_srgb_to_xyz(lin_srgb([p / scale] * 3)), env)
-            j, m = c[self.L_IDX], c[self.C_IDX]
-            if m > self.max_colorfulness:
-                self.max_colorfulness = m
-            self.domain.append(j)
-            points.append([j, m])
-
-    def scale(self, point: float) -> float:
-        """Scale the lightness to match the range."""
-
-        if point <= self.domain[0]:
-            point = (point - self.domain[0]) / (self.domain[-1] - self.domain[0])
-        elif point >= self.domain[-1]:
-            point = 1.0 + (point - self.domain[-1]) / (self.domain[-1] - self.domain[0])
-        else:
-            regions = len(self.domain) - 1
-            size = (1 / regions)
-            index = 0
-            adjusted = 0.0
-            index = bisect.bisect(self.domain, point) - 1
-            a, b = self.domain[index:index + 2]
-            l = b - a
-            adjusted = ((point - a) / l) if l else 0.0
-            point = size * index + (adjusted * size)
-        return point
-
-    def get_ideal_chroma(self, j: float, m: float) -> float:
-        """Get the ideal chroma."""
-
-        if j < 0.0:  # pragma: no cover
-            return 0.0
-
-        if self.spline is not None:
-            point = self.scale(j)
-            m2 = self.spline(point)[1]
-            if abs(m2 - m) < self.threshold:
-                return m
-            elif m < m2:
-                return m2
-
-        # This would be for `discounting=True`,
-        # which we do not run with currently.
-        return m  # pragma: no cover
-
-    def test(self, j: float, m: float) -> bool:
-        """Test if the current color is achromatic."""
-
-        # If colorfulness is past this limit, we'd have to have a lightness
-        # so high, that our test has already broken down.
-        if m > self.max_colorfulness:
-            return False
-
-        # This is for when "discounting" as colorfulness should be very near zero
-        if self.spline is None:  # pragma: no cover
-            return True
-
-        # If we are higher than 1, we are extrapolating;
-        # otherwise, use the spline.
-        point = self.scale(j)
-        if point < 0:  # pragma: no cover
-            m2 = 0.0
-        else:
-            m2 = self.spline(point)[1]
-        return m < m2 or abs(m2 - m) < self.threshold
+        self.discounting = discounting
+        self.ref_white = util.xy_to_xyz(ref_white)
+        self.surround = surround
+        xyz_w = alg.multiply(self.ref_white, 100, dims=alg.D1_SC)
+
+        # The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits)
+        self.la = adapting_luminance
+        # The relative luminance of the nearby background
+        self.yb = background_luminance
+        # Absolute luminance of the reference white.
+        yw = xyz_w[1]
+
+        # Cone response for reference white
+        rgb_w = alg.dot(M16, xyz_w)
+
+        # Surround: dark, dim, and average
+        f, self.c, self.nc = SURROUND[self.surround]
+
+        k = 1 / (5 * self.la + 1)
+        k4 = k ** 4
+
+        # Factor of luminance level adaptation
+        self.fl = (k4 * self.la + 0.1 * (1 - k4) * (1 - k4) * math.pow(5 * self.la, 1 / 3))
+        self.fl_root = math.pow(self.fl, 0.25)
+
+        self.n = self.yb / yw
+        self.z = 1.48 + math.sqrt(self.n)
+        self.nbb = 0.725 * math.pow(self.n, -0.2)
+        self.ncb = self.nbb
+
+        # Degree of adaptation calculating if not discounting illuminant (assumed eye is fully adapted)
+        d = alg.clamp(f * (1 - 1 / 3.6 * math.exp((-self.la - 42) / 92)), 0, 1) if not discounting else 1
+        self.d_rgb = [alg.lerp(1, yw / coord, d) for coord in rgb_w]
+        self.d_rgb_inv = [1 / coord for coord in self.d_rgb]
+
+        # Achromatic response
+        rgb_cw = alg.multiply(rgb_w, self.d_rgb, dims=alg.D1)
+        rgb_aw = adapt(rgb_cw, self.fl)
+        self.a_w = self.nbb * (2 * rgb_aw[0] + rgb_aw[1] + 0.05 * rgb_aw[2])
+
+
+def cam16_to_xyz_d65(
+    J: float | None = None,
+    C: float | None = None,
+    h: float | None = None,
+    s: float | None = None,
+    Q: float | None = None,
+    M: float | None = None,
+    H: float | None = None,
+    env: Environment | None = None
+) -> Vector:
+    """
+    From CAM16 to XYZ.
+
+    Reverse calculation can actually be obtained from a small subset of the CAM16 components
+    Really, only one suitable value is needed for each type of attribute: (lightness/brightness),
+    (chroma/colorfulness/saturation), (hue/hue quadrature). If more than one for a given
+    category is given, we will fail as we have no idea which is the right one to use. Also,
+    if none are given, we must fail as well as there is nothing to calculate with.
+    """
+
+    # These check ensure one, and only one attribute for a given category is provided.
+    # Unfortunately, `mypy` is not smart enough to tell which one is not `None`,
+    # so we have to we must test each again later, but it is still faster than calling `cast()`.
+    if not ((J is not None) ^ (Q is not None)):
+        raise ValueError("Conversion requires one and only one: 'J' or 'Q'")
+
+    if not ((C is not None) ^ (M is not None) ^ (s is not None)):
+        raise ValueError("Conversion requires one and only one: 'C', 'M' or 's'")
+
+    # Hue is absolutely required
+    if not ((h is not None) ^ (H is not None)):
+        raise ValueError("Conversion requires one and only one: 'h' or 'H'")
+
+    # We need viewing conditions
+    if env is None:
+        raise ValueError("No viewing conditions/environment provided")
+
+    # Black
+    if J == 0.0 or Q == 0.0:
+        return [0.0, 0.0, 0.0]
+
+    # Break hue into Cartesian components
+    h_rad = 0.0
+    if h is not None:
+        h_rad = math.radians(h)
+    elif H is not None:
+        h_rad = math.radians(inv_hue_quadrature(H))
+    cos_h = math.cos(h_rad)
+    sin_h = math.sin(h_rad)
+
+    # Calculate `J_root` from one of the lightness derived coordinates.
+    J_root = 0.0
+    if J is not None:
+        J_root = alg.nth_root(J, 2) * 0.1
+    elif Q is not None:
+        J_root = 0.25 * env.c * Q / ((env.a_w + 4) * env.fl_root)
+
+    # Calculate the `t` value from one of the chroma derived coordinates
+    alpha = 0.0
+    if C is not None:
+        alpha = C / J_root
+    elif M is not None:
+        alpha = (M / env.fl_root) / J_root
+    elif s is not None:
+        alpha = 0.0004 * (s ** 2) * (env.a_w + 4) / env.c
+    t = alg.npow(alpha * math.pow(1.64 - math.pow(0.29, env.n), -0.73), 10 / 9)
+
+    # Eccentricity
+    et = 0.25 * (math.cos(h_rad + 2) + 3.8)
+
+    # Achromatic response
+    A = env.a_w * alg.npow(J_root, 2 / env.c / env.z)
+
+    # Calculate red-green and yellow-blue components
+    p1 = 5e4 / 13 * env.nc * env.ncb * et
+    p2 = A / env.nbb
+    r = 23 * (p2 + 0.305) * t / (23 * p1 + t * (11 * cos_h + 108 * sin_h))
+    a = r * cos_h
+    b = r * sin_h
+
+    # Calculate back from cone response to XYZ
+    rgb_c = unadapt(alg.multiply(alg.dot(M1, [p2, a, b], dims=alg.D2_D1), 1 / 1403, dims=alg.D1_SC), env.fl)
+    return alg.divide(
+        alg.dot(MI6_INV, alg.multiply(rgb_c, env.d_rgb_inv, dims=alg.D1), dims=alg.D2_D1),
+        100,
+        dims=alg.D1_SC
+    )
+
+
+def xyz_d65_to_cam16(xyzd65: Vector, env: Environment) -> Vector:
+    """From XYZ to CAM16."""
+
+    # Cone response
+    rgb_a = adapt(
+        alg.multiply(
+            alg.dot(M16, alg.multiply(xyzd65, 100, dims=alg.D1_SC), dims=alg.D2_D1),
+            env.d_rgb,
+            dims=alg.D1
+        ),
+        env.fl
+    )
+
+    # Red-green and yellow-blue components
+    a = rgb_a[0] + (-12 * rgb_a[1] + rgb_a[2]) / 11
+    b = (rgb_a[0] + rgb_a[1] - 2 * rgb_a[2]) / 9
+    h_rad = math.atan2(b, a)
+
+    # Eccentricity
+    et = 0.25 * (math.cos(h_rad + 2) + 3.8)
+
+    t = (
+        5e4 / 13 * env.nc * env.ncb * et * math.sqrt(a ** 2 + b ** 2) /
+        (rgb_a[0] + rgb_a[1] + 1.05 * rgb_a[2] + 0.305)
+    )
+    alpha = alg.npow(t, 0.9) * math.pow(1.64 - math.pow(0.29, env.n), 0.73)
+
+    # Achromatic response
+    A = env.nbb * (2 * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2])
+
+    J_root = alg.npow(A / env.a_w, 0.5 * env.c * env.z)
+
+    # Lightness
+    J = 100 * alg.npow(J_root, 2)
+
+    # Brightness
+    Q = (4 / env.c * J_root * (env.a_w + 4) * env.fl_root)
+
+    # Chroma
+    C = alpha * J_root
+
+    # Colorfulness
+    M = C * env.fl_root
+
+    # Hue
+    h = util.constrain_hue(math.degrees(h_rad))
+
+    # Hue quadrature
+    H = hue_quadrature(h)
+
+    # Saturation
+    s = 50 * alg.nth_root(env.c * alpha / (env.a_w + 4), 2)
+
+    return [J, C, h, s, Q, M, H]
+
+
+def xyz_d65_to_cam16_jmh(xyzd65: Vector, env: Environment) -> Vector:
+    """XYZ to CAM16 JMh."""
+
+    cam16 = xyz_d65_to_cam16(xyzd65, env)
+    J, M, h = cam16[0], cam16[5], cam16[2]
+    if J <= 0.0:
+        J = M = h = 0.0
+    return [J, M, h]
+
+
+def cam16_jmh_to_xyz_d65(jmh: Vector, env: Environment) -> Vector:
+    """CAM16 JMh to XYZ."""
+
+    J, M, h = jmh
+    if J <= 0.0:
+        J = M = h = 0.0
+    return cam16_to_xyz_d65(J=J, M=M, h=h, env=env)
 
 
 class CAM16JMh(LChish, Space):
     """CAM16 class (JMh)."""
 
-    BASE = "cam16"
+    BASE = "xyz-d65"
     NAME = "cam16-jmh"
     SERIALIZE = ("--cam16-jmh",)
-    CHANNELS = (
-        Channel("j", 0.0, 100.0, limit=(0.0, None)),
-        Channel("m", 0, 105.0, limit=(0.0, None)),
-        Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
-    )
     CHANNEL_ALIASES = {
         "lightness": "j",
         "colorfulness": 'm',
         "hue": 'h'
     }
     WHITE = WHITES['2deg']['D65']
-    # Assuming the same environment as our CAM16 Jab. If we used a different
-    # environment (viewing conditions), we'd have to have our BASE set as
-    # a Jab that shared those conditions, or resolve directly from XYZ.
-    ENV = CAM16.ENV
-
-    # Achromatic detection
+    # Assuming sRGB which has a lux of 64
+    ENV = Environment(WHITE, 64 / math.pi * 0.2, 20, 'average', False)
+    # If discounting were True, we could remove the dynamic achromatic response
     ACHROMATIC = Achromatic(
-        {
-            'low': (0, 25, 1, 100.0),
-            'mid': (25, 101, 9, 80.0),
-            'high': (101, 302, 5, 60.0)
-        },
-        0.032,
-        ENV,
-        'monotone'
+        # Precalculated from:
+        # [
+        #     (1, 5, 1, 1000.0),
+        #     (1, 10, 1, 200.0),
+        #     (5, 521, 5, 100.0)
+        # ],
+        ACHROMATIC_RESPONSE,
+        0.0012,
+        0.0141,
+        6.7,
+        'catrom',
+        env=ENV
+    )
+    CHANNELS = (
+        Channel("j", 0.0, 100.0, limit=(0.0, None)),
+        Channel("m", 0, 105.0, limit=(0.0, None)),
+        Channel("h", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue)
     )
 
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
 
-        return self.ACHROMATIC.hue
+        if index == 2:
+            h = coords[2]
+            return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h
 
-    def normalize(self, coords: Vector) -> Vector:
-        """Normalize the color ensuring no unexpected NaN and achromatic hues are NaN."""
+        elif index == 1:
+            c = coords[1]
+            return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c
 
-        coords = alg.no_nans(coords)
-        j, m = coords[:2]
-        if self.ACHROMATIC.test(j, m):
-            coords[2] = alg.NaN
-        return coords
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
 
-    def to_base(self, coords: Vector) -> Vector:
-        """To XYZ from CAM16."""
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
 
-        j, m = coords[:2]
-        if self.ACHROMATIC.test(j, m):
-            coords[1] = self.ACHROMATIC.get_ideal_chroma(j, m)
-            coords[2] = self.ACHROMATIC.hue
+        return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], coords[1], coords[2])
+
+    def to_base(self, coords: Vector) -> Vector:
+        """From CAM16 JMh to XYZ."""
 
-        return cam16_jmh_to_cam16_jab(coords)
+        return cam16_jmh_to_xyz_d65(coords, self.ENV)
 
     def from_base(self, coords: Vector) -> Vector:
-        """From XYZ to CAM16."""
+        """From XYZ to CAM16 JMh."""
 
-        jmh = cam16_jab_to_cam16_jmh(coords)
-        return self.normalize(jmh)
+        return xyz_d65_to_cam16_jmh(coords, self.ENV)
diff --git a/coloraide/spaces/cam16_ucs.py b/coloraide/spaces/cam16_ucs.py
index 2a277b59d..ac265276d 100644
--- a/coloraide/spaces/cam16_ucs.py
+++ b/coloraide/spaces/cam16_ucs.py
@@ -10,6 +10,7 @@
 from . cam16 import CAM16
 from ..types import Vector
 from ..channels import Channel, FLG_MIRROR_PERCENT
+from .. import algebra as alg
 
 COEFFICENTS = {
     'lcd': (0.77, 0.007, 0.0053),
@@ -85,6 +86,13 @@ class CAM16UCS(CAM16):
         Channel("b", -50.0, 50.0, flags=FLG_MIRROR_PERCENT)
     )
 
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        j, a, b = cam16_ucs_to_cam16(coords, self.MODEL)
+        m, h = alg.rect_to_polar(a, b)
+        return coords[0] == 0.0 or self.ACHROMATIC.test(j, m, h)
+
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ from CAM16."""
 
@@ -101,7 +109,6 @@ class CAM16LCD(CAM16UCS):
 
     NAME = "cam16-lcd"
     SERIALIZE = ("--cam16-lcd",)
-    ENV = ENV = CAM16.ENV
     MODEL = 'lcd'
     CHANNELS = (
         Channel("j", 0.0, 100.0, limit=(0.0, None)),
@@ -115,7 +122,6 @@ class CAM16SCD(CAM16UCS):
 
     NAME = "cam16-scd"
     SERIALIZE = ("--cam16-scd",)
-    ENV = ENV = CAM16.ENV
     MODEL = 'scd'
     CHANNELS = (
         Channel("j", 0.0, 100.0, limit=(0.0, None)),
diff --git a/coloraide/spaces/cmy.py b/coloraide/spaces/cmy.py
index ef4789f55..adf768483 100644
--- a/coloraide/spaces/cmy.py
+++ b/coloraide/spaces/cmy.py
@@ -4,6 +4,8 @@
 from ..channels import Channel
 from ..cat import WHITES
 from ..types import Vector
+from .. import algebra as alg
+import math
 
 
 def srgb_to_cmy(rgb: Vector) -> Vector:
@@ -36,6 +38,15 @@ class CMY(Space):
     }
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Test if color is achromatic."""
+
+        black = [1, 1, 1]
+        for x in alg.vcross(coords, black):
+            if not math.isclose(0.0, x, abs_tol=1e-4):
+                return False
+        return True
+
     def to_base(self, coords: Vector) -> Vector:
         """To sRGB."""
 
diff --git a/coloraide/spaces/cmyk.py b/coloraide/spaces/cmyk.py
index 6cfedbba0..07f26536a 100644
--- a/coloraide/spaces/cmyk.py
+++ b/coloraide/spaces/cmyk.py
@@ -8,6 +8,8 @@
 from ..channels import Channel
 from ..cat import WHITES
 from ..types import Vector
+from .. import algebra as alg
+import math
 
 
 def srgb_to_cmyk(rgb: Vector) -> Vector:
@@ -55,6 +57,18 @@ class CMYK(Space):
     }
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Test if color is achromatic."""
+
+        if math.isclose(1.0, coords[-1], abs_tol=1e-4):
+            return True
+
+        black = [1, 1, 1]
+        for x in alg.vcross(coords, black):
+            if not math.isclose(0.0, x, abs_tol=1e-5):
+                return False
+        return True
+
     def to_base(self, coords: Vector) -> Vector:
         """To sRGB."""
 
diff --git a/coloraide/spaces/hct.py b/coloraide/spaces/hct.py
index 89130028b..b23fbadf5 100644
--- a/coloraide/spaces/hct.py
+++ b/coloraide/spaces/hct.py
@@ -54,12 +54,71 @@
 from ..spaces import Space, LChish
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .cam16 import Environment, cam16_to_xyz_d65, xyz_d65_to_cam16
+from .cam16_jmh import Environment, cam16_to_xyz_d65, xyz_d65_to_cam16
 from .cam16_jmh import Achromatic as _Achromatic
+from .srgb_linear import lin_srgb_to_xyz
+from .srgb import lin_srgb
 from .lab import EPSILON, KAPPA, KE
 from ..types import Vector, VectorLike
+from typing import Any
 import math
 
+ACHROMATIC_RESPONSE = [
+    [0.06991457401674239, 0.14894004649491988, 209.54685746644427],
+    [0.13982914803348123, 0.19600973408108388, 209.54683151374996],
+    [0.20974372205022362, 0.22847588408235084, 209.54681244250116],
+    [0.279658296066966, 0.2539510473857674, 209.54679680373425],
+    [0.34957287008370486, 0.27520353123074354, 209.5467833054029],
+    [0.6991457401674133, 0.3503388633000292, 209.54673233806605],
+    [1.0487186102511181, 0.40138459372769575, 209.54669489478562],
+    [1.398291480334823, 0.4411483978448841, 209.5466641968681],
+    [1.7478643504185314, 0.4741765535239112, 209.54663770502128],
+    [2.0974372205022362, 0.5026634742613167, 209.54661414586045],
+    [2.447010090585941, 0.527852488793119, 209.5465927758291],
+    [2.7965829606696495, 0.5505226723841257, 209.54657311722175],
+    [3.1624547958278697, 0.5721193279495612, 209.54655401960414],
+    [3.5553195764898433, 0.5933528830664249, 209.5465348955287],
+    [3.9752712774319967, 0.6142212187429535, 209.5465157686296],
+    [4.422818762249047, 0.6347462660662626, 209.54649663922487],
+    [4.89845719045681, 0.6549477974515944, 209.5464775076281],
+    [5.40266895987223, 0.6748437111130419, 209.54645837410766],
+    [5.93592454797767, 0.6944502696114526, 209.54643923893252],
+    [6.4986832666748775, 0.7137823011126716, 209.5464201023271],
+    [7.091393942308237, 0.7328533701575941, 209.54640096452033],
+    [7.714495530830426, 0.7516759233486977, 209.54638182570906],
+    [8.362902589291522, 0.7702614142702398, 209.5463626860871],
+    [9.010442756551821, 0.7886204111202818, 209.54634354582433],
+    [9.653817900540862, 0.8067626898669608, 209.5463244050959],
+    [10.29318377392433, 0.8246973152252656, 209.5463052640459],
+    [10.928685772528809, 0.8424327113314979, 209.5462861228275],
+    [11.56045990779819, 0.8599767236656302, 209.54626698157904],
+    [12.188633662809302, 0.8773366735026968, 209.5462478404268],
+    [12.813326748632115, 0.8945194059620538, 209.54622869949029],
+    [13.434651775012771, 0.9115313325460387, 209.54620955888788],
+    [14.0527148470809, 0.928378468919685, 209.54619041872456],
+    [14.667616097925809, 0.9450664685622908, 209.5461712791161],
+    [15.279450165363095, 0.9616006528304665, 209.54615214014538],
+    [15.888306619956744, 0.9779860378836446, 209.5461330019187],
+    [16.494270350320917, 0.9942273588675171, 209.54611386451774],
+    [17.097421910858294, 1.0103290916839158, 209.54609472803028],
+    [17.697837836366574, 1.0262954726377385, 209.5460755925407],
+    [18.295590927334914, 1.042130516206454, 209.54605645811773],
+    [18.890750509238096, 1.0578380311468378, 209.54603732484748],
+    [19.483382668700443, 1.0734216351263122, 209.54601819278727],
+    [20.073550469030984, 1.088884768038463, 209.54599906201673],
+    [20.661314147315657, 1.1042307041468111, 209.54597993259077],
+    [53.38896474111432, 1.8932913045660376, 209.54481718319346],
+    [100.0, 2.871588955286716, 209.5429359788321],
+    [142.21233355267947, 3.6598615904431204, 209.54109066792404],
+    [181.74498695762145, 4.33303753284783, 209.53928262364892],
+    [219.379559142843, 4.924275900757143, 209.5375118768831],
+    [255.55949550426857, 5.452264905961894, 209.53577795810992],
+    [290.56862469201246, 5.929002120237057, 209.53408016967768],
+    [324.6031878228979, 6.362852583853973, 209.5324177017366],
+    [357.8063946931686, 6.7599912702266085, 209.53078968985596],
+    [390.2870215828255, 7.125170758147813, 209.52919524657008],
+    [422.13028305191517, 7.462166817890066, 209.52763347977915]]  # type: list[Vector]
+
 
 def y_to_lstar(y: float, white: VectorLike) -> float:
     """Convert XYZ Y to Lab L*."""
@@ -95,10 +154,6 @@ def hct_to_xyz(coords: Vector, env: Environment) -> Vector:
 
     h, c, t = coords[:]
 
-    # No NaN
-    if alg.is_nan(h):  # pragma: no cover
-        h = 0
-
     if t == 0:
         return [0.0, 0.0, 0.0]
     elif t == 100.0:
@@ -137,24 +192,26 @@ def xyz_to_hct(coords: Vector, env: Environment) -> Vector:
     """Convert XYZ to HCT."""
 
     t = y_to_lstar(coords[1], env.ref_white)
-    # We have no interest in trying to calculate inverse lightness
-    # colors forward and backwards.
-    if t <= 0:
-        return [alg.NaN, 0.0, 0.0]
-    c, h = xyz_d65_to_cam16(coords, env)[1:3]
-
+    if t <= 0.0:
+        t = c = h = 0.0
+    else:
+        c, h = xyz_d65_to_cam16(coords, env)[1:3]
     return [h, c, alg.clamp(t, 0.0)]
 
 
 class Achromatic(_Achromatic):
     """Test HCT achromatic response."""
 
-    CONVERTER = staticmethod(xyz_to_hct)
     # Lightness and chroma (equivalent) index.
     L_IDX = 2
     C_IDX = 1
     H_IDX = 0
 
+    def convert(self, coords: Vector, *, env: Environment, **kwargs: Any) -> Vector:  # type: ignore[override]
+        """Convert to the target color space."""
+
+        return xyz_to_hct(lin_srgb_to_xyz(lin_srgb(coords)), env)
+
 
 class HCT(LChish, Space):
     """HCT class."""
@@ -162,17 +219,6 @@ class HCT(LChish, Space):
     BASE = "xyz-d65"
     NAME = "hct"
     SERIALIZE = ("--hct",)
-    CHANNELS = (
-        Channel("h", 0.0, 360.0, flags=FLG_ANGLE),
-        Channel("c", 0.0, 145.0, limit=(0.0, None)),
-        Channel("t", 0.0, 100.0, limit=(0.0, None))
-    )
-    CHANNEL_ALIASES = {
-        "lightness": "t",
-        "tone": "t",
-        "chroma": "c",
-        "hue": "h"
-    }
     WHITE = WHITES['2deg']['D65']
     ENV = Environment(
         WHITE,
@@ -181,51 +227,66 @@ class HCT(LChish, Space):
         'average',
         False
     )
+    CHANNEL_ALIASES = {
+        "lightness": "t",
+        "tone": "t",
+        "chroma": "c",
+        "hue": "h"
+    }
 
     # Achromatic detection
+    # Precalculated from:
+    # [
+    #     (1, 5, 1, 1000.0),
+    #     (1, 40, 1, 200.0),
+    #     (50, 551, 50, 100.0)
+    # ]
     ACHROMATIC = Achromatic(
-        {
-            'low': (0, 51, 1, 200.0),
-            'mid': (50, 101, 5, 100.0),
-            'high': (101, 502, 25, 75.0)
-        },
-        0.085,
-        ENV,
-        'natural'
+        ACHROMATIC_RESPONSE,
+        0.0097,
+        0.0787,
+        8.1,
+        'catrom',
+        env=ENV,
     )
 
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
+    CHANNELS = (
+        Channel("h", 0.0, 360.0, flags=FLG_ANGLE),
+        Channel("c", 0.0, 145.0, limit=(0.0, None)),
+        Channel("t", 0.0, 100.0, limit=(0.0, None))
+    )
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
 
-        return self.ACHROMATIC.hue
+        if index == 0:
+            h = coords[0]
+            return self.ACHROMATIC.get_ideal_hue(coords[2]) if math.isnan(h) else h
 
-    def normalize(self, coords: Vector) -> Vector:
-        """Normalize the color ensuring no unexpected NaN and achromatic hues are NaN."""
+        elif index == 1:
+            c = coords[1]
+            return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c
 
-        coords = alg.no_nans(coords)
-        m, j = coords[1:3]
-        if self.ACHROMATIC.test(j, m):
-            coords[0] = alg.NaN
-        return coords
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
 
-    def lchish_names(self) -> tuple[str, ...]:
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        return coords[2] == 0.0 or self.ACHROMATIC.test(coords[2], coords[1], coords[0])
+
+    def names(self) -> tuple[str, ...]:
         """Return LCh-ish names in the order L C h."""
 
         channels = self.channels
         return channels[2], channels[1], channels[0]
 
     def to_base(self, coords: Vector) -> Vector:
-        """To XYZ from HCT."""
-
-        m, j = coords[1:3]
-        if self.ACHROMATIC.test(j, m):
-            coords[1] = self.ACHROMATIC.get_ideal_chroma(j, m)
-            coords[0] = self.ACHROMATIC.hue
+        """To XYZ from CAM16."""
 
         return hct_to_xyz(coords, self.ENV)
 
     def from_base(self, coords: Vector) -> Vector:
-        """From XYZ to HCT."""
+        """From XYZ to CAM16."""
 
-        hct = xyz_to_hct(coords, self.ENV)
-        return self.normalize(hct)
+        return xyz_to_hct(coords, self.ENV)
diff --git a/coloraide/spaces/hpluv.py b/coloraide/spaces/hpluv.py
index 3809c7da9..fd11db792 100644
--- a/coloraide/spaces/hpluv.py
+++ b/coloraide/spaces/hpluv.py
@@ -25,15 +25,13 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
+from ..spaces import Space, HSLish
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .lch import ACHROMATIC_THRESHOLD
 from .lab import EPSILON, KAPPA
 from .srgb_linear import XYZ_TO_RGB
 import math
 from .. import util
-from .. import algebra as alg
 from ..types import Vector
 
 
@@ -80,12 +78,10 @@ def hpluv_to_lch(hpluv: Vector) -> Vector:
     if l > 100 - 1e-7:
         l = 100
     elif l < 1e-08:
-        l = 0
-    elif not alg.is_nan(h):
+        l = 0.0
+    else:
         _hx_max = max_safe_chroma_for_l(l)
         c = _hx_max / 100 * s
-    if c < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
     return [l, c, util.constrain_hue(h)]
 
 
@@ -97,16 +93,14 @@ def lch_to_hpluv(lch: Vector) -> Vector:
     if l > 100 - 1e-7:
         l = 100
     elif l < 1e-08:
-        l = 0
-    elif not alg.is_nan(h):
+        l = 0.0
+    else:
         _hx_max = max_safe_chroma_for_l(l)
         s = c / _hx_max * 100
-    if s < 1e-07:
-        h = alg.NaN
     return [util.constrain_hue(h), s, l]
 
 
-class HPLuv(Cylindrical, Space):
+class HPLuv(HSLish, Space):
     """HPLuv class."""
 
     BASE = 'lchuv'
@@ -124,13 +118,10 @@ class HPLuv(Cylindrical, Space):
     }
     WHITE = WHITES['2deg']['D65']
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Check if color is achromatic."""
 
-        coords = alg.no_nans(coords)
-        if abs(coords[1]) < 1e-07 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08:
-            coords[0] = alg.NaN
-        return coords
+        return abs(coords[1]) < 1e-4 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08
 
     def to_base(self, coords: Vector) -> Vector:
         """To LChuv from HPLuv."""
diff --git a/coloraide/spaces/hsi.py b/coloraide/spaces/hsi.py
index 394b872d6..4a069916b 100644
--- a/coloraide/spaces/hsi.py
+++ b/coloraide/spaces/hsi.py
@@ -4,7 +4,7 @@
 https://en.wikipedia.org/wiki/HSL_and_HSV#Saturation
 """
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
+from .hsv import HSV
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
 from .. import algebra as alg
@@ -16,12 +16,12 @@ def srgb_to_hsi(rgb: Vector) -> Vector:
     """sRGB to HSI."""
 
     r, g, b = rgb
-    h = alg.NaN
+    h = 0.0
     s = 0.0
     mx = max(rgb)
     mn = min(rgb)
     i = sum(rgb) * 1 / 3
-    s = 0 if i == 0.0 or abs(1 - i) < 1e-7 else 1 - (mn / i)
+    s = 0 if i == 0.0 else 1 - (mn / i)
     c = mx - mn
 
     if c != 0.0:
@@ -33,9 +33,6 @@ def srgb_to_hsi(rgb: Vector) -> Vector:
             h = (r - g) / c + 4.0
         h *= 60.0
 
-    if abs(s) < 1e-07:
-        h = alg.NaN
-
     return [util.constrain_hue(h), s, i]
 
 
@@ -76,7 +73,7 @@ def hsi_to_srgb(hsi: Vector) -> Vector:
     return [chan + m for chan in rgb]
 
 
-class HSI(Cylindrical, Space):
+class HSI(HSV):
     """HSI class."""
 
     BASE = "srgb"
@@ -95,16 +92,6 @@ class HSI(Cylindrical, Space):
     WHITE = WHITES['2deg']['D65']
     GAMUT_CHECK = "srgb"
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
-
-        _, s, i = alg.no_nans(coords[:-1])
-
-        if abs(s) < 1e-7 or i == 0.0 or abs(1 - i) < 1e-7:
-            coords[0] = alg.NaN
-
-        return coords
-
     def to_base(self, coords: Vector) -> Vector:
         """To sRGB from HSI."""
 
diff --git a/coloraide/spaces/hsl/__init__.py b/coloraide/spaces/hsl/__init__.py
index f45f549fe..193434365 100644
--- a/coloraide/spaces/hsl/__init__.py
+++ b/coloraide/spaces/hsl/__init__.py
@@ -1,10 +1,9 @@
 """HSL class."""
 from __future__ import annotations
-from ...spaces import Space, Cylindrical
+from ...spaces import HSLish, Space
 from ...cat import WHITES
 from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT
 from ... import util
-from ... import algebra as alg
 from ...types import Vector
 
 
@@ -14,7 +13,7 @@ def srgb_to_hsl(rgb: Vector) -> Vector:
     r, g, b = rgb
     mx = max(rgb)
     mn = min(rgb)
-    h = alg.NaN
+    h = 0.0
     s = 0.0
     l = (mn + mx) / 2
     c = mx - mn
@@ -26,10 +25,8 @@ def srgb_to_hsl(rgb: Vector) -> Vector:
             h = (b - r) / c + 2.0
         else:
             h = (r - g) / c + 4.0
-        s = 0 if l == 0.0 or abs(1 - l) < 1e-7 else (mx - l) / min(l, 1 - l)
+        s = 0 if l == 0.0 or l == 1.0 else (mx - l) / min(l, 1 - l)
         h *= 60.0
-        if abs(s) < 1e-07:
-            h = alg.NaN
 
     return [util.constrain_hue(h), s, l]
 
@@ -53,7 +50,7 @@ def f(n: int) -> float:
     return [f(0), f(8), f(4)]
 
 
-class HSL(Cylindrical, Space):
+class HSL(HSLish, Space):
     """HSL class."""
 
     BASE = "srgb"
@@ -72,14 +69,10 @@ class HSL(Cylindrical, Space):
     WHITE = WHITES['2deg']['D65']
     GAMUT_CHECK = "srgb"
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Check if color is achromatic."""
 
-        coords = alg.no_nans(coords)
-        if abs(coords[1]) < 1e-8 or coords[2] == 0.0 or abs(1 - coords[2]) < 1e-7:
-            coords[0] = alg.NaN
-
-        return coords
+        return abs(coords[1]) < 1e-4 or coords[2] == 0.0 or abs(1 - coords[2]) < 1e-7
 
     def to_base(self, coords: Vector) -> Vector:
         """To sRGB from HSL."""
diff --git a/coloraide/spaces/hsluv.py b/coloraide/spaces/hsluv.py
index b69dcc270..4decfa9ff 100644
--- a/coloraide/spaces/hsluv.py
+++ b/coloraide/spaces/hsluv.py
@@ -25,15 +25,13 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
+from ..spaces import Space, HSLish
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .lch import ACHROMATIC_THRESHOLD
 from .lab import EPSILON, KAPPA
 from .srgb_linear import XYZ_TO_RGB
 import math
 from .. import util
-from .. import algebra as alg
 from ..types import Vector
 
 
@@ -83,11 +81,9 @@ def hsluv_to_lch(hsluv: Vector) -> Vector:
         l = 100.0
     elif l < 1e-08:
         l = 0.0
-    elif not alg.is_nan(h):
+    else:
         _hx_max = max_chroma_for_lh(l, h)
         c = _hx_max / 100.0 * s
-    if c < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
     return [l, c, util.constrain_hue(h)]
 
 
@@ -100,15 +96,13 @@ def lch_to_hsluv(lch: Vector) -> Vector:
         l = 100.0
     elif l < 1e-08:
         l = 0.0
-    elif not alg.is_nan(h):
+    else:
         _hx_max = max_chroma_for_lh(l, h)
         s = c / _hx_max * 100.0
-    if s < 1e-07:
-        h = alg.NaN
     return [util.constrain_hue(h), s, l]
 
 
-class HSLuv(Cylindrical, Space):
+class HSLuv(HSLish, Space):
     """HSLuv class."""
 
     BASE = 'lchuv'
@@ -127,13 +121,10 @@ class HSLuv(Cylindrical, Space):
     WHITE = WHITES['2deg']['D65']
     GAMUT_CHECK = "srgb"
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Check if color is achromatic."""
 
-        coords = alg.no_nans(coords)
-        if abs(coords[1]) < 1e-07 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08:
-            coords[0] = alg.NaN
-        return coords
+        return abs(coords[1]) < 1e-4 or coords[2] > (100 - 1e-7) or coords[2] < 1e-08
 
     def to_base(self, coords: Vector) -> Vector:
         """To LChuv from HSLuv."""
diff --git a/coloraide/spaces/hsv.py b/coloraide/spaces/hsv.py
index 568fc317d..80607404a 100644
--- a/coloraide/spaces/hsv.py
+++ b/coloraide/spaces/hsv.py
@@ -1,10 +1,9 @@
 """HSV class."""
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
+from ..spaces import Space, HSVish
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
 from .. import util
-from .. import algebra as alg
 from ..types import Vector
 
 
@@ -17,10 +16,7 @@ def hsv_to_hsl(hsv: Vector) -> Vector:
 
     h, s, v = hsv
     l = v * (1.0 - s / 2.0)
-    s = 0.0 if l == 0 or abs(1 - l) < 1e-7 else (v - l) / min(l, 1.0 - l)
-
-    if abs(s) < 1e-07:
-        h = alg.NaN
+    s = 0.0 if l == 0.0 or l == 1.0 else (v - l) / min(l, 1.0 - l)
 
     return [util.constrain_hue(h), s, l]
 
@@ -37,13 +33,10 @@ def hsl_to_hsv(hsl: Vector) -> Vector:
     v = l + s * min(l, 1.0 - l)
     s = 0.0 if v == 0.0 else 2 * (1.0 - l / v)
 
-    if abs(s) < 1e-07:
-        h = alg.NaN
-
     return [util.constrain_hue(h), s, v]
 
 
-class HSV(Cylindrical, Space):
+class HSV(HSVish, Space):
     """HSL class."""
 
     BASE = "hsl"
@@ -62,14 +55,10 @@ class HSV(Cylindrical, Space):
     GAMUT_CHECK = "srgb"
     WHITE = WHITES['2deg']['D65']
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
-
-        coords = alg.no_nans(coords)
-        if abs(coords[1]) < 1e-07 or coords[2] == 0.0:
-            coords[0] = alg.NaN
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Check if color is achromatic."""
 
-        return coords
+        return abs(coords[1]) < 1e-5 or coords[2] == 0.0
 
     def to_base(self, coords: Vector) -> Vector:
         """To HSL from HSV."""
diff --git a/coloraide/spaces/hwb/__init__.py b/coloraide/spaces/hwb/__init__.py
index fb625f697..aee28001a 100644
--- a/coloraide/spaces/hwb/__init__.py
+++ b/coloraide/spaces/hwb/__init__.py
@@ -3,7 +3,6 @@
 from ...spaces import Space, Cylindrical
 from ...cat import WHITES
 from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT
-from ... import algebra as alg
 from ...types import Vector
 
 
@@ -15,7 +14,7 @@ def hwb_to_hsv(hwb: Vector) -> Vector:
     wb = w + b
     if 1 - wb < 2e-07:
         gray = w / wb
-        return [alg.NaN, 0.0, gray]
+        return [0.0, 0.0, gray]
 
     v = 1 - b
     s = 0 if v == 0 else 1 - w / v
@@ -30,7 +29,7 @@ def hsv_to_hwb(hsv: Vector) -> Vector:
     w = v * (1 - s)
     b = 1 - v
     if 1 - (w + b) < 2e-07:
-        h = alg.NaN
+        h = 0.0
     return [h, w, b]
 
 
@@ -53,13 +52,15 @@ class HWB(Cylindrical, Space):
     GAMUT_CHECK = "srgb"
     WHITE = WHITES['2deg']['D65']
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Check if color is achromatic."""
 
-        coords = alg.no_nans(coords)
         if 1 - (coords[1] + coords[2]) < 2e-07:
-            coords[0] = alg.NaN
-        return coords
+            return True
+
+        v = 1 - coords[2]
+        s = 0 if v == 0 else 1 - coords[1] / v
+        return abs(s) < 1e-4
 
     def to_base(self, coords: Vector) -> Vector:
         """To HSV from HWB."""
diff --git a/coloraide/spaces/ictcp.py b/coloraide/spaces/ictcp.py
index 29ae6db9a..86e787756 100644
--- a/coloraide/spaces/ictcp.py
+++ b/coloraide/spaces/ictcp.py
@@ -4,7 +4,7 @@
 https://professional.dolby.com/siteassets/pdfs/ictcp_dolbywhitepaper_v071.pdf
 """
 from __future__ import annotations
-from ..spaces import Space, Labish
+from .lab import Lab
 from ..cat import WHITES
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from .. import util
@@ -83,7 +83,7 @@ def xyz_d65_to_ictcp(xyzd65: Vector) -> Vector:
     return alg.dot(lms_p_to_ictcp_m, pqlms, dims=alg.D2_D1)
 
 
-class ICtCp(Labish, Space):
+class ICtCp(Lab):
     """ICtCp class."""
 
     BASE = "xyz-d65"
@@ -102,7 +102,7 @@ class ICtCp(Labish, Space):
     WHITE = WHITES['2deg']['D65']
     DYNAMIC_RANGE = 'hdr'
 
-    def labish_names(self) -> tuple[str, ...]:
+    def names(self) -> tuple[str, ...]:
         """
         Return Lab-ish names in the order L a b.
 
diff --git a/coloraide/spaces/igpgtg.py b/coloraide/spaces/igpgtg.py
index 247642013..c967bcd99 100644
--- a/coloraide/spaces/igpgtg.py
+++ b/coloraide/spaces/igpgtg.py
@@ -4,11 +4,16 @@
 https://www.ingentaconnect.com/content/ist/jpi/2020/00000003/00000002/art00002#
 """
 from __future__ import annotations
-from ..spaces import Space, Labish
+from .ipt import IPT
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from ..cat import WHITES
 from .. import algebra as alg
+from .achromatic import Achromatic as _Achromatic
+from .srgb_linear import lin_srgb_to_xyz
+from .srgb import lin_srgb
 from ..types import Vector
+from typing import Any
+import math
 
 XYZ_TO_LMS = [
     [2.968, 2.741, -0.649],
@@ -34,6 +39,14 @@
     [0.022656986516578225, -0.0047011518748263665, -0.030048158824914562]
 ]
 
+ACHROMATIC_RESPONSE = [
+    [0.01710472400677632, 7.497407788263028e-05, 289.00717276288583],
+    [0.022996189520032607, 0.00010079777395972956, 289.0071727628907],
+    [0.027343043084773415, 0.00011985106810106645, 289.0071727628969],
+    [0.030916881922897717, 0.00013551605464415584, 289.00717276289157],
+    [0.9741484960046701, 0.004269924798539691, 289.0071727629053],
+    [5.049390603804086, 0.022132681254573808, 289.00717276289043]]  # type: list[Vector]
+
 
 def xyz_to_igpgtg(xyz: Vector) -> Vector:
     """XYZ to IgPgTg."""
@@ -59,7 +72,19 @@ def igpgtg_to_xyz(itp: Vector) -> Vector:
     return alg.dot(LMS_TO_XYZ, lms_in, dims=alg.D2_D1)
 
 
-class IgPgTg(Labish, Space):
+class Achromatic(_Achromatic):
+    """Test if color is achromatic."""
+
+    def convert(self, coords: Vector, **kwargs: Any) -> Vector:
+        """Convert to the target color space."""
+
+        lab = xyz_to_igpgtg(lin_srgb_to_xyz(lin_srgb(coords)))
+        l = lab[0]
+        c, h = alg.rect_to_polar(*lab[1:])
+        return [l, c, h]
+
+
+class IgPgTg(IPT):
     """The IgPgTg class."""
 
     BASE = "xyz-d65"
@@ -71,11 +96,37 @@ class IgPgTg(Labish, Space):
         Channel("tg", -1.0, 1.0, flags=FLG_MIRROR_PERCENT)
     )
     CHANNEL_ALIASES = {
-        "intensity": "i",
-        "protan": "cp",
-        "tritan": "ct"
+        "intensity": "ig",
+        "protan": "pg",
+        "tritan": "tg"
     }
     WHITE = WHITES['2deg']['D65']
+    # Precalculated from:
+    # [
+    #     (1, 5, 1, 1000.0),
+    #     (100, 101, 1, 100),
+    #     (520, 521, 1, 100)
+    # ]
+    ACHROMATIC = Achromatic(
+        ACHROMATIC_RESPONSE,
+        1e-5,
+        1e-5,
+        0.03126,
+        'linear',
+        mirror=True
+    )
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
+
+        if index in (1, 2):
+            if not math.isnan(coords[index]):
+                return coords[index]
+
+            return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1]
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
 
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ."""
diff --git a/coloraide/spaces/ipt.py b/coloraide/spaces/ipt.py
index 649c40900..38f51e67a 100644
--- a/coloraide/spaces/ipt.py
+++ b/coloraide/spaces/ipt.py
@@ -9,7 +9,12 @@
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from ..cat import WHITES
 from .. import algebra as alg
+from .achromatic import Achromatic as _Achromatic
+from .srgb_linear import lin_srgb_to_xyz
+from .srgb import lin_srgb
 from ..types import Vector
+from typing import Any
+import math
 
 # The IPT algorithm requires the use of the Hunt-Pointer-Estevez matrix,
 # but it was originally calculated with the assumption of a slightly different
@@ -52,6 +57,14 @@
     [1.0, 0.0326151099170664, -0.6768871830691793]
 ]
 
+ACHROMATIC_RESPONSE = [
+    [0.017066845239980113, 1.3218447776831226e-06, 329.7602673181543],
+    [0.022993026958471587, 1.7808336678784566e-06, 329.76026731797435],
+    [0.027372558329889066, 2.1200328924997904e-06, 329.76026731785475],
+    [0.030976977952230922, 2.399198912177825e-06, 329.7602673177659],
+    [0.9999910919149724, 7.745034210859942e-05, 329.7602673174851],
+    [5.243613106559706, 0.00040612324677300886, 329.7602673178901]]  # type: list[Vector]
+
 
 def xyz_to_ipt(xyz: Vector) -> Vector:
     """XYZ to IPT."""
@@ -67,6 +80,18 @@ def ipt_to_xyz(ipt: Vector) -> Vector:
     return alg.dot(LMS_TO_XYZ, lms, dims=alg.D2_D1)
 
 
+class Achromatic(_Achromatic):
+    """Test achromatic response."""
+
+    def convert(self, coords: Vector, **kwargs: Any) -> Vector:
+        """Convert to the target color space."""
+
+        lab = xyz_to_ipt(lin_srgb_to_xyz(lin_srgb(coords)))
+        l = lab[0]
+        c, h = alg.rect_to_polar(*lab[1:])
+        return [l, c, h]
+
+
 class IPT(Labish, Space):
     """The IPT class."""
 
@@ -80,10 +105,42 @@ class IPT(Labish, Space):
     )
     CHANNEL_ALIASES = {
         "intensity": "i",
-        "protan": "cp",
-        "tritan": "ct"
+        "protan": "p",
+        "tritan": "t"
     }
     WHITE = WHITES['2deg']['D65']
+    # Precalculated from:
+    # [
+    #     (1, 5, 1, 1000.0),
+    #     (100, 101, 1, 100),
+    #     (520, 521, 1, 100)
+    # ]
+    ACHROMATIC = Achromatic(
+        ACHROMATIC_RESPONSE,
+        1e-5,
+        1e-5,
+        0.00049,
+        'linear',
+        mirror=True
+    )  # type: _Achromatic
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
+
+        if index in (1, 2):
+            if not math.isnan(coords[index]):
+                return coords[index]
+
+            return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1]
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
+
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        m, h = alg.rect_to_polar(coords[1], coords[2])
+        return self.ACHROMATIC.test(coords[0], m, h)
 
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ."""
diff --git a/coloraide/spaces/jzazbz.py b/coloraide/spaces/jzazbz.py
index ddc80fccd..3b4d281e3 100644
--- a/coloraide/spaces/jzazbz.py
+++ b/coloraide/spaces/jzazbz.py
@@ -20,11 +20,17 @@
 """
 from __future__ import annotations
 from ..spaces import Space, Labish
+from .achromatic import Achromatic as _Achromatic
 from ..cat import WHITES
 from ..channels import Channel, FLG_MIRROR_PERCENT
 from .. import util
 from .. import algebra as alg
+from .lch import lab_to_lch
+from .srgb_linear import lin_srgb_to_xyz
+from .srgb import lin_srgb
 from ..types import Vector
+from typing import Any
+import math
 
 B = 1.15
 G = 0.66
@@ -72,6 +78,106 @@
     [0.9999999999999998, -0.09601924202631895, -0.8118918960560388]
 ]
 
+ACHROMATIC_RESPONSE = [
+    [0.0009185262133445958, 2.840443191466584e-06, 216.0885802021336],
+    [0.0032449260086909186, 8.875176418290735e-06, 216.0865538095895],
+    [0.007045724283550615, 1.742628198531671e-05, 216.08513073729978],
+    [0.009865500056099596, 2.317265885648471e-05, 216.08447255685712],
+    [0.012214842923825998, 2.7682763639616338e-05, 216.08404287184672],
+    [0.014313828964294246, 3.153644048239706e-05, 216.08371808817984],
+    [0.016438077905865333, 3.5290049847676045e-05, 216.08343078937077],
+    [0.018605469297130712, 3.898508192957335e-05, 216.08317068540933],
+    [0.02080811172991238, 4.261478385537034e-05, 216.0829334034566],
+    [0.02303956968571666, 4.617490464976015e-05, 216.0827155404453],
+    [0.02529454916234039, 4.966297634911679e-05, 216.0825143871757],
+    [0.027568661881969103, 5.307781011104134e-05, 216.08232775391835],
+    [0.029858245862958505, 5.641913782115498e-05, 216.08215383175184],
+    [0.032160227098370596, 5.9687353969353895e-05, 216.0819911399305],
+    [0.03447201166859765, 6.288332788976944e-05, 216.08183842876093],
+    [0.03679140069552315, 6.600826589362147e-05, 216.08169464885202],
+    [0.03911652265347357, 6.906360946484213e-05, 216.0815588821907],
+    [0.04144577901905777, 7.20509595041043e-05, 216.08143037308045],
+    [0.04377780027866024, 7.497201997536904e-05, 216.0813084440367],
+    [0.046111410055380574, 7.78285557456021e-05, 216.08119250961198],
+    [0.04844559565682705, 8.062236105672717e-05, 216.08108206116256],
+    [0.050779483741710464, 8.335523609797642e-05, 216.08097664284261],
+    [0.053112320097915965, 8.602896956101964e-05, 216.0808758658446],
+    [0.05544345274603036, 8.864532585124454e-05, 216.08077937053],
+    [0.05777231775004377, 9.120603576758393e-05, 216.08068683882303],
+    [0.060098427245276025, 9.371278988327557e-05, 216.08059799668482],
+    [0.062421359292580254, 9.616723398809035e-05, 216.08051257202447],
+    [0.07282655496930118, 0.00010660656866708172, 216.08016488545363],
+    [0.09108704537148082, 0.00012302746725157782, 216.07966036150265],
+    [0.10900798276245852, 0.00013720664366179703, 216.0792568965177],
+    [0.12658714074775043, 0.00014958895850897245, 216.0789232341222],
+    [0.1438408486329413, 0.00016050730576011498, 216.07864044077417],
+    [0.16079190286441147, 0.0001702150126800942, 216.0783961640361],
+    [0.17746453522326563, 0.0001789083001012923, 216.07818199543323],
+    [0.1938822908546569, 0.00018674170523656867, 216.07799190659145],
+    [0.21006717973109132, 0.00019383878540645526, 216.07782150011536],
+    [0.22603940395038172, 0.00020029967178728176, 216.0776674210859],
+    [0.24181734649813305, 0.0002062064957239789, 216.0775271074707],
+    [0.25741767436011453, 0.00021162735571841754, 216.0773985097046],
+    [0.27285548569428925, 0.00021661926443777217, 216.07728001861],
+    [0.28814446743495276, 0.00022123037188011426, 216.07717029782012],
+    [0.3032970476681226, 0.0002255016653315956, 216.0770682756892],
+    [0.318324536052124, 0.00022946828610769828, 216.07697303732877],
+    [0.3332372500029604, 0.00023316056105526304, 216.07688383235663],
+    [0.34804462653287077, 0.00023660481904069536, 216.0768000283236],
+    [0.3627553206321442, 0.00023982404450001245, 216.07672106548637],
+    [0.3773772914869721, 0.00024283840391701908, 216.07664646508445],
+    [0.3919178779258038, 0.0002456656744964799, 216.07657583096483],
+    [0.40638386443891256, 0.00024832159559521613, 216.07650879846213],
+    [0.420781539003173, 0.0002508201586917396, 216.07644505801895],
+    [0.4351167438075533, 0.0002531738482749366, 216.07638435201847],
+    [0.44939491983780117, 0.0002553938438855013, 216.0763264167306],
+    [0.46362114614988975, 0.00025749018901337296, 216.076271046612],
+    [0.47780017454650436, 0.000259471935031054, 216.0762180267072],
+    [0.49193646026919147, 0.0002613472624003357, 216.07616722683406],
+    [0.506034189231372, 0.0002631235853618668, 216.07611845640926],
+    [0.520097302241697, 0.0002648076406577341, 216.07607158130352],
+    [0.5341295166035784, 0.0002664055645672449, 216.07602649958457],
+    [0.5481343454215033, 0.0002679229595196272, 216.07598307638068],
+    [0.562115114898706, 0.00026936495156998765, 216.0759412038042],
+    [0.5760749798710889, 0.00027073624083088496, 216.0759007944157],
+    [0.5900169377887273, 0.0002720411453401863, 216.07586174613544],
+    [0.6039438413278134, 0.00027328363966826354, 216.07582400851473],
+    [0.6178584097918066, 0.0002744673887323733, 216.07578748393507],
+    [0.6317632394388409, 0.0002755957777534978, 216.07575212814703],
+    [0.6456608128558685, 0.00027667193897750657, 216.07571787420144],
+    [0.6595535074843613, 0.0002776987749361305, 216.0756845900282],
+    [0.6734436033881548, 0.00027867897881155425, 216.0756523336041],
+    [0.6873332903451649, 0.0002796150541276385, 216.07562100643182],
+    [0.7012246743322099, 0.0002805093307679912, 216.07559056780278],
+    [0.7151197834661946, 0.00028136397973813873, 216.07556096861626],
+    [0.7290205734555001, 0.00028218102717968054, 216.07553214064387],
+    [0.7429289326111091, 0.00028296236534187003, 216.07550410795596],
+    [0.7568466864597538, 0.0002837097645372413, 216.07547681413567],
+    [0.7707756019973733, 0.00028442488261536087, 216.0754501913429],
+    [0.7847173916174169, 0.0002851092732967129, 216.07542424437477],
+    [0.7986737167436694, 0.0002857643945778034, 216.07539893412002],
+    [0.8126461911946142, 0.0002863916163923197, 216.07537422001636],
+    [0.82663638430444, 0.0002869922264017996, 216.07535011510663],
+    [0.8406458238212122, 0.00028756743686351684, 216.07532657427606],
+    [0.8546759986026858, 0.00028811838956578005, 216.07530356840167],
+    [0.8687283611263583, 0.00028864616127277436, 216.07528106686505],
+    [0.8828043298308796, 0.0002891517676790176, 216.0752590661328],
+    [0.8969052913010638, 0.0002896361684484102, 216.07523755309595],
+    [0.9110326023119082, 0.0002901002704615418, 216.07521649953736],
+    [0.9251875917408455, 0.0002905449316470965, 216.07519588476185],
+    [0.939371562360337, 0.00029097096413252364, 216.0751757112678],
+    [0.9535857925200474, 0.0002913791373231644, 216.07515593963097],
+    [0.9678315377267531, 0.000291770180656392, 216.0751365593372]]  # type: list[Vector]
+
+
+class Achromatic(_Achromatic):
+    """Test if color is achromatic."""
+
+    def convert(self, coords: Vector, **kwargs: Any) -> Vector:
+        """Convert to the target color space."""
+
+        return lab_to_lch(xyz_d65_to_jzazbz(lin_srgb_to_xyz(lin_srgb(coords))))
+
 
 def jzazbz_to_xyz_d65(jzazbz: Vector) -> Vector:
     """From Jzazbz to XYZ."""
@@ -136,6 +242,37 @@ class Jzazbz(Labish, Space):
     }
     WHITE = WHITES['2deg']['D65']
     DYNAMIC_RANGE = 'hdr'
+    # Precalculated from
+    # [
+    #     (1, 5, 5, 1000.0),
+    #     (1, 52, 2, 200.0),
+    #     (30, 521, 8, 100.0)
+    # ]
+    ACHROMATIC = Achromatic(
+        ACHROMATIC_RESPONSE,
+        2.121e-7,
+        9.863e-7,
+        0.00039,
+        'catrom',
+    )
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
+
+        if index in (1, 2):
+            if not math.isnan(coords[index]):
+                return coords[index]
+
+            return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1]
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
+
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        c, h = alg.rect_to_polar(coords[1], coords[2])
+        return coords[0] == 0.0 or self.ACHROMATIC.test(coords[0], c, h)
 
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ from Jzazbz."""
diff --git a/coloraide/spaces/jzczhz.py b/coloraide/spaces/jzczhz.py
index 7ed4325ad..b759b3d08 100644
--- a/coloraide/spaces/jzczhz.py
+++ b/coloraide/spaces/jzczhz.py
@@ -4,60 +4,15 @@
 https://www.osapublishing.org/oe/fulltext.cfm?uri=oe-25-13-15131&id=368272
 """
 from __future__ import annotations
-from ..spaces import Space, LChish
+import math
 from ..cat import WHITES
+from .lch import LCh
+from .jzazbz import Jzazbz
 from ..channels import Channel, FLG_ANGLE
-from .. import util
-import math
-from .. import algebra as alg
 from ..types import Vector
 
-ACHROMATIC_THRESHOLD = 0.0003
-# The transform consistently yields ~216 for achromatic hues for positive lightness
-# Replacing achromatic NaN hues with this hue gives us closer translations back.
-ACHROMATIC_HUE = 216.0777045520467
-
-
-def jzazbz_to_jzczhz(jzazbz: Vector) -> Vector:
-    """Jzazbz to JzCzhz."""
-
-    jz, az, bz = jzazbz
-
-    cz = math.sqrt(az ** 2 + bz ** 2)
-    hz = math.degrees(math.atan2(bz, az))
-
-    # Achromatic colors will often get extremely close, but not quite hit zero.
-    # Essentially, we want to discard noise through rounding and such.
-    if cz < ACHROMATIC_THRESHOLD:
-        hz = alg.NaN
-
-    return [jz, cz, util.constrain_hue(hz)]
 
-
-def jzczhz_to_jzazbz(jzczhz: Vector) -> Vector:
-    """JzCzhz to Jzazbz."""
-
-    jz, cz, hz = jzczhz
-
-    # For better round tripping of achromatic colors,
-    # use the achromatic hue that occurs in forward transform.
-    # We use the one from white translation. It may vary slightly
-    # depending on the grayscale color, but only slightly,
-    # so this is close enough.
-    if cz < ACHROMATIC_THRESHOLD:
-        hz = ACHROMATIC_HUE
-
-    if alg.is_nan(hz):  # pragma: no cover
-        return [jz, 0.0, 0.0]
-
-    return [
-        jz,
-        cz * math.cos(math.radians(hz)),
-        cz * math.sin(math.radians(hz))
-    ]
-
-
-class JzCzhz(LChish, Space):
+class JzCzhz(LCh):
     """
     JzCzhz class.
 
@@ -67,44 +22,40 @@ class JzCzhz(LChish, Space):
     BASE = "jzazbz"
     NAME = "jzczhz"
     SERIALIZE = ("--jzczhz",)
-    CHANNELS = (
-        Channel("jz", 0.0, 1.0, limit=(0.0, None)),
-        Channel("cz", 0.0, 0.5, limit=(0.0, None)),
-        Channel("hz", 0.0, 360.0, flags=FLG_ANGLE)
-    )
+    WHITE = WHITES['2deg']['D65']
+    DYNAMIC_RANGE = 'hdr'
     CHANNEL_ALIASES = {
         "lightness": "jz",
         "chroma": "cz",
         "hue": "hz"
     }
-    WHITE = WHITES['2deg']['D65']
-    DYNAMIC_RANGE = 'hdr'
+    ACHROMATIC = Jzazbz.ACHROMATIC
+    CHANNELS = (
+        Channel("jz", 0.0, 1.0, limit=(0.0, None)),
+        Channel("cz", 0.0, 0.5, limit=(0.0, None)),
+        Channel("hz", 0.0, 360.0, flags=FLG_ANGLE, nans=ACHROMATIC.hue)
+    )
+
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resolve channels."""
 
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
+        if index == 2:
+            h = coords[2]
+            return self.ACHROMATIC.get_ideal_hue(coords[0]) if math.isnan(h) else h
 
-        return ACHROMATIC_HUE
+        elif index == 1:
+            c = coords[1]
+            return self.ACHROMATIC.get_ideal_chroma(coords[0]) if math.isnan(c) else c
 
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
 
-        coords = alg.no_nans(coords)
-        if coords[1] < ACHROMATIC_THRESHOLD:
-            coords[2] = alg.NaN
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
 
-        return coords
+        return coords[0] == 0.0 or self.ACHROMATIC.test(*coords)
 
     def hue_name(self) -> str:
         """Hue name."""
 
         return "hz"
-
-    def to_base(self, coords: Vector) -> Vector:
-        """To Jzazbz from JzCzhz."""
-
-        return jzczhz_to_jzazbz(coords)
-
-    def from_base(self, coords: Vector) -> Vector:
-        """From Jzazbz to JzCzhz."""
-
-        return jzazbz_to_jzczhz(coords)
diff --git a/coloraide/spaces/lab/__init__.py b/coloraide/spaces/lab/__init__.py
index d9b7646e7..320e09045 100644
--- a/coloraide/spaces/lab/__init__.py
+++ b/coloraide/spaces/lab/__init__.py
@@ -7,6 +7,7 @@
 from ... import algebra as alg
 from ...types import VectorLike, Vector
 
+ACHROMATIC_THRESHOLD = 1e-4
 EPSILON = 216 / 24389  # `6^3 / 29^3`
 EPSILON3 = 6 / 29  # Cube root of EPSILON
 KAPPA = 24389 / 27
@@ -79,6 +80,11 @@ class Lab(Labish, Space):
     }
     WHITE = WHITES['2deg']['D50']
 
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        return alg.rect_to_polar(coords[1], coords[2])[0] < ACHROMATIC_THRESHOLD
+
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ D50 from Lab."""
 
diff --git a/coloraide/spaces/lch/__init__.py b/coloraide/spaces/lch/__init__.py
index d9e121c96..25618a27b 100644
--- a/coloraide/spaces/lch/__init__.py
+++ b/coloraide/spaces/lch/__init__.py
@@ -5,11 +5,9 @@
 from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT
 from ... import util
 import math
-from ... import algebra as alg
 from ...types import Vector
 
-ACHROMATIC_THRESHOLD = 0.0000000002
-ACHROMATIC_HUE = 0.0
+ACHROMATIC_THRESHOLD = 1e-4
 
 
 def lab_to_lch(lab: Vector) -> Vector:
@@ -20,11 +18,6 @@ def lab_to_lch(lab: Vector) -> Vector:
     c = math.sqrt(a ** 2 + b ** 2)
     h = math.degrees(math.atan2(b, a))
 
-    # Achromatic colors will often get extremely close, but not quite hit zero.
-    # Essentially, we want to discard noise through rounding and such.
-    if c < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
     return [l, c, util.constrain_hue(h)]
 
 
@@ -33,17 +26,6 @@ def lch_to_lab(lch: Vector) -> Vector:
 
     l, c, h = lch
 
-    # For better round tripping of achromatic colors,
-    # use the achromatic hue that occurs in forward transform.
-    # We use the one from white translation. It may or may not vary slightly
-    # depending on the grayscale color, but only slightly,
-    # so this is close enough.
-    if c < ACHROMATIC_THRESHOLD:
-        h = ACHROMATIC_HUE
-
-    if alg.is_nan(h):  # pragma: no cover
-        return [l, 0.0, 0.0]
-
     return [
         l,
         c * math.cos(math.radians(h)),
@@ -69,18 +51,10 @@ class LCh(LChish, Space):
     }
     WHITE = WHITES['2deg']['D50']
 
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
-
-        return ACHROMATIC_HUE
-
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
 
-        coords = alg.no_nans(coords)
-        if coords[1] < ACHROMATIC_THRESHOLD:
-            coords[2] = alg.NaN
-        return coords
+        return coords[1] < ACHROMATIC_THRESHOLD
 
     def to_base(self, coords: Vector) -> Vector:
         """To Lab from LCh."""
diff --git a/coloraide/spaces/lch99o.py b/coloraide/spaces/lch99o.py
index 6536910e0..9b7203483 100644
--- a/coloraide/spaces/lch99o.py
+++ b/coloraide/spaces/lch99o.py
@@ -1,52 +1,10 @@
 """DIN99o LCh class."""
 from __future__ import annotations
 from ..cat import WHITES
-from .lch import LCh, ACHROMATIC_THRESHOLD, ACHROMATIC_HUE
-from .. import util
-import math
-from .. import algebra as alg
-from ..types import Vector
+from .lch import LCh
 from ..channels import Channel, FLG_ANGLE
 
 
-def lch_to_lab(lch: Vector) -> Vector:
-    """DIN99o LCh to lab."""
-
-    l, c, h = lch
-
-    # For better round tripping of achromatic colors,
-    # use the achromatic hue that occurs in forward transform.
-    # We use the one from white translation. It may or may not vary slightly
-    # depending on the grayscale color, but only slightly,
-    # so this is close enough.
-    if c < ACHROMATIC_THRESHOLD:
-        h = ACHROMATIC_HUE
-
-    if alg.is_nan(h):  # pragma: no cover
-        return [l, 0.0, 0.0]
-
-    return [
-        l,
-        c * math.cos(math.radians(h)),
-        c * math.sin(math.radians(h))
-    ]
-
-
-def lab_to_lch(lab: Vector) -> Vector:
-    """DIN99o Lab to LCh."""
-
-    l, a, b = lab
-    h = math.degrees(math.atan2(b, a))
-    c = math.sqrt(a ** 2 + b ** 2)
-
-    # Achromatic colors will often get extremely close, but not quite hit zero.
-    # Essentially, we want to discard noise through rounding and such.
-    if c <= ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
-    return [l, c, util.constrain_hue(h)]
-
-
 class LCh99o(LCh):
     """DIN99o LCh class."""
 
@@ -59,18 +17,3 @@ class LCh99o(LCh):
         Channel("c", 0.0, 60.0, limit=(0.0, None)),
         Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
     )
-
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
-
-        return ACHROMATIC_HUE
-
-    def to_base(self, coords: Vector) -> Vector:
-        """To DIN99o from DIN99o LCh."""
-
-        return lch_to_lab(coords)
-
-    def from_base(self, coords: Vector) -> Vector:
-        """From DIN99o to DIN99o LCh."""
-
-        return lab_to_lch(coords)
diff --git a/coloraide/spaces/lchuv.py b/coloraide/spaces/lchuv.py
index e5ebfffdb..31e91a8ae 100644
--- a/coloraide/spaces/lchuv.py
+++ b/coloraide/spaces/lchuv.py
@@ -3,52 +3,10 @@
 from ..spaces import Space
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .lch import LCh, ACHROMATIC_THRESHOLD, ACHROMATIC_HUE
-from .. import util
-import math
-from .. import algebra as alg
+from .lch import LCh, ACHROMATIC_THRESHOLD
 from ..types import Vector
 
 
-def luv_to_lchuv(luv: Vector) -> Vector:
-    """Luv to LChuv."""
-
-    l, u, v = luv
-
-    c = math.sqrt(u ** 2 + v ** 2)
-    h = math.degrees(math.atan2(v, u))
-
-    # Achromatic colors will often get extremely close, but not quite hit zero.
-    # Essentially, we want to discard noise through rounding and such.
-    if c < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
-    return [l, c, util.constrain_hue(h)]
-
-
-def lchuv_to_luv(lchuv: Vector) -> Vector:
-    """LChuv to Luv."""
-
-    l, c, h = lchuv
-
-    # For better round tripping of achromatic colors,
-    # use the achromatic hue that occurs in forward transform.
-    # We use the one from white translation. It may vary slightly
-    # depending on the grayscale color, but only slightly,
-    # so this is close enough.
-    if c < ACHROMATIC_THRESHOLD:
-        h = ACHROMATIC_HUE
-
-    if alg.is_nan(h):  # pragma: no cover
-        return [l, 0.0, 0.0]
-
-    return [
-        l,
-        c * math.cos(math.radians(h)),
-        c * math.sin(math.radians(h))
-    ]
-
-
 class LChuv(LCh, Space):
     """LChuv class."""
 
@@ -62,17 +20,7 @@ class LChuv(LCh, Space):
         Channel("h", 0.0, 360.0, flags=FLG_ANGLE)
     )
 
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
-
-        return ACHROMATIC_HUE
-
-    def to_base(self, coords: Vector) -> Vector:
-        """To Luv from LChuv."""
-
-        return lchuv_to_luv(coords)
-
-    def from_base(self, coords: Vector) -> Vector:
-        """From Luv to LChuv."""
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
 
-        return luv_to_lchuv(coords)
+        return coords[0] == 0.0 or coords[1] < ACHROMATIC_THRESHOLD
diff --git a/coloraide/spaces/luv.py b/coloraide/spaces/luv.py
index e3c845cea..493c61105 100644
--- a/coloraide/spaces/luv.py
+++ b/coloraide/spaces/luv.py
@@ -7,7 +7,7 @@
 from ..spaces import Space, Labish
 from ..cat import WHITES
 from ..channels import Channel, FLG_MIRROR_PERCENT
-from .lab import KAPPA, EPSILON, KE
+from .lab import KAPPA, EPSILON, KE, ACHROMATIC_THRESHOLD
 from .. import util
 from .. import algebra as alg
 from ..types import Vector
@@ -70,6 +70,11 @@ class Luv(Labish, Space):
     }
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
+
+        return coords[0] == 0.0 or alg.rect_to_polar(coords[1], coords[2])[0] < ACHROMATIC_THRESHOLD
+
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ D50 from Luv."""
 
diff --git a/coloraide/spaces/okhsl.py b/coloraide/spaces/okhsl.py
index 9f95702b5..ab95c5449 100644
--- a/coloraide/spaces/okhsl.py
+++ b/coloraide/spaces/okhsl.py
@@ -26,16 +26,26 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
-from ..cat import WHITES
+from .hsl import HSL
 from ..channels import Channel, FLG_ANGLE
-from .oklab import oklab_to_linear_srgb
-from .oklch import ACHROMATIC_THRESHOLD
 from .. import util
 import math
 import sys
 from .. import algebra as alg
 from ..types import Vector
+from . oklab import OKLAB_TO_LMS3
+
+SRGBL_TO_LMS = [
+    [0.41222146947076277, 0.536332537261735, 0.051445993267502196],
+    [0.21190349581782508, 0.6806995506452346, 0.10739695353694051],
+    [0.08830245919005636, 0.2817188391361215, 0.6299787016738223]
+]
+
+LMS_TO_SRGBL = [
+    [4.07674163607596, -3.3077115392580643, 0.23096990318210434],
+    [-1.268437973285031, 2.6097573492876878, -0.3413193760026569],
+    [-0.004196076138675668, -0.7034186179359357, 1.707614694074611]
+]
 
 FLT_MAX = sys.float_info.max
 
@@ -100,6 +110,16 @@ def get_st_mid(a: float, b: float) -> Vector:
     return [s, t]
 
 
+def oklab_to_linear_srgb(lab: Vector) -> Vector:
+    """Convert from Oklab to linear sRGB."""
+
+    return alg.dot(
+        LMS_TO_SRGBL,
+        [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)],
+        dims=alg.D2_D1
+    )
+
+
 def find_cusp(a: float, b: float) -> Vector:
     """
     Finds L_cusp and C_cusp for a given hue.
@@ -156,9 +176,9 @@ def find_gamut_intersection(
         dl = l1 - l0
         dc = c1
 
-        k_l = +0.3963377774 * a + 0.2158037573 * b
-        k_m = -0.1055613458 * a - 0.0638541728 * b
-        k_s = -0.0894841775 * a - 1.2914855480 * b
+        k_l = alg.vdot(OKLAB_TO_LMS3[0][1:], [a, b])
+        k_m = alg.vdot(OKLAB_TO_LMS3[1][1:], [a, b])
+        k_s = alg.vdot(OKLAB_TO_LMS3[2][1:], [a, b])
 
         l_dt = dl + dc * k_l
         m_dt = dl + dc * k_m
@@ -184,23 +204,23 @@ def find_gamut_intersection(
         mdt2 = 6 * (m_dt ** 2) * m_
         sdt2 = 6 * (s_dt ** 2) * s_
 
-        r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1
-        r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt
-        r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2
+        r = alg.vdot(LMS_TO_SRGBL[0], [l, m, s]) - 1
+        r1 = alg.vdot(LMS_TO_SRGBL[0], [ldt, mdt, sdt])
+        r2 = alg.vdot(LMS_TO_SRGBL[0], [ldt2, mdt2, sdt2])
 
         u_r = r1 / (r1 * r1 - 0.5 * r * r2)
         t_r = -r * u_r
 
-        g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1
-        g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt
-        g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2
+        g = alg.vdot(LMS_TO_SRGBL[1], [l, m, s]) - 1
+        g1 = alg.vdot(LMS_TO_SRGBL[1], [ldt, mdt, sdt])
+        g2 = alg.vdot(LMS_TO_SRGBL[1], [ldt2, mdt2, sdt2])
 
         u_g = g1 / (g1 * g1 - 0.5 * g * g2)
         t_g = -g * u_g
 
-        b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1
-        b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt
-        b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2
+        b = alg.vdot(LMS_TO_SRGBL[2], [l, m, s]) - 1
+        b1 = alg.vdot(LMS_TO_SRGBL[2], [ldt, mdt, sdt])
+        b2 = alg.vdot(LMS_TO_SRGBL[2], [ldt2, mdt2, sdt2])
 
         u_b = b1 / (b1 * b1 - 0.5 * b * b2)
         t_b = -b * u_b
@@ -257,38 +277,32 @@ def compute_max_saturation(a: float, b: float) -> float:
 
     # Select different coefficients depending on which component goes below zero first.
 
-    if (-1.88170328 * a - 0.80936493 * b) > 1:
+    if (-1.8817031 * a - 0.80936501 * b) > 1:
         # Red component
         k0 = 1.19086277
         k1 = 1.76576728
         k2 = 0.59662641
         k3 = 0.75515197
         k4 = 0.56771245
-        wl = 4.0767416621
-        wm = -3.3077115913
-        ws = 0.2309699292
+        wl, wm, ws = LMS_TO_SRGBL[0]
 
-    elif (1.81444104 * a - 1.19445276 * b) > 1:
+    elif (1.81444079 * a - 1.19445267 * b) > 1:
         # Green component
         k0 = 0.73956515
         k1 = -0.45954404
         k2 = 0.08285427
-        k3 = 0.12541070
-        k4 = 0.14503204
-        wl = -1.2684380046
-        wm = 2.6097574011
-        ws = -0.3413193965
+        k3 = 0.12541073
+        k4 = -0.14503204
+        wl, wm, ws = LMS_TO_SRGBL[1]
 
     else:
         # Blue component
-        k0 = 1.35733652
-        k1 = -0.00915799
-        k2 = -1.15130210
-        k3 = -0.50559606
-        k4 = 0.00692167
-        wl = -0.0041960863
-        wm = -0.7034186147
-        ws = 1.7076147010
+        k0 = 1.35692055
+        k1 = -0.00927503
+        k2 = -1.15077658
+        k3 = -0.50648323
+        k4 = 0.00645566
+        wl, wm, ws = LMS_TO_SRGBL[2]
 
     # Approximate max saturation using a polynomial:
     sat = k0 + k1 * a + k2 * b + k3 * (a ** 2) + k4 * a * b
@@ -297,9 +311,9 @@ def compute_max_saturation(a: float, b: float) -> float:
     # This gives an error less than 10e6, except for some blue hues where the `dS/dh` is close to infinite.
     # This should be sufficient for most applications, otherwise do two/three steps.
 
-    k_l = 0.3963377774 * a + 0.2158037573 * b
-    k_m = -0.1055613458 * a - 0.0638541728 * b
-    k_s = -0.0894841775 * a - 1.2914855480 * b
+    k_l = alg.vdot(OKLAB_TO_LMS3[0][1:], [a, b])
+    k_m = alg.vdot(OKLAB_TO_LMS3[1][1:], [a, b])
+    k_s = alg.vdot(OKLAB_TO_LMS3[2][1:], [a, b])
 
     l_ = 1.0 + sat * k_l
     m_ = 1.0 + sat * k_m
@@ -335,7 +349,7 @@ def okhsl_to_oklab(hsl: Vector) -> Vector:
     L = toe_inv(l)
     a = b = 0.0
 
-    if L != 0.0 and not abs(1 - L) < 1e-7 and abs(s) >= ACHROMATIC_THRESHOLD and not alg.is_nan(h):
+    if L != 0.0 and L != 1.0 and s != 0:
         a_ = math.cos(2.0 * math.pi * h)
         b_ = math.sin(2.0 * math.pi * h)
 
@@ -374,21 +388,17 @@ def okhsl_to_oklab(hsl: Vector) -> Vector:
 def oklab_to_okhsl(lab: Vector) -> Vector:
     """Oklab to Okhsl."""
 
-    h = alg.NaN
     L = lab[0]
     s = 0.0
     l = toe(L)
 
     c = math.sqrt(lab[1] ** 2 + lab[2] ** 2)
-    if c < ACHROMATIC_THRESHOLD:
-        c = 0.0
+    h = 0.5 + 0.5 * math.atan2(-lab[2], -lab[1]) / math.pi
 
-    if l != 0.0 and not abs(1 - l) < 1e-7 and c != 0:
+    if l != 0.0 and l != 1.0 and c != 0:
         a_ = lab[1] / c
         b_ = lab[2] / c
 
-        h = 0.5 + 0.5 * math.atan2(-lab[2], -lab[1]) / math.pi
-
         c_0, c_mid, c_max = get_cs([L, a_, b_])
 
         mid = 0.8
@@ -409,13 +419,10 @@ def oklab_to_okhsl(lab: Vector) -> Vector:
             t = (c - k_0) / (k_1 + k_2 * (c - k_0))
             s = mid + 0.2 * t
 
-    if abs(s) < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
     return [util.constrain_hue(h * 360), s, l]
 
 
-class Okhsl(Cylindrical, Space):
+class Okhsl(HSL):
     """HSL class."""
 
     BASE = "oklab"
@@ -431,16 +438,6 @@ class Okhsl(Cylindrical, Space):
         "saturation": "s",
         "lightness": "l"
     }
-    WHITE = WHITES['2deg']['D65']
-    GAMUT_CHECK = "srgb"
-
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
-
-        coords = alg.no_nans(coords)
-        if coords[2] == 0.0 or abs(1 - coords[2]) < 1e-7 or abs(coords[1]) < ACHROMATIC_THRESHOLD:
-            coords[0] = alg.NaN
-        return coords
 
     def to_base(self, coords: Vector) -> Vector:
         """To Oklab from Okhsl."""
diff --git a/coloraide/spaces/okhsv.py b/coloraide/spaces/okhsv.py
index 0d7619784..66e4ab1cc 100644
--- a/coloraide/spaces/okhsv.py
+++ b/coloraide/spaces/okhsv.py
@@ -26,13 +26,10 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ..spaces import Space, Cylindrical
-from ..cat import WHITES
+from .hsv import HSV
 from ..channels import FLG_ANGLE, Channel
 from .. import util
-from .oklab import oklab_to_linear_srgb
-from .okhsl import toe, toe_inv, find_cusp, to_st
-from .oklch import ACHROMATIC_THRESHOLD
+from .okhsl import toe, toe_inv, find_cusp, to_st, oklab_to_linear_srgb
 import math
 from .. import algebra as alg
 from ..types import Vector
@@ -42,14 +39,14 @@ def okhsv_to_oklab(hsv: Vector) -> Vector:
     """Convert from Okhsv to Oklab."""
 
     h, s, v = hsv
-    h = alg.no_nan(h)
     h = h / 360.0
 
     l = toe_inv(v)
+
     a = b = 0.0
 
     # Avoid processing gray or colors with undefined hues
-    if l != 0.0 and abs(s) >= ACHROMATIC_THRESHOLD and not alg.is_nan(h):
+    if l != 0.0 and s != 0.0:
         a_ = math.cos(2.0 * math.pi * h)
         b_ = math.sin(2.0 * math.pi * h)
 
@@ -92,20 +89,16 @@ def oklab_to_okhsv(lab: Vector) -> Vector:
     """Oklab to Okhsv."""
 
     l = lab[0]
-    h = alg.NaN
     s = 0.0
     v = toe(l)
 
     c = math.sqrt(lab[1] ** 2 + lab[2] ** 2)
-    if c < ACHROMATIC_THRESHOLD:
-        c = 0.0
+    h = 0.5 + 0.5 * math.atan2(-lab[2], -lab[1]) / math.pi
 
-    if l != 0.0 and not abs(1 - l) < 1e-7 and c != 0:
+    if l != 0.0 and l != 1 and c != 0.0:
         a_ = lab[1] / c
         b_ = lab[2] / c
 
-        h = 0.5 + 0.5 * math.atan2(-lab[2], -lab[1]) / math.pi
-
         cusp = find_cusp(a_, b_)
         s_max, t_max = to_st(cusp)
         s_0 = 0.5
@@ -133,13 +126,10 @@ def oklab_to_okhsv(lab: Vector) -> Vector:
         v = l / l_v
         s = (s_0 + t_max) * c_v / ((t_max * s_0) + t_max * k * c_v)
 
-    if abs(s) < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
     return [util.constrain_hue(h * 360), s, v]
 
 
-class Okhsv(Cylindrical, Space):
+class Okhsv(HSV):
     """Okhsv class."""
 
     BASE = "oklab"
@@ -155,16 +145,6 @@ class Okhsv(Cylindrical, Space):
         "saturation": "s",
         "value": "v"
     }
-    WHITE = WHITES['2deg']['D65']
-    GAMUT_CHECK = "srgb"
-
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
-
-        coords = alg.no_nans(coords)
-        if abs(coords[1]) < ACHROMATIC_THRESHOLD or coords[2] == 0.0:
-            coords[0] = alg.NaN
-        return coords
 
     def to_base(self, okhsv: Vector) -> Vector:
         """To Oklab from Okhsv."""
diff --git a/coloraide/spaces/oklab/__init__.py b/coloraide/spaces/oklab/__init__.py
index f3d58877b..046af8ace 100644
--- a/coloraide/spaces/oklab/__init__.py
+++ b/coloraide/spaces/oklab/__init__.py
@@ -26,38 +26,24 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ...spaces import Space, Labish
 from ...cat import WHITES
 from ...channels import Channel, FLG_OPT_PERCENT, FLG_MIRROR_PERCENT
 from ... import algebra as alg
 from ...types import Vector
-
-# sRGB Linear to LMS
-SRGBL_TO_LMS = [
-    [0.41222146947076277, 0.536332537261735, 0.051445993267502196],
-    [0.21190349581782508, 0.6806995506452346, 0.10739695353694051],
-    [0.08830245919005636, 0.2817188391361215, 0.6299787016738223]
-]
-
-# LMS to sRGB Linear
-LMS_TO_SRGBL = [
-    [4.07674163607596, -3.3077115392580643, 0.23096990318210434],
-    [-1.268437973285031, 2.6097573492876878, -0.3413193760026569],
-    [-0.004196076138675668, -0.7034186179359357, 1.707614694074611]
-]
+from ..lab import Lab
 
 # LMS ** 1/3 to Oklab
 LMS3_TO_OKLAB = [
-    [0.2104542553, 0.793617785, -0.0040720468],
-    [1.9779984951, -2.428592205, 0.4505937099],
-    [0.0259040371, 0.7827717662, -0.808675766]
+    [0.21045426824930336, 0.7936177747759865, -0.00407204302528986],
+    [1.977998539071675, -2.4285922502018127, 0.4505937111301374],
+    [0.025904030547901084, 0.7827717270900503, -0.8086757576379514]
 ]
 
 # Oklab to LMS ** 1/3
 OKLAB_TO_LMS3 = [
-    [0.9999999984505206, 0.39633779217376774, 0.21580375806075874],
-    [1.0000000088817604, -0.10556134232365631, -0.06385417477170588],
-    [1.0000000546724108, -0.08948418209496573, -1.2914855378640917]
+    [1.0000000000000002, 0.3963377773761749, 0.2158037573099136],
+    [1.0, -0.10556134581565857, -0.0638541728258133],
+    [0.9999999999999999, -0.08948418498039246, -1.2914855480194092]
 ]
 
 # XYZ D65 to LMS
@@ -75,26 +61,6 @@
 ]
 
 
-def oklab_to_linear_srgb(lab: Vector) -> Vector:
-    """Convert from Oklab to linear sRGB."""
-
-    return alg.dot(
-        LMS_TO_SRGBL,
-        [c ** 3 for c in alg.dot(OKLAB_TO_LMS3, lab, dims=alg.D2_D1)],
-        dims=alg.D2_D1
-    )
-
-
-def linear_srgb_to_oklab(rgb: Vector) -> Vector:  # pragma: no cover
-    """Linear sRGB to Oklab."""
-
-    return alg.dot(
-        LMS3_TO_OKLAB,
-        [alg.cbrt(c) for c in alg.dot(SRGBL_TO_LMS, rgb, dims=alg.D2_D1)],
-        dims=alg.D2_D1
-    )
-
-
 def oklab_to_xyz_d65(lab: Vector) -> Vector:
     """Convert from Oklab to XYZ D65."""
 
@@ -110,12 +76,12 @@ def xyz_d65_to_oklab(xyz: Vector) -> Vector:
 
     return alg.dot(
         LMS3_TO_OKLAB,
-        [alg.cbrt(c) for c in alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)],
+        [alg.nth_root(c, 3) for c in alg.dot(XYZD65_TO_LMS, xyz, dims=alg.D2_D1)],
         dims=alg.D2_D1
     )
 
 
-class Oklab(Labish, Space):
+class Oklab(Lab):
     """Oklab class."""
 
     BASE = "xyz-d65"
diff --git a/coloraide/spaces/oklch/__init__.py b/coloraide/spaces/oklch/__init__.py
index 6a854f1c1..fc107d4ba 100644
--- a/coloraide/spaces/oklch/__init__.py
+++ b/coloraide/spaces/oklch/__init__.py
@@ -24,62 +24,12 @@
 SOFTWARE.
 """
 from __future__ import annotations
-from ...spaces import Space, LChish
+from ..lch import LCh
 from ...cat import WHITES
 from ...channels import Channel, FLG_ANGLE, FLG_OPT_PERCENT
-from ... import util
-import math
-from ... import algebra as alg
-from ...types import Vector
 
-ACHROMATIC_THRESHOLD = 0.000002
-# The transform consistently yields ~90˚ for achromatic hues for positive lightness
-# Replacing achromatic NaN hues with this hue gives us closer translations back.
-# Negative lightness uses ~270, but not worth handling as real world colors don't
-# use inverse lightness.
-ACHROMATIC_HUE = 90.00000025580869
 
-
-def oklab_to_oklch(oklab: Vector) -> Vector:
-    """Oklab to OkLCh."""
-
-    l, a, b = oklab
-
-    c = math.sqrt(a ** 2 + b ** 2)
-    h = math.degrees(math.atan2(b, a))
-
-    # Achromatic colors will often get extremely close, but not quite hit zero.
-    # Essentially, we want to discard noise through rounding and such.
-    if c < ACHROMATIC_THRESHOLD:
-        h = alg.NaN
-
-    return [l, c, util.constrain_hue(h)]
-
-
-def oklch_to_oklab(oklch: Vector) -> Vector:
-    """OkLCh to Oklab."""
-
-    l, c, h = oklch
-
-    # For better round tripping of achromatic colors,
-    # use the achromatic hue that occurs in forward transform.
-    # We use the one from white translation. It may or may not vary slightly
-    # depending on the grayscale color, but only slightly,
-    # so this is close enough.
-    if c < ACHROMATIC_THRESHOLD:
-        h = ACHROMATIC_HUE
-
-    if alg.is_nan(h):  # pragma: no cover
-        return [l, 0.0, 0.0]
-
-    return [
-        l,
-        c * math.cos(math.radians(h)),
-        c * math.sin(math.radians(h))
-    ]
-
-
-class OkLCh(LChish, Space):
+class OkLCh(LCh):
     """OkLCh class."""
 
     BASE = "oklab"
@@ -96,27 +46,3 @@ class OkLCh(LChish, Space):
         "hue": "h"
     }
     WHITE = WHITES['2deg']['D65']
-
-    def achromatic_hue(self) -> float:
-        """Ideal achromatic hue."""
-
-        return ACHROMATIC_HUE
-
-    def normalize(self, coords: Vector) -> Vector:
-        """On color update."""
-
-        coords = alg.no_nans(coords)
-        if coords[1] < ACHROMATIC_THRESHOLD:
-            coords[2] = alg.NaN
-
-        return coords
-
-    def to_base(self, oklch: Vector) -> Vector:
-        """To Lab."""
-
-        return oklch_to_oklab(oklch)
-
-    def from_base(self, oklab: Vector) -> Vector:
-        """To Lab."""
-
-        return oklab_to_oklch(oklab)
diff --git a/coloraide/spaces/orgb.py b/coloraide/spaces/orgb.py
index d170d31ea..12260d235 100644
--- a/coloraide/spaces/orgb.py
+++ b/coloraide/spaces/orgb.py
@@ -75,7 +75,7 @@ class oRGB(Labish, Space):
         "luma": "l"
     }
 
-    def labish_names(self) -> tuple[str, ...]:
+    def names(self) -> tuple[str, ...]:
         """
         Return Lab-ish names in the order L a b.
 
diff --git a/coloraide/spaces/prismatic.py b/coloraide/spaces/prismatic.py
index 2f7c358ff..efd5ea684 100644
--- a/coloraide/spaces/prismatic.py
+++ b/coloraide/spaces/prismatic.py
@@ -11,6 +11,8 @@
 from ..channels import Channel
 from ..cat import WHITES
 from ..types import Vector
+from .. import algebra as alg
+import math
 
 
 def srgb_to_lrgb(rgb: Vector) -> Vector:
@@ -51,6 +53,18 @@ class Prismatic(Space):
     }
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Test if color is achromatic."""
+
+        if math.isclose(0.0, coords[0], abs_tol=1e-4):
+            return True
+
+        white = [1, 1, 1]
+        for x in alg.vcross(coords, white):
+            if not math.isclose(0.0, x, abs_tol=1e-5):
+                return False
+        return True
+
     def to_base(self, coords: Vector) -> Vector:
         """To sRGB."""
 
diff --git a/coloraide/spaces/srgb/__init__.py b/coloraide/spaces/srgb/__init__.py
index 7cd735e50..da09c4c25 100644
--- a/coloraide/spaces/srgb/__init__.py
+++ b/coloraide/spaces/srgb/__init__.py
@@ -1,6 +1,6 @@
 """sRGB color class."""
 from __future__ import annotations
-from ...spaces import Space
+from ...spaces import RGBish, Space
 from ...cat import WHITES
 from ...channels import Channel, FLG_OPT_PERCENT
 from ... import algebra as alg
@@ -44,7 +44,7 @@ def gam_srgb(rgb: Vector) -> Vector:
     return result
 
 
-class sRGB(Space):
+class sRGB(RGBish, Space):
     """sRGB class."""
 
     BASE = "srgb-linear"
@@ -63,6 +63,15 @@ class sRGB(Space):
 
     EXTENDED_RANGE = True
 
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Test if color is achromatic."""
+
+        white = [1, 1, 1]
+        for x in alg.vcross(coords, white):
+            if not math.isclose(0.0, x, abs_tol=1e-5):
+                return False
+        return True
+
     def from_base(self, coords: Vector) -> Vector:
         """From sRGB Linear to sRGB."""
 
diff --git a/coloraide/spaces/xyy.py b/coloraide/spaces/xyy.py
index 78b1c381a..b142cf21d 100644
--- a/coloraide/spaces/xyy.py
+++ b/coloraide/spaces/xyy.py
@@ -9,6 +9,8 @@
 from ..cat import WHITES
 from .. import util
 from ..types import Vector
+from .. import algebra as alg
+import math
 
 
 class xyY(Space):
@@ -24,6 +26,18 @@ class xyY(Space):
     )
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool:
+        """Test if color is achromatic."""
+
+        if math.isclose(0.0, coords[-1], abs_tol=1e-4):
+            return True
+
+        white = [0.3127, 0.3290]
+        for x in alg.vcross(coords[:-1], white):
+            if not math.isclose(0.0, x, abs_tol=1e-6):
+                return False
+        return True
+
     def to_base(self, coords: Vector) -> Vector:
         """To XYZ."""
 
diff --git a/coloraide/spaces/xyz_d65.py b/coloraide/spaces/xyz_d65.py
index 9279ad9cf..13c38b384 100644
--- a/coloraide/spaces/xyz_d65.py
+++ b/coloraide/spaces/xyz_d65.py
@@ -1,12 +1,15 @@
 """XYZ D65 class."""
 from __future__ import annotations
-from ..spaces import Space
+import math
+from ..spaces import Space, RGBish
 from ..cat import WHITES
 from ..channels import Channel
+from .. import util
+from .. import algebra as alg
 from ..types import Vector
 
 
-class XYZD65(Space):
+class XYZD65(RGBish, Space):
     """XYZ D65 class."""
 
     BASE = "xyz-d65"
@@ -19,6 +22,14 @@ class XYZD65(Space):
     )
     WHITE = WHITES['2deg']['D65']
 
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Is achromatic."""
+
+        for x in alg.vcross(coords, util.xy_to_xyz(self.white())):
+            if not math.isclose(0.0, x, abs_tol=1e-5):
+                return False
+        return True
+
     def to_base(self, coords: Vector) -> Vector:
         """
         To XYZ (no change).
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 3a0a327d8..1a2c722fe 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -71,8 +71,10 @@ HPLuv
 HSB
 HSI
 HSL
+HSLish
 HSLuv
 HSV
+HSVish
 HUSL
 HWB
 Horner's
@@ -146,6 +148,7 @@ QCh
 QMh
 Qsh
 RGB
+RGBish
 RLAB
 ROMM
 RYB
@@ -230,6 +233,8 @@ luma
 luminance
 macOS
 memoization
+mixin
+mixins
 monochromacy
 monotonicity
 oRGB
@@ -239,6 +244,7 @@ opto
 performant
 piecewise
 pre
+precalculate
 precalculated
 precalculating
 preformatted
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index f071abe2a..88fba8d73 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,66 @@
 # Changelog
 
+## 2.0
+
+- **BREAK**: `interpolate`, `steps`, `mix`, `filter`, `compose`, and `harmony` will no longer base the output color on
+  the first input color. Colors will be evaluated in the specified color space and be output in that space unless
+  `out_space` is used to specify a specific output color space. For migration, specify the desired `out_space` if the
+  working `space` does not match the desired output.
+
+- **BREAK**: Achromatic and undefined color channel handling has been rewritten. Color space objects no longer utilize
+  the `normalize()` or `achromatic_hue()` method and instead now use a new `is_achromatic()` and `resolve_channel()`
+  methods.
+
+- **NEW**: Expose the new `Color.is_achromatic()` method to tell if colors, even non-cylindrical colors, are achromatic
+  or reasonably close to achromatic.
+
+- **NEW**: Color channel definitions can specify a non-zero default for an undefined channel. Use `resolve_channel()`
+  for more advanced handling.
+
+- **NEW**: CAM16, CAM16 UCS, CAM16 SCD, CAM15 LCD, CAM16 JMh, HCT, Jzazbz, JzCzhz, and IPT all currently require a
+  dynamic approach to detect achromatic colors. Undefined LCh chroma and hue channels and Lab a and b channels can now
+  resolve to non-zero values when undefined for better achromatic interpolation.
+
+- **NEW** (ACEScct) will now resolve undefined color channels (non-alpha) with a non-zero default that represents black
+  as zero is actually out of gamut for that space.
+
+- **NEW**: `filter`, `compose`, and `harmony` all now support the `out_space` parameter.
+
+- **NEW**: All `<space>ish` mixin classes now give access to normalized names and indexes as `names()` and `indexes()`
+  opposed to `<space>ish_names()` etc. Old methods are still available but are deprecated.
+
+- **NEW**: All RGB, HSL, and HSV color spaces are now created with a respective `RGBish`, `HSLish`, and `HSVish` mixin
+  class.
+
+- **NEW**: Separable blend modes will now be evaluated in whatever RGB-ish color space is provided.
+
+- **NEW**: `compose` will throw an error if a non-RGB-ish color space is provided.
+
+- **NEW**: `Color.normalize()` added a new `nans` parameter that when set to `False` will prevent achromatic hue
+  normalization and will just force all channels to be defined.
+
+- **NEW**: `Color.coords()` and `Color.alpha()`, which used to be available during the alpha/beta period have been
+  re-added. `coords()` accesses just the color channels (no alpha channel) while `alpha()` gets the alpha channel.
+
+- **NEW**: Coordinate access functions: `get()`, `set()`, `coords()`, and `alpha()` functions now have a `nans`
+  parameter that when set to `False` will ensure the component(s) is returned as a real number instead of NaN. Set
+  operations only apply this when passing the current value to a callback for relative modification.
+
+- **NEW**: A `norm` parameter is now added to `convert` and `update`. When set to `False`, it will prevent achromatic
+  normalization of hues during conversion. If no conversion is needed, the color is returned as is.
+
+- **NEW**: ColorAide used to gamut map colors such as HSL, HSV, and HWB when interpolating into those spaces. This is no
+  longer done. It is possible to gamut map wider gamuts with these color spaces, so it will be up to the user to apply
+  gamut mapping when it is determined they need it.
+
+- **NEW**: `EXTENDED_RANGE` is no longer needed and is removed from current color space classes.
+
+- **NEW**: Improved accuracy for Oklab, OkLCh, Okhsl, and Okhsv.
+
+- **FIX**: Fix aliases in IPT and IgPgTg.
+
+- **FIX**: Fix some conversion issues with CAM16 based color spaces that was caused due to bad achromatic handling.
+
 ## 1.8.2
 
 - **FIX**: Fix some exception messages.
diff --git a/docs/src/markdown/advanced.md b/docs/src/markdown/advanced.md
index 225308539..c60b24378 100644
--- a/docs/src/markdown/advanced.md
+++ b/docs/src/markdown/advanced.md
@@ -57,7 +57,7 @@ color[:]
 color.convert('prophoto-rgb').convert('srgb')[:]
 ```
 
-### Special Handling: RGB Cylindrical Spaces
+### Special Handling: Cylindrical Spaces
 
 Sometimes, round trip accuracy can be compromised further for practical reasons. A common case where we make compromises
 is with cylindrical color models.
@@ -67,9 +67,9 @@ color using a cylindrical color space.
 
 Achromatic colors do not have a hue, but all conversions end up yielding something for hue, even it it has no practical
 meaning. This can cause odd color shifts when interpolating with an achromatic color. In order to get logical results
-when doing interpolation, we can set achromatic hues to `NaN` (or `none` in CSS), which means the hue is undefined. This
-helps us to identify achromatic cases and helps us to prevent weird color shifts when interpolating between achromatic
-colors. Only if a user manually defines a hue do we respect it.
+when doing interpolation, we detect when a color is achromatic (or very close to achromatic) and set the hues to
+undefined. This helps us to identify achromatic cases and helps us to prevent weird color shifts when interpolating
+between achromatic colors. Only if a user manually defines a hue do we respect it.
 
 ```py play
 Color.interpolate(['lch(75 100 180)', 'lch(75 0 0)'], space='lch')
@@ -79,10 +79,10 @@ Color.interpolate(['lch(75 100 180)', 'lch(75 0 none)'], space='lch')
 Because of [floating point issues](#floating-point-math), conversions to cylindrical color spaces do not always satisfy
 the requirements to be recognized as achromatic colors.
 
-Consider the following example. HSL colors are achromatic when the sRGB color it is derived from has all color channels
-equal to each other. Let's say we convert the color `#!color darkgray` to the XYZ D65 color space and then back again.
-We can see that what was once was a color with all color channels equal to each other is now a color that has color
-channels very nearly equal to each other.
+As an example, HSL colors are achromatic when the sRGB color it is derived from has all color channels equal to each
+other. Let's say we convert the color `#!color darkgray` to the XYZ D65 color space and then back again. We can see that
+what was once was a color with all color channels equal to each other is now a color that has color channels very nearly
+equal to each other.
 
 ```py play
 c1 = Color('darkgray')
@@ -95,98 +95,54 @@ These two colors are intended to be the same, but one satisfies the requirement
 the other does not. This is a case where accuracy vs practicality comes into play. We all know the color is essentially
 still `#!color darkgray`, and that is what the user intends. To allow this to work seamlessly, we apply a little
 leniency to the achromatic rules and state that if the color is very, very close to being achromatic, we will consider
-it achromatic, and we sacrifice a little accuracy to gain practicality. Or maybe it is better say that we compensate for
-the natural inaccuracies that exist.
+it achromatic, and we sacrifice a little accuracy to gain practicality. Or maybe it is better to say that we compensate
+for the natural inaccuracies that exist.
 
 ```py play
 Color('darkgray').convert('hsl')[:-1]
 Color('darkgray').convert('xyz-d65').convert('hsl')[:-1]
 ```
 
-A similar problem can occur with HSL near `#!color white`. If we take white and run it through XYZ, we'll get a value
-not that is not only just slightly not achromatic, but values that are slightly out of range (above 1).
+This problem can exist in various scenarios in pretty much all cylindrical color spaces. Some have tighter algorithms
+and may give really good results with sRGB, but then when converting from some other color space we'll see maybe not as
+tight a translation to and from.
 
-```py play
-c1 = Color('white')
-c1[:-1]
-c2 = c1.convert('xyz-d65').convert('srgb')
-c2[:-1]
-```
-
-Without any correction, we'd get the following:
-
-```py
->>> Color('white').convert('xyz-d65').convert('hsl')[:]
-[60.0, 0.5, 0.9999999999999998, 1.0]
-```
-
-Again, we all know this is `#!color white`, so we relax the rules a bit and say if a value is very, very close to max
-lightness, we will consider saturation to be zero. This provides a little leniency if lightness is slightly over or
-under.
+Additionally, some color spaces have very dynamic achromatic responses, as an interesting example, let's consider CAM16
+JMh. This color space actually has its lower limit for achromatic gradually rise higher and higher as lightness
+increases. Not only that, the achromatic line actually passes mainly through hue ~209.5 for most achromatic colors
+lighter than black.
 
 ```py play
-Color('white').convert('hsl')[:-1]
-Color('white').convert('xyz-d65').convert('hsl')[:-1]
+Color('color(srgb 0 0 0)').convert('cam16-jmh', norm=False)[:]
+Color('color(srgb 0.5 0.5 0.5)').convert('cam16-jmh', norm=False)[:]
+Color('color(srgb 1 1 1)').convert('cam16-jmh', norm=False)[:]
 ```
 
-Not all RGB cylindrical models have the exact same rules, but we apply similar logic to all HSL, HSV, and HWB like
-models. The approach might be slightly different depending on the model, but the idea is the same.
-
-### Special Handling: LCh Cylindrical Spaces
-
-Lastly, LCh like cylindrical models have a similar issues as the RGB cylindrical models. Since the algorithms are
-usually consistent even when extending from SDR to HDR, lightness can usually exceed the upper boundary without the
-algorithm breaking down. As a matter of fact, there are a number of LCh models that are based on HDR color spaces. The
-real issue is that many of these color spaces do not always have their contrast resolve perfectly to their lower limit
-(usually zero) to signify an achromatic color. So, just like with RGB cylindrical models, when contrast gets very, very
-close to the lower limit, we consider the hue to be undefined and set it to `NaN`.
+This can be hard to set a simple chroma check that preserves high accuracy. Simply lowering the chroma or changing the
+hue can make the color no longer achromatic.
 
 ```py play
-Color('darkgray').convert('lch')[:]
+white = Color('color(srgb 1 1 1)')
+white.convert('cam16-jmh', norm=False).convert('srgb').to_string(hex=True)
+white.convert('cam16-jmh', norm=False).set('h', 0).convert('srgb').to_string(hex=True)
+white.convert('cam16-jmh', norm=False).set('m', 0.0).convert('srgb').to_string(hex=True)
 ```
 
-Some may have the bottom chroma limit rise slightly as the achromatic colors approach and surpass `#!color white`. We
-can see this behavior occur with JzCzhz:
+For these types of color spaces, ColorAide will map the achromatic response with a spline and use it as a reference
+to give decent achromatic values for undefined chroma and hue.
 
 ```py play
-Color('color(srgb 0 0 0)').convert('jzczhz')[:]
-Color('color(srgb 0.5 0.5 0.5)').convert('jzczhz')[:]
-Color('color(srgb 1 1 1)').convert('jzczhz')[:]
-Color('color(--rec2100-pq 1 1 1)').convert('jzczhz')[:]
+Color('cam16-jmh', [100, NaN, NaN]).convert('srgb').to_string(hex=True)
+Color('cam16-jmh', [50, NaN, NaN]).convert('srgb').to_string(hex=True)
+Color('cam16-jmh', [20, NaN, NaN]).convert('srgb').to_string(hex=True)
 ```
 
-Generally, as long as the lower chroma limit is quite low, the hue value has little impact on the conversion back and we
-can set a lower threshold such that any chroma that falls below that threshold can be considered achromatic. Generally,
-conversion will be pretty good, but some round trip accuracy is lost when the lower chroma limit is slightly larger.
-
-For spaces that have a larger chroma limit like JzCzhz has, we may even track the ideal hue to use in the conversion
-back from an achromatic color to mitigate round trip errors being introduced. That hue can be acquired from the LCh like
-color space object:
+Depending on how well we can fit the achromatic response, the better the accuracy, but we do purposely allow some wiggle
+room to ensure we can capture achromatic colors within the threshold of the spline's accuracy. This can introduce some
+loss of accuracy, but makes working with achromatic colors in difficult spaces like CAM16 JMh more reasonable.
 
 ```py play
-color = Color('gray').convert('jzczhz')
-color._space.achromatic_hue()
+gray = Color('cam16-jmh', [50, NaN, NaN])
+gray.normalize()
+Color.interpolate([gray, 'green'], space='cam16-jmh')
 ```
-
-There are some slightly more complicated cases, such as CAM16 JMh and HCT (which is based off of CAM16), but the basic
-idea remains relatively the same.
-
-Do keep in mind that there are color space transformations which may vary in precision due to their algorithm. There are
-times when we do all we can, and we can still end up not being able to predict an achromatic color as the
-transformations can cause values to fall right outside our thresholds. CAM16 JMh and HCT (which is derived from CAM16
-JMh) are a bit more difficult to calculate the achromatic threshold to the high degree of precision, and there is only
-so far we are willing to drop accuracy for the sake of convenience, so converting between these cylindrical spaces and
-other cylindrical spaces may not get us the achromatic hues we desire.
-
-Notice in the example below that we get the achromatic hue in all cases except when we convert from `cam16-jmh` to
-`hsl`.
-
-```py play
-Color('gray').convert('cam16-jmh')
-Color('gray').convert('hsl')
-Color('gray').convert('hsl').convert('cam16-jmh')
-Color('gray').convert('cam16-jmh').convert('hsl')
-```
-
-When it comes to cylindrical color spaces, it is best to choose one instead of bouncing between multiple cylindrical
-spaces. If you choose to do so, you may need to manually check for achromatic colors with an even lower threshold.
diff --git a/docs/src/markdown/api/index.md b/docs/src/markdown/api/index.md
index ba0689402..75dac8cb6 100644
--- a/docs/src/markdown/api/index.md
+++ b/docs/src/markdown/api/index.md
@@ -322,7 +322,7 @@ Parameters
     Parameters | Defaults     | Description
     ---------- | -------------| -----------
     `space`    |              | The color space name in which to generate a random color in.
-    `limits`   | `#!py  None` | An optional list of constraints for various color channels. Each entry should either be a sequence contain a minimum and maximum value, or should be `#!py  None`. `#!py None` values will be ignored and the color space's specified channel range will be used instead. Any missing entries will be treated as `#!py None`.
+    `limits`   | `#!py None`  | An optional list of constraints for various color channels. Each entry should either be a sequence contain a minimum and maximum value, or should be `#!py  None`. `#!py None` values will be ignored and the color space's specified channel range will be used instead. Any missing entries will be treated as `#!py None`.
 
 Return
 
@@ -356,6 +356,8 @@ def update(
     color: ColorInput,
     data: VectorLike | None = None,
     alpha: float = util.DEF_ALPHA,
+    *,
+    norm: bool = True,
     **kwargs: Any
 ) -> Color:
     ...
@@ -365,9 +367,10 @@ def update(
 Description
 
 -   The `update` method provides a way to update the underlying color space with coordinates from any color space. The
-    method's signature is the same as [`new`](#new) and accepts color strings, `Color` objects, or raw data points
-    specified with a color space string and coordinates. The object itself will be updated and remain in its current
-    color space.
+    method's signature is the same as [`new`](#new) except that it adds an additional `norm` parameter used to skip
+    achromatic hue normalization when converting to the current color space. The object itself will assume the
+    equivalent color in the current color space that matches the input color's value (assuming no algorithmic
+    limitations preventing an equivalent color).
 
 Parameters
 
@@ -377,6 +380,7 @@ Parameters
     `color`    |              | A color string, or other `Color` class object. If given `data`, a string must be used and should represent the color space to use.
     `data`     | `#!py None`  | `data` accepts a list of numbers representing the coordinates of the color. If provided, `color` must be a string specifying the color space.
     `alpha`    | `#!py 1`     | `alpha` accepts a number specifying the `alpha` channel. Must be used in conjunction with `data` or it will be ignored.
+    `norm`     | `#!py True`  | When set to `#!py False`, this prevents achromatic normalization when updating from a different color space. If no update occurs, nothing is done.
 
 Return
 
@@ -400,8 +404,8 @@ def mutate(
 /// define
 Description
 
--   The `mutate` method is just like [`update`](#update) except that it will not only update the color space, but mutate
-    it to the provided color space.
+-   The `mutate` method is similar to [`update`](#update) except that it does not convert the input color to the current
+    color space, but instead replaces the current color space and values with the input color's color space and values.
 
 Parameters
 
@@ -425,7 +429,8 @@ def convert(
     space: str,
     *,
     fit: bool | str = False,
-    in_place: bool = False
+    in_place: bool = False,
+    norm: bool = True
 ) -> Color:
     ...
 ```
@@ -434,8 +439,8 @@ def convert(
 Description
 
 -   Converts a [`Color`](#color) object from one color space to another. If the current color space matches the
-    specified color space, the object will be cloned. If `in_place` is `#!py True`, the current object will be modified
-    in place.
+    specified color space, a clone of the current color will be returned with no changes to the channel values. If
+    `in_place` is `#!py True`, the current object will be modified in place.
 
 Parameters
 
@@ -445,6 +450,7 @@ Parameters
     `space`    |               | A string representing the desired final color space.
     `fit`      | `#!py False`  | Parameter specifying whether the current color should be gamut mapped into the final, desired color space. If set to `#!py3 True`, the color will be gamut mapped using the default gamut mapping method. If set to a string, the string will be interpreted as the name of the gamut mapping method to be used.
     `in_place` | `#!py False`  | Boolean specifying whether the convert should alter the current [`Color`](#color) object or return a new one.
+    `norm`     | `#!py True`   | When set to `#!py False`, this prevents achromatic normalization when converting from a different color space. If no update occurs, nothing is done.
 
 Return
 
@@ -474,18 +480,27 @@ Return
 ## `#!py Color.normalize` {#normalize}
 
 ```py
-def to_dict(
-    self
-) -> Mapping[str, Any]:
+def normalize(
+    self,
+    *,
+    nans: bool = True
+) -> Color:
     ...
 ```
 
 /// define
 Description
 
--   Force normalization of a color's channels by cleaning up channels that shouldn't be undefined and setting any
-    channels to undefined if they meet the specific color's criteria dictating such, e.g., hue is undefined in HSL when
-    saturation is zero. Normalize modifies the current color in place.
+-   Force normalization of a color's channels by cleaning up channels that setting hue to undefined if the color is
+    achromatic. if `nans` is set to `#!py False`, the hue normalization step (setting hue to undefined) will be skipped.
+    Normalize modifies the current color in place.
+
+Parameters
+
+- 
+    Parameters | Defaults      | Description
+    ---------- | ------------- | -----------
+    `nans`    | `#!py True`    | Perform hue normalization (setting hue to undefined if the color is achromatic).
 
 Return
 
@@ -496,7 +511,9 @@ Return
 
 ```py
 def to_dict(
-    self
+    self,
+    *,
+    nans: bool = True
 ) -> Mapping[str, Any]:
     ...
 ```
@@ -514,6 +531,13 @@ Description
     }
     ```
 
+Parameters
+
+- 
+    Parameters | Defaults      | Description
+    ---------- | ------------- | -----------
+    `nans`     | `#!py True`   | Return channel values having undefined values resolved as defined values.
+
 Return
 
 -   A dictionary containing the color space name and channel values.
@@ -824,7 +848,7 @@ Parameters
     --------------- | ----------------- | -----------
     `colors`        |                   | A list of color strings, [`Color`](#color) objects, dictionaries representing a color, [`stop`](#piecewise) objects, or easing functions.
     `space`         | `#!py "lab"`      | Color space to interpolate in.
-    `out_space`     | `#!py None`       | Color space that the new color should be in. If `#!py None`, the color will be in the same color space as the base color.
+    `out_space`     | `#!py None`       | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified by `space`.
     `progress`      | `#!py None`       | An optional function that that allows for custom logic to perform non-linear interpolation.
     `hue`           | `#!py "shorter"`  | Define how color spaces which have hue angles are interpolated. Default evaluates between the shortest angle.
     `premultiplied` | `#!py True`       | Use premultiplied alpha when interpolating.
@@ -933,6 +957,7 @@ def filter(
     *,
     space: str | None = None,
     in_place: bool = False,
+    out_space: str | None = None,
     **kwargs: Any
 ) -> Color:
     ...
@@ -970,13 +995,14 @@ Description
 Parameters
 
 - 
-    Parameters                 | Defaults                           | Description
-    -------------------------- | ---------------------------------- | -----------
-    `name`                     |                                    | The name of the filter that should be applied.
-    `amount`                   | See\ above                         | A numerical value adjusting to what degree the filter is applied. Input range can vary depending on the filter being used. Default can also dependent on the filter being used.
-    `space`                    | `#!py3 None`                       | Controls the algorithm used for simulating the given CVD.
-    `in_place`                 | `#!py3 False`                      | Boolean used to determine if the the current color should be modified "in place" or a new [`Color`](#color) object should be returned. 
-    `**kwargs`                 |                                    | Additional filter specific parameters.
+    Parameters  | Defaults       | Description
+    ----------- | ---------------| -----------
+    `name`      |                | The name of the filter that should be applied.
+    `amount`    | See\ above     | A numerical value adjusting to what degree the filter is applied. Input range can vary depending on the filter being used. Default can also dependent on the filter being used.
+    `space`     | `#!py3 None`   | Controls the algorithm used for simulating the given CVD.
+    `in_place`  | `#!py3 False`  | Boolean used to determine if the the current color should be modified "in place" or a new [`Color`](#color) object should be returned. 
+    `out_space` | `#!py None`    | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified via `space`.
+    `**kwargs`  |                | Additional filter specific parameters.
 
     CVDs also take an optional `method` parameter that allows for specifying the CVD algorithm to use.
 
@@ -999,7 +1025,8 @@ def harmony(
     self,
     name: str,
     *,
-    space: str | None = None
+    space: str | None = None,
+    out_space: str | None = None
 ) -> list[Color]:
     ...
 ```
@@ -1024,10 +1051,11 @@ Description
 Parameters
 
 - 
-    Parameters                 | Defaults                           | Description
-    -------------------------- | ---------------------------------- | -----------
-    `name`                     |                                    | Name of the color harmony to use.
-    `space`                    | `#!py 'oklch'`                     | Color space under which the harmonies will be calculated. Must be a cylindrical space.
+    Parameters  | Defaults       | Description
+    ----------- | ---------------| -----------
+    `name`      |                | Name of the color harmony to use.
+    `space`     | `#!py 'oklch'` | Color space under which the harmonies will be calculated. Must be a cylindrical space.
+    `out_space` | `#!py None`    | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified via `space`.
 
 Return
 
@@ -1082,14 +1110,14 @@ Description
 Parameters
 
 - 
-    Parameters                 | Defaults                           | Description
-    -------------------------- | ---------------------------------- | -----------
-    `backdrop`                 |                                    | A background color represented with either a string or [`Color`](#color) object.
-    `blend`                    | `#!py3 None`                       | A blend mode to use to use when compositing. Values should be a string specifying the name of the blend mode to use. If `#!py None`, [`normal`](#normal) will be used. If `#!py False`, blending will be skipped.
-    `operator`                 | `#!py3 None`                       | A Porter Duff operator to use for alpha compositing. Values should be a string specifying the name of the operator to use. If `#!py None`, [`source-over`](#source-over) will be used. If `#!py False`, alpha compositing will be skipped.
-    `space`                    | `#!py3 None`                       | A color space to perform the overlay in. If `#!py None`, the base color's space will be used.
-    `out_space`                | `#!py3 None`                       | A color space to output the resultant color to.
-    `in_place`                 | `#!py3 False`                      | Boolean used to determine if the the current color should be modified "in place" or a new [`Color`](#color) object should be returned.
+    Parameters  | Defaults       | Description
+    ----------- | -------------- | -----------
+    `backdrop`  |                | A background color represented with either a string or [`Color`](#color) object.
+    `blend`     | `#!py3 None`   | A blend mode to use to use when compositing. Values should be a string specifying the name of the blend mode to use. If `#!py None`, [`normal`](#normal) will be used. If `#!py False`, blending will be skipped.
+    `operator`  | `#!py3 None`   | A Porter Duff operator to use for alpha compositing. Values should be a string specifying the name of the operator to use. If `#!py None`, [`source-over`](#source-over) will be used. If `#!py False`, alpha compositing will be skipped.
+    `space`     | `#!py3 None`   | A color space to perform the overlay in. If `#!py None`, the base color's space will be used.
+    `out_space` | `#!py None`    | Color space that the new color should be in. If `#!py None`, the return color will be in the same color space as specified by `space`.
+    `in_place`  | `#!py3 False`  | Boolean used to determine if the the current color should be modified "in place" or a new [`Color`](#color) object should be returned.
 
 Return
 
@@ -1202,7 +1230,9 @@ Return
 ```py
 def get(
     self,
-    name: str | list[str] | tuple[str, ...]
+    name: str | list[str] | tuple[str, ...],
+    *,
+    nans: bool = True
 ) -> float | list[float]:
 ```
 
@@ -1219,11 +1249,12 @@ Parameters
     Parameters | Defaults           | Description
     ---------- | ------------------ | -----------
     `name`     |                    | Channel name or sequence of channel names. Channel names can define the color space and channel name to retrieve value from a different color space.
-
+    `nans`     | `#!py True`        | Determines whether an undefined value is allowed to be returned. If disabled, undefined values will be resolved before returning.
 Return
 
 -   Returns a numerical value that is stored internally for the specified channel, or a calculated value in the case
-    that a channel in a different color space is requested.
+    that a channel in a different color space is requested. If more than one value is requested, the a list of numerical
+    values will be returned.
 ///
 
 ## `#!py Color.set` {#set}
@@ -1232,7 +1263,9 @@ Return
 def set(
     self,
     name: str | dict[str, float | Callable[..., float]],
-    value: float | Callable[..., float] | None = None
+    value: float | Callable[..., float] | None = None,
+    *,
+    nans: bool = True
 ) -> Color:
 ```
 
@@ -1258,12 +1291,91 @@ Parameters
     ---------- | ------------------ | -----------
     `name`     |                    | A string containing a channel name or color space and channel separated by a `.` specifying the what channel to set. If `value` is omitted, `name` can also be a dictionary containing multiple channels, each specifying their own value to set.
     `value`    |                    | A numerical value, a string value accepted by the specified color space, or a function.
+    `nans`     | `#!py True`        | When doing relative sets via a callback input, ensure the channel value passed to the callback is a real number, not an undefined value.
 
 Return
 
 -   Returns a reference to the current [`Color`](#color) object.
 ///
 
+## `#!py Color.coords` {#coords}
+
+```py
+def coords(
+    self,
+    *,
+    nans: bool = True
+) -> Vector:
+    ...
+```
+
+/// define
+Description
+
+-   Get the color channels (no alpha). If `nans` is set to `#!py False`, all undefined values will be returned as
+    defined.
+
+Parameters
+
+- 
+    Parameters | Defaults     | Description
+    ---------- | ------------ | -----------
+    `nans`     | `#!py True`  | If `nans` is set to `#!py False`, all undefined values will be returned as defined.
+
+Return
+
+-   Returns a list of `#!py float` values, one for each color channel.
+
+///
+
+## `#!py Color.alpha` {#coords}
+
+```py
+def alpha(
+    self,
+    *,
+    nans: bool = True
+) -> float:
+    ...
+```
+
+/// define
+Description
+
+-   Get the alpha channel's value. If `nans` is set to `#!py False`, an undefined value will be returned as defined.
+
+Parameters
+
+- 
+    Parameters | Defaults     | Description
+    ---------- | ------------ | -----------
+    `nans`    | `#!py True`  | If `nans` is set to `#!py False`, an undefined value will be returned as defined.
+
+Return
+
+-   Returns a `#!py float`.
+///
+
+## `#!py Color.is_achromatic` {#is_achromatic}
+
+```py
+def is_achromatic(
+    self
+) -> bool:
+    ...
+```
+
+/// define
+Description
+
+-   Can be called on any color to determine if the color is achromatic. If a color is achromatic, or very close to
+    achromatic, it will return `#!py True`.
+
+Return
+
+-   Returns a boolean indicating whether the color is achromatic.
+///
+
 ## `#!py Color.is_nan` {#is_nan}
 
 ```py
@@ -1277,9 +1389,9 @@ def is_nan(
 /// define
 Description
 
--   Retrieves the coordinate value from the specified channel and checks whether the value is `NaN`. Channel must be a
-    channel name in the current color space or a channel name in the specified color space using the syntax:
-    `space.channel`.
+-   Retrieves the coordinate value from the specified channel and checks whether the value is undefined (set to NaN).
+    Channel must be a channel name in the current color space or a channel name in the specified color space using the
+    syntax: `space.channel`.
 
 Parameters
 
diff --git a/docs/src/markdown/color.md b/docs/src/markdown/color.md
index ada17d005..5d577aca5 100644
--- a/docs/src/markdown/color.md
+++ b/docs/src/markdown/color.md
@@ -1,11 +1,11 @@
 # The Color Object
 
 The `Color` object is where all the magic of ColorAide happens and provides access to all the color manipulation
-methods available. The `Color` object is used to represent a given color within a particular color space, and in order
+methods available. The `Color` object is used to represent a given color within a particular color space. In order
 to perform most operations, you will need to create a color instance to begin.
 
-There are a number of ways to instantiate new colors. Here we will cover basic creating, cloning, and updating of the
-`Color` class object and a few other class specific topics.
+There are a number of ways to instantiate new colors. Here we will cover the basics of creating colors, cloning colors,
+converting colors, and a few other `Color` class specific topics.
 
 ## Importing
 
@@ -244,7 +244,18 @@ ends up being much more efficient!
 import re
 from coloraide import Color
 
-RE_COLOR_START = re.compile(r"(?i)(?:\b(?<![-#&$])(?:color\((?!\s*-)|(?:hsla?|lch|lab|hwb|rgba?)\()|\b(?<![-#&$])[\w]{3,}(?![(-])\b|(?<![&])#)")
+RE_COLOR_START = re.compile(
+    r"""(?ix)
+    (?:
+        # CSS functions
+        \b(?<![-#&$])(?:color\((?!\s*-)|(?:hsla?|(?:ok)?(?:lch|lab)|hwb|rgba?)\()|
+        # Color words
+        \b(?<![-#&$])[\w]{3,}(?![(-])\b|
+        # Hex codes
+        (?<![&])\#
+    )
+    """
+)
 
 text = """
 <html>
diff --git a/docs/src/markdown/colors/hct.md b/docs/src/markdown/colors/hct.md
index d19455975..b3f16bb12 100644
--- a/docs/src/markdown/colors/hct.md
+++ b/docs/src/markdown/colors/hct.md
@@ -88,7 +88,7 @@ palettes just like in Material's color utilities.
 ```py play
 c = Color('hct', [325, 24, 50])
 tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
-HtmlSteps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit='hct-chroma') for tone in tones])
+Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit='hct-chroma') for tone in tones])
 ```
 
 Results in our library may be slightly different in some cases compared to Material's color utilities. This is because
@@ -112,14 +112,14 @@ material1 = ['#000000', '#00006e', '#0001ac',
              '#7c84ff', '#9da3ff', '#bec2ff',
              '#e0e0ff', '#f1efff', '#ffffff']
 c = Color('blue').convert('hct')
-HtmlSteps([x.to_string() for x in tonal_palette(c)])
-HtmlSteps([Color(x).convert('hct').to_string() for x in material1])
+Steps([x.to_string() for x in tonal_palette(c)])
+Steps([Color(x).convert('hct').to_string() for x in material1])
 
 material2 = ['#000000', '#191a2c', '#2e2f42',
              '#444559', '#5c5d72', '#75758b',
              '#8f8fa6', '#a9a9c1', '#c5c4dd',
              '#e1e0f9', '#f1efff', '#ffffff']
 c['chroma'] = 16
-HtmlSteps([x.to_string() for x in tonal_palette(c)])
-HtmlSteps([Color(x).convert('hct').to_string() for x in material2])
+Steps([x.to_string() for x in tonal_palette(c)])
+Steps([Color(x).convert('hct').to_string() for x in material2])
 ```
diff --git a/docs/src/markdown/compositing.md b/docs/src/markdown/compositing.md
index 9a9a9c6d2..c99746b84 100644
--- a/docs/src/markdown/compositing.md
+++ b/docs/src/markdown/compositing.md
@@ -51,6 +51,14 @@ c1.compose(c2, blend='multiply', space="srgb")
 ```
 ///
 
+/// tip
+`compose()` can output the results in any color space you need by setting `out_space`.
+
+```py play
+Color('#07c7ed').compose(Color('#fc3d99'), blend='multiply', space='srgb', out_space='hsl')
+```
+///
+
 /// note | Display Differences
 As some browsers apply compositing based on the display's current color space, we've provided examples in both sRGB
 and Display P3 so that the examples can be compared on different displays. Which of the above matches your browser?
diff --git a/docs/src/markdown/filters.md b/docs/src/markdown/filters.md
index b0d35484a..e8a743c94 100644
--- a/docs/src/markdown/filters.md
+++ b/docs/src/markdown/filters.md
@@ -52,17 +52,25 @@ according to the W3C spec will be used instead.
 ```py play
 inputs = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
 colors = Color.steps(inputs, steps=10, space='srgb')
-HtmlSteps(colors)
-HtmlSteps([c.filter('brightness', 0.5).clip() for c in colors])
-HtmlSteps([c.filter('saturate', 0.5).clip() for c in colors])
-HtmlSteps([c.filter('contrast', 1.2).clip() for c in colors])
-HtmlSteps([c.filter('opacity', 0.5).clip() for c in colors])
-HtmlSteps([c.filter('invert', 1).clip() for c in colors])
-HtmlSteps([c.filter('hue-rotate', 90).clip() for c in colors])
-HtmlSteps([c.filter('sepia', 1).clip() for c in colors])
-HtmlSteps([c.filter('grayscale', 1).clip() for c in colors])
+Steps(colors)
+Steps([c.filter('brightness', 0.5).clip() for c in colors])
+Steps([c.filter('saturate', 0.5).clip() for c in colors])
+Steps([c.filter('contrast', 1.2).clip() for c in colors])
+Steps([c.filter('opacity', 0.5).clip() for c in colors])
+Steps([c.filter('invert', 1).clip() for c in colors])
+Steps([c.filter('hue-rotate', 90).clip() for c in colors])
+Steps([c.filter('sepia', 1).clip() for c in colors])
+Steps([c.filter('grayscale', 1).clip() for c in colors])
 ```
 
+/// tip
+`filter()` can output the results in any color space you need by setting `out_space`.
+
+```py play
+Color('#07c7ed').filter('grayscale', 1, out_space='hsl')
+```
+///
+
 ## Color Vision Deficiency Simulation
 
 Color blindness or color vision deficiency (CVD) affects approximately 1 in 12 men (8%) and 1 in 200 women. CVD affects
@@ -125,13 +133,13 @@ appear to be of the same color making it difficult to distinguish between any of
 --8<-- "confusion_lines.md"
 
 confusing_colors = confusion_line(Color('orange'), 'l')
-HtmlSteps([c.clip() for c in confusing_colors])
+Steps([c.clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('hotpink'), 'm')
-HtmlSteps([c.clip() for c in confusing_colors])
+Steps([c.clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('seagreen'), 's')
-HtmlSteps([c.clip() for c in confusing_colors])
+Steps([c.clip() for c in confusing_colors])
 ```
 ///
 
@@ -141,13 +149,13 @@ HtmlSteps([c.clip() for c in confusing_colors])
 ---8<-- "confusion_lines.md"
 
 confusing_colors = confusion_line(Color('orange'), 'l')
-HtmlSteps([c.filter('protan').clip() for c in confusing_colors])
+Steps([c.filter('protan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('hotpink'), 'm')
-HtmlSteps([c.filter('protan').clip() for c in confusing_colors])
+Steps([c.filter('protan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('seagreen'), 's')
-HtmlSteps([c.filter('protan').clip() for c in confusing_colors])
+Steps([c.filter('protan').clip() for c in confusing_colors])
 ```
 ///
 
@@ -157,13 +165,13 @@ HtmlSteps([c.filter('protan').clip() for c in confusing_colors])
 ---8<-- "confusion_lines.md"
 
 confusing_colors = confusion_line(Color('orange'), 'l')
-HtmlSteps([c.filter('deutan').clip() for c in confusing_colors])
+Steps([c.filter('deutan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('hotpink'), 'm')
-HtmlSteps([c.filter('deutan').clip() for c in confusing_colors])
+Steps([c.filter('deutan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('seagreen'), 's')
-HtmlSteps([c.filter('deutan').clip() for c in confusing_colors])
+Steps([c.filter('deutan').clip() for c in confusing_colors])
 ```
 ///
 
@@ -173,13 +181,13 @@ HtmlSteps([c.filter('deutan').clip() for c in confusing_colors])
 ---8<-- "confusion_lines.md"
 
 confusing_colors = confusion_line(Color('orange'), 'l')
-HtmlSteps([c.filter('tritan').clip() for c in confusing_colors])
+Steps([c.filter('tritan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('hotpink'), 'm')
-HtmlSteps([c.filter('tritan').clip() for c in confusing_colors])
+Steps([c.filter('tritan').clip() for c in confusing_colors])
 
 confusing_colors = confusion_line(Color('seagreen'), 's')
-HtmlSteps([c.filter('tritan').clip() for c in confusing_colors])
+Steps([c.filter('tritan').clip() for c in confusing_colors])
 ```
 ///
 
@@ -190,10 +198,10 @@ probably the best approach for all cases, Viénot is much faster and does quite
 ```py play
 inputs = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
 colors = Color.steps(inputs, steps=10, space='srgb')
-HtmlSteps(colors)
-HtmlSteps([c.filter('protan').clip() for c in colors])
-HtmlSteps([c.filter('deutan').clip() for c in colors])
-HtmlSteps([c.filter('tritan').clip() for c in colors])
+Steps(colors)
+Steps([c.filter('protan').clip() for c in colors])
+Steps([c.filter('deutan').clip() for c in colors])
+Steps([c.filter('tritan').clip() for c in colors])
 ```
 
 If desired, any of the three available methods can be used. Brettel is usually considered best option for accuracy.
@@ -204,10 +212,10 @@ tritanopia.
 ```py play
 inputs = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
 colors = Color.steps(inputs, steps=10, space='srgb')
-HtmlSteps(colors)
-HtmlSteps([c.filter('tritan', method='brettel').clip() for c in colors])
-HtmlSteps([c.filter('tritan', method='vienot').clip() for c in colors])
-HtmlSteps([c.filter('tritan', method='machado').clip() for c in colors])
+Steps(colors)
+Steps([c.filter('tritan', method='brettel').clip() for c in colors])
+Steps([c.filter('tritan', method='vienot').clip() for c in colors])
+Steps([c.filter('tritan', method='machado').clip() for c in colors])
 ```
 
 ### Anomalous Trichromacy
@@ -248,10 +256,10 @@ results. With that said, the `method` can always be overridden to use something
 ```py play
 inputs = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
 colors = Color.steps(inputs, steps=10, space='srgb')
-HtmlSteps(colors)
-HtmlSteps([c.filter('protan', 0.3).clip() for c in colors])
-HtmlSteps([c.filter('protan', 0.5).clip() for c in colors])
-HtmlSteps([c.filter('protan', 0.9).clip() for c in colors])
+Steps(colors)
+Steps([c.filter('protan', 0.3).clip() for c in colors])
+Steps([c.filter('protan', 0.5).clip() for c in colors])
+Steps([c.filter('protan', 0.9).clip() for c in colors])
 ```
 
 ## Usage Details
@@ -281,9 +289,9 @@ designed to be applied in the Linear sRGB space, and cannot be used in any other
 ```py play
 inputs = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
 colors = Color.steps(inputs, steps=10, space='srgb')
-HtmlSteps(colors)
-HtmlSteps([c.filter('sepia', 1, space='srgb-linear').clip() for c in colors])
-HtmlSteps([c.filter('sepia', 1, space='srgb').clip() for c in colors])
+Steps(colors)
+Steps([c.filter('sepia', 1, space='srgb-linear').clip() for c in colors])
+Steps([c.filter('sepia', 1, space='srgb').clip() for c in colors])
 ```
 
 /// tip | Processing Lots of Colors
diff --git a/docs/src/markdown/gamut.md b/docs/src/markdown/gamut.md
index 5ec2a90af..d9e09067c 100644
--- a/docs/src/markdown/gamut.md
+++ b/docs/src/markdown/gamut.md
@@ -105,9 +105,9 @@ hsl.to_string(fit=False)
 hsl.in_gamut('srgb')
 ```
 
-This happens because these cylindrical color models do not represent colors out of gamut in a very sane way. They are
-simply not designed to extend past the color gamut. So even a slightly out of gamut sRGB color can translate to a value
-way outside the cylindrical color model's boundaries.
+This happens because these cylindrical color models do not represent colors in a very sane way when lightness exceeds
+the SDR range of 0 - 1. They are simply not designed to extend past such limits in a sane way. So even a slightly out of
+gamut sRGB color _could_ translate to a value way outside the cylindrical color model's boundaries.
 
 For this reason, gamut checks in the HSL, HSV, or HWB models apply tolerance checks on the color's coordinates in the
 sRGB color space **and** the respective cylindrical model ensuring we have coordinates that are close to the color's
@@ -282,7 +282,7 @@ can allow creating good tonal palettes:
 ```py play
 c = Color('hct', [325, 24, 50])
 tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
-HtmlSteps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit='hct-chroma') for tone in tones])
+Steps([c.clone().set('tone', tone).convert('srgb').to_string(hex=True, fit='hct-chroma') for tone in tones])
 ```
 
 To HCT Chroma plugin is not registered by default, but can be added by subclassing `Color`. You must register the
@@ -333,12 +333,12 @@ these dark colors and makes them reddish. This is a very undesirable outcome.
 # Gamut mapping in LCh
 yellow = Color('color(display-p3 1 1 0)')
 lightness_mask = Color('lch(0% none none)')
-HtmlRow([c.fit('srgb') for c in Color.steps([yellow, lightness_mask], steps=10, space='lch')])
+Row([c.fit('srgb') for c in Color.steps([yellow, lightness_mask], steps=10, space='lch')])
 
 # Clipping
 yellow = Color('color(display-p3 1 1 0)')
 lightness_mask = Color('lch(0% none none)')
-HtmlRow([c.clip('srgb') for c in Color.steps([yellow, lightness_mask], steps=10, space='lch')])
+Row([c.clip('srgb') for c in Color.steps([yellow, lightness_mask], steps=10, space='lch')])
 ```
 
 There are times when clipping is simply preferred. It is fast, and if you are just trimming noise off channels, it is
diff --git a/docs/src/markdown/harmonies.md b/docs/src/markdown/harmonies.md
index e12f8cb5e..30dccf0e4 100644
--- a/docs/src/markdown/harmonies.md
+++ b/docs/src/markdown/harmonies.md
@@ -36,7 +36,7 @@ primary colors of light. Starting with red (0˚), we could extract the colors at
 This would give us our 12 colors for the sRGB color space.
 
 ```py play
-HtmlSteps([Color('hsl', [x, 1, 0.5]) for x in range(0, 360, 30)])
+Steps([Color('hsl', [x, 1, 0.5]) for x in range(0, 360, 30)])
 ```
 
 From this we can construct an sRGB color wheel.
@@ -50,7 +50,7 @@ If we were to select the perceptually uniform OkLCh color space, and seed it wit
 
 ```py play
 c = Color('red').convert('oklch', in_place=True)
-HtmlSteps([Color('oklch', [*c[0:2], x]) for x in range(0, 360, 30)])
+Steps([Color('oklch', [*c[0:2], x]) for x in range(0, 360, 30)])
 ```
 
 This produces colors with visually more uniform lightness, does that mean these are better?
@@ -67,19 +67,28 @@ mainly on the fact it keeps hue more consistent than some other options, and it
 options like HSL.
 
 ```py play
-HtmlSteps(Color.steps(['black', 'blue', 'white'], steps=11, space='oklch'))
-HtmlSteps(Color.steps(['black', 'blue', 'white'], steps=11, space='hsl'))
-HtmlSteps(Color.steps(['black', 'blue', 'white'], steps=11, space='lch'))
+Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='oklch'))
+Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='hsl'))
+Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='lch'))
 ```
 
 While OkLCh is the default, we understand that there are many reasons to use other spaces, so use what you like, we
 won't judge :smile:.
 
 ```py play
-HtmlSteps(Color('red').harmony('complement'))
-HtmlSteps(Color('red').harmony('complement', space='hsl'))
+Steps(Color('red').harmony('complement'))
+Steps(Color('red').harmony('complement', space='hsl'))
 ```
 
+/// tip
+`harmony()` can output the results in any color space you need by setting `out_space`.
+
+```py play
+Steps(Color('red').harmony('complement', out_space='srgb'))
+```
+///
+
+
 ## Supported Harmonies
 
 ColorAide currently supports 7 theorized color harmonies: [monochromatic](#monochromatic),
@@ -106,7 +115,7 @@ The monochromatic harmony pairs various tints and shades of a color together to
 ![Harmony Monochromatic](images/harmony-mono.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('mono'))
+Steps(Color('red').harmony('mono'))
 ```
 
 /// note | Achromatic Colors
@@ -121,7 +130,7 @@ Complementary harmonies use a dyad of colors at opposite ends of the color wheel
 ![Harmony Complementary](images/harmony-complement.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('complement'))
+Steps(Color('red').harmony('complement'))
 ```
 
 ### Split Complementary
@@ -132,7 +141,7 @@ complement, it splits and chooses two colors on the opposite side that are close
 ![Harmony Split Complementary](images/harmony-split-complement.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('split'))
+Steps(Color('red').harmony('split'))
 ```
 
 ### Analogous
@@ -142,7 +151,7 @@ Analogous harmonies consists of 3 adjacent colors.
 ![Harmony Analogous](images/harmony-analogous.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('analogous'))
+Steps(Color('red').harmony('analogous'))
 ```
 
 ### Triadic
@@ -153,7 +162,7 @@ harmony.
 ![Harmony Triadic](images/harmony-triadic.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('triad'))
+Steps(Color('red').harmony('triad'))
 ```
 
 ### Tetradic Square
@@ -164,7 +173,7 @@ between four colors on the color wheel.
 ![Harmony Tetradic](images/harmony-tetradic.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('square'))
+Steps(Color('red').harmony('square'))
 ```
 
 ### Tetradic Rectangular
@@ -175,7 +184,7 @@ four colors instead of a square.
 ![Harmony Tetradic Rectangular](images/harmony-tetradic-rect.png)
 
 ```py play
-HtmlSteps(Color('red').harmony('rectangle'))
+Steps(Color('red').harmony('rectangle'))
 ```
 
 ## Changing the Default Harmony Color Space
@@ -189,5 +198,5 @@ harmony calculations will use the specified color space unless overridden via th
 class Custom(Color):
     HARMONY = 'hsl'
 
-HtmlSteps(Custom('red').harmony('split'))
+Steps(Custom('red').harmony('split'))
 ```
diff --git a/docs/src/markdown/interpolation.md b/docs/src/markdown/interpolation.md
index f119c8014..75f6bd638 100644
--- a/docs/src/markdown/interpolation.md
+++ b/docs/src/markdown/interpolation.md
@@ -56,14 +56,6 @@ Color.interpolate(
 )
 ```
 
-/// tip | Interpolating in Constrained Gamuts
-Some color spaces, like sRGB, have a limited gamut, but have extended ranges that allow them to represent out of
-gamut colors in a sane way. Other models, like HSL, HSV, and HWB have the same limited gamut, but have no sane way
-to represent out of gamut colors. If colors are requested to be interpolated and are too big to be interpolated
-in the requested color space and cannot properly be represented in that space or model, the colors will be gamut
-mapped before interpolation.
-///
-
 ## Piecewise Interpolation
 
 Piecewise interpolation takes the idea of linear interpolation and then applies it to multiple colors. As drawing a
@@ -740,9 +732,9 @@ to -0.5 and 1.5.
 ```py play
 offset, factor = 0.25, 1.5
 i = Color.interpolate(['red', 'blue'])
-HtmlGradient([i((r * factor / 100) - offset) for r in range(101)])
+Ramp([i((r * factor / 100) - offset) for r in range(101)])
 i = Color.interpolate(['red', 'blue'], extrapolate=True)
-HtmlGradient([i((r * factor / 100) - offset) for r in range(101)])
+Ramp([i((r * factor / 100) - offset) for r in range(101)])
 ```
 
 Lastly, it is important to note that this affects stops as well, mainly stops applied to interpolation endpoints. When
diff --git a/docs/src/markdown/manipulation.md b/docs/src/markdown/manipulation.md
index 3ca0fca96..ad9cbd41f 100644
--- a/docs/src/markdown/manipulation.md
+++ b/docs/src/markdown/manipulation.md
@@ -82,6 +82,33 @@ color[:-1] = [0, 0, 1]
 color
 ```
 
+### Access by Type
+
+/// new | New 2.0
+///
+
+When dealing with colors, you have two types of channels: color channels and an alpha channel. These values can be
+accessed and separated by [slicing](#access-by-slicing) as mentioned earlier, but some convenience functions have
+been added to make this easier. `coords()` and `alpha()` will retrieve the color channels and the alpha channel
+respectively.
+
+```py play
+color = Color("srgb", [1, 0, 1], 0.5)
+color
+color.alpha()
+color.coords()
+```
+
+In addition, both of these functions offer a special parameter `nans` that controls whether undefined values are
+returned as specified or whether they are resolved to defined values.
+
+```py play
+color = Color("hsl", [NaN, 0, 0.75], 0.5)
+color
+color.coords()
+color.coords(nans=False)
+```
+
 ### Access By Functions
 
 Colors can also be accessed and modified in more advanced ways with special access functions `get()` and `set()`.
@@ -161,41 +188,26 @@ and then converts it back making it susceptible to any possible [round trip erro
 
 ## Undefined Values
 
-Colors in general can sometimes have undefined channels. This can actually happen in a number of ways.
+Colors can sometimes have undefined channels. This can actually happen in a number of ways. In almost all cases,
+undefined values are generated or manually inserted in order to help out with [interpolation](./interpolation.md).
 
-1. Channels can naturally be undefined under certain situations as defined by the color space. For instance, spaces
-   with hues will have powerless hues when the color is achromatic. As an example, this can occur if saturation or
-   chroma is zero.
-
-    If an achromatic color manually has its hue defined, then the hue is considered defined, though that value will
-    still be powerless during conversions. But lets considered an achromatic color in a rectangular color space being
-    converted to a cylindrical color space with hue. During the conversion process, there is nothing to suggest to the
-    algorithm what the hue should be. For instance, if saturation is zero, one could argue the hue should be `0`, but
-    that is actually a red hue, and achromatic colors have no hue. In the end, no hue is actually satisfactory, so an
-    undefined hue is applied.
+1. Hues can naturally become undefined if the color is [achromatic](#normalizing-achromatic-colors).
 
     ```py play
     color = Color('white').convert('hsl')
     color[:]
     ```
 
-2. When specifying raw data, and an insufficient amount of channel data is provided, the missing channels will be
-   assumed as undefined, the exception is the `alpha` channel which is assumed to be `1` unless explicitly defined or
-   explicitly set as undefined.
+2. When specifying raw data, channels can be explicitly set to undefined, and when an insufficient amount of channel
+   data is provided, the missing channels will be assumed as undefined, the exception is the `alpha` channel which is
+   assumed to be `1` unless explicitly defined or explicitly set as undefined.
 
     ```py play
     Color('srgb', [1])[:]
     Color('srgb', [1, 0, 0], NaN)[:]
     ```
 
-3. Undefined values can also occur when a user specifies a channel with the `none` keyword in CSS syntax. This can also
-   be done in raw color data by directly passing `#!py3 float('nan')` -- the provided `NaN` constant is essentially an
-   alias for this.
-
-    One may question why such a thing would ever be desired, but this can be quite useful when interpolating as
-    undefined channels will not be interpolated. It can be thought of as a way to mask off channels. Checkout the
-    [Interpolation](./interpolation.md) section in the documentation to learn more.
-
+3. Undefined values can also occur when a user specifies a channel with the `none` keyword in CSS syntax.
 
     ```py play
     from coloraide import NaN
@@ -229,8 +241,9 @@ Colors in general can sometimes have undefined channels. This can actually happe
 
 ### Checking for Undefined Values
 
-As previously mentioned, a color channel can be undefined for a number of reasons. And in cases such as interpolation,
-undefined values can even be useful. On the other hand, sometimes an undefined value may need to be handled special.
+As [previously mentioned](#undefined-values), a color channel can be undefined for a number of reasons. And in cases
+such as [interpolation](./interpolation.md), undefined values can even be useful. On the other hand, sometimes knowing
+there is an undefined value or being able to ignore it can be useful.
 
 Undefined values are represented as the float value `NaN`. And since `NaN` values are not numbers -- hence the name "not
 a number" -- they don't quite work the same as normal numbers. They don't contribute to math operations like add,
@@ -246,10 +259,11 @@ color = Color('color(srgb 1 none 1)')
 color['green'] + 0.5
 ```
 
-Because a `NaN` may cause surprising results, it can be useful to check if a hue (or any channel) is `NaN` before
-applying certain operations where `NaN` may be undesirable, especially if the color potentially came from an unknown
-source. To make checking for `NaN`s easy, the convenience function `is_nan` has been made available. You can simply give
-`is_nan` the property you wish to check, and it will return either return `#!py3 True` or `#!py3 False`.
+Because a `NaN` (or undefined value) may cause surprising results, it can be useful to check if a hue (or any channel)
+is undefined before applying certain operations where such a value may be undesirable, especially if the color
+potentially came from an unknown source. To make checking for undefined values easy, the convenience function
+`is_nan` has been made available. You can simply give `is_nan` the property you wish to check, and it will return
+either `#!py3 True` or `#!py3 False`.
 
 ```py play
 Color('hsl(none 0% 100%)').is_nan('hue')
@@ -261,3 +275,162 @@ This is equivalent to using the `math` library and comparing the value directly:
 import math
 math.isnan(Color('hsl(none 0% 100%)')['hue'])
 ```
+### Forcing Defined Values
+
+Another way to deal with `NaN` values is to just ignore them. `get()`, `set()`, `coords()`, and `alpha()` all can use
+the `nans` option to ensure read operations return a defined value.
+
+```py play
+c = Color('srgb', [])
+c
+c.get('red', nans=False)
+c.set('green', lambda x: x + 3, nans=False)
+```
+
+/// note | `set()`
+In the context of `set()`, `nans` specifically ensures that when a callback function is provided that the input value
+is transformed into a real value opposed to an undefined value.
+///
+
+We can also use `normalize()` to just set all channels to defined values, but keep mind, when an
+[achromatic color](#achromatic-colors) has a real hue, they will then influence interpolation results if interpolating
+in that same color space.
+
+```py play
+c = Color('srgb', [])
+c.normalize(nans=False)
+```
+
+### How are Undefined Values Resolved?
+
+ColorAide will resolve undefined values when necessary. Resolving undefined values may be needed to compute color
+distance, convert a color, serialize a color, or various other reasons.
+
+Normally, an undefined value defaults to `0` when forced to be defined, but there are a few cases where this may not
+always be true.
+
+1. When a color is achromatic the hue becomes meaningless in most cylindrical color spaces. This makes sense as
+   achromatic colors have no hues, but this is also because the algorithms usually work out this way. When chroma is
+   small enough, it usually makes the hue mathematically insignificant. In these cases, when a hue must be defined, we
+   will generally assume `0` as an arbitrary default, but there are some color spaces who have algorithms where the hue
+   actually becomes more important for precise conversions.
+
+    The color spaces CAM16 JMh and HCT are color models that allow you to set the viewing environment. One of the
+    options determines whether the eye is adapted to the illuminant or not. If not adapted, which is our default for
+    both CAM16 JMh and HCT, you can get an achromatic response where grayscale colors lean heavily into one specific
+    hue. Additionally, achromatic chroma may grow to a value much greater than `0` as lightness increases. If we were
+    to use `0` as a default for chroma and/or hue, we'd actually not convert back to a real achromatic color.
+
+    We can see in the example below that using `0` for an undefined hue in CAM16 JMh will not convert `#!py gray` back
+    to sRGB properly, but using the one calculated for the color space gets us much closer.
+
+    ```py play
+    srgb = Color('gray')
+    srgb
+    jmh = srgb.convert('cam16-jmh')
+    jmh.coords(nans=False)
+    jmh.convert('srgb')
+    jmh.set('hue', 0).convert('srgb')
+    ```
+
+    For color spaces with more dynamic achromatic response, ColorAide will resolve undefined hues and chroma with real
+    values that are neutral for that color's given lightness. This doesn't just apply to cylindrical spaces either.
+    This behavior can be seen in non cylindrical spaces as well, like the Lab form of CAM16.
+
+    ```py play
+    Color('gray').convert('cam16')
+    Color('cam16', [43.042, NaN, NaN]).normalize(nans=False)
+    ```
+
+    The selected values may not always perfectly precise, but they are much better than blindly assuming zero.
+
+    There are a number of spaces that benefit from this approach: Jzazbz/JzCzhz, IPT, IgPgTg.
+
+2. Most of the time, if you set all color channels to undefined, when resolved, the color will be black (or white in
+   the case of CMYK). Unfortunately, using `0` for undefined channels in some color spaces can create colors outside the
+   viewable gamut. One such example is ACEScct which has a greater value than zero for black. In this case, setting
+   undefined channels to zero will cause nonsense colors. In this specific case, we use ACEScct's value for black
+   instead of `0` for more a more practical default.
+
+    ```py play
+    aces = Color('black').convert('acescct')
+    aces
+    aces.mask(['alpha'], invert=True, in_place=True)
+    aces.coords(nans=False)
+    aces.in_gamut()
+    aces[:] = [0] * 3
+    aces.in_gamut()
+    ```
+
+## Achromatic Colors
+
+An achromatic color is a color without any real hue. Essentially, it is devoid of color leaving only shades of gray.
+Different color spaces represent achromatic colors in different ways.
+
+ColorAide has some special handling of achromatic colors and a few ways to test if a color is achromatic.
+
+### Checking For Achromatic Colors
+
+/// new | New 2.0
+///
+
+ColorAide generally respects an input color's defined channels, but during conversion, or if `normalize()` is called,
+cylindrical color spaces will have their hue set to undefined if the color is achromatic (or very close to achromatic).
+One easy way to check for achromatic colors is simply to check if the hue is undefined with `is_nan()`.
+
+```py play
+c = Color('gray').convert('hsl')
+c.is_nan('hue')
+```
+
+Unfortunately, this assumes that the `hue` has not been manually altered and this doesn't work with non cylindrical
+colors. Luckily, ColorAide has a universal way to check if any color is achromatic by using `is_achromatic()`.
+
+```py play
+color1 = Color('orange')
+color1
+color1.is_achromatic()
+color2 = Color('gray').convert('lab')
+color2
+color2.is_achromatic()
+color3 = Color('darkgray').convert('hsl').set('hue', 270)
+color3
+color3.is_achromatic()
+```
+
+`is_achromatic()` tries to use a reasonable threshold to determine achromatic colors. The method used is usually
+specific to the color space as it is fastest to test in the color space being evaluated.
+
+### Normalizing Achromatic Colors
+
+When ColorAide converts to a cylindrical color, if the color is achromatic, the hue will get set as undefined. This is
+mainly because when a color gets very close to achromatic, the hues can become nonsensical. Many cylindrical spaces, as
+chroma (or saturation) approaches zero, the color approaches being achromatic. And as chroma gets smaller, the impact of
+the hue becomes smaller and smaller. In these cases, when we get very close to achromatic, we
+don't care what the hue is, so it gets set as undefined. Additionally, having hue set to undefined allows us to
+interpolate achromatic colors in a sane way, see [Interpolation](./interpolation.md) for more info.
+
+There are times that a color can be defined such that it is not in this normalized achromatic state. We can manually
+define a color not in this state, and we can also force a color out of this state.
+
+Here we can disable the normalization when converting. We can do this with `convert()` and `update()`
+
+```py play
+Color('white').convert('lch')
+Color('white').convert('lch', norm=False)
+```
+
+We can also remove the normalization by setting `nans` to `#!py False` when using `normalize()`.
+
+```py play
+Color('white').convert('lch').normalize(nans=False)
+```
+
+If we want to force a color back into this normalized state, we can just call `normalize()` without any parameters.
+Normalize will remove any existing undefined channels and set achromatic hues to undefined.
+
+```py play
+c = Color('lch', [1, 0, 0])
+c
+c.normalize()
+```
diff --git a/docs/src/markdown/plugins/space.md b/docs/src/markdown/plugins/space.md
index 162d5aa41..d4428241b 100644
--- a/docs/src/markdown/plugins/space.md
+++ b/docs/src/markdown/plugins/space.md
@@ -48,7 +48,8 @@ class XYZD65(Space):
     #        high: float,
     #        bound: bool = False,
     #        flags: int = 0,
-    #        limit: Tuple[float | None, float | None] = (None, None)
+    #        limit: Tuple[float | None, float | None] = (None, None),
+    #        nans: float = 0.0
     #    ) -> 'Channel':
     #```
     #
@@ -62,6 +63,8 @@ class XYZD65(Space):
     #            do not naturally occur, not even with normal out of gamut colors. So, we could clamp the lower
     #            bound: `(0, None)`.
     # - `flags`: Flags used to provide additional context for the channel.
+    # - `nans`: Default value to use for a given channel when it is undefined. More advanced handling can be
+    #           done by overriding `resolve_channel()` on the color space object.
     #
     # The following flags are supported:
     # - FLG_ANGLE: denotes that channel is a angle or degree value.
@@ -70,7 +73,7 @@ class XYZD65(Space):
     #                percentage format. The CSS `color()` function ignores this flags as no channels are always
     #                required to be percentages. Percentage range will be determined by `high` and `low`.
     # - FLG_OPT_PERCENT: denotes the value can optionally be considered as a percent.
-    #                    This is also only used for CSS string input and output. CSS `oklab`, `lab()`, `oklch`,
+    #                    This is also only used for CSS string input and output. CSS `oklab()`, `lab()`, `oklch()`,
     #                    `lch()`, and `srgb()` allow for channels to be provided as percentages or normal
     #                    numbers in certain cases. This tells the parser and serializer which channels allow this.
     #                    Percentage range will be determined by `high` and `low`.
@@ -109,12 +112,6 @@ class XYZD65(Space):
     #   space, the values can be greatly out of specification (looking at you HSL).
     GAMUT_CHECK = None
 
-    # When set to `True`, this denotes that the color space has the ability to represent out of gamut in colors in an
-    # extended range. When interpolation is done, if colors are interpolated in a smaller gamut than the colors being
-    # interpolated, and that color is "bound" to a gamut, the colors will usually be gamut mapped, but if the
-    # interpolation space happens to support extended ranges, then the colors will not be gamut mapped even if their
-    # gamut is larger than the target interpolation space.
-    EXTENDED_RANGE = False
     # What is the color space's dynamic range
     DYNAMIC_RANGE = 'sdr'
 
@@ -179,40 +176,64 @@ space that has a different white point. In ColorAide, any time XYZ D65 is either
 other color space has a different white point, the XYZ coordinates, will either be adapted to XYZ D65 or XYZ (new white
 point) respectively. This all happens without The `Space` plugin needing to do anything additional.
 
-
 White points are specified via the `WHITE` property, and should contain a tuple of `xy` coordinates of the white point.
 
-## Color Normalization
+## Achromatic Rules
 
-In addition to the aforementioned methods, some color spaces, such as cylindrical spaces, have some additional logic
-that determines when a `hue` is undefined. During conversion, undefined channels are initially thrown away, but a color
-may be returned with undefined hues if it is a cylindrical color space and the the color is achromatic, or in some cases,
-very close to achromatic.
+A given color space can define its rules for determining whether a color is achromatic. If one is not defined, the
+color will be convert to XYZ D65 and its achromatic method will be used. In order to have reasonably fast checks, it is
+better to evaluate the achromatic state without converting to another color. In order to define achromatic rules, simply
+override `is_achromatic()`. For instance, LCh is achromatic when chroma is very close to zero:
 
-The `Space.normalize` function is not used during conversion, but provides logic for specifically normalizing an exiting
-color when `Color.normalize` is called. Logic should generally match whatever occurs during conversion.
+```py
+    def is_achromatic(self, coords: Vector) -> bool | None:
+        """Check if color is achromatic."""
 
-Usually, for rectangular spaces, `normalize` simply eliminates undefined channels. For cylindrical spaces, it will also
-set the hue to "undefined" if the color meets the criteria of the color space. This may be when chroma is zero, or maybe
-even when very close to zero. This can vary from color space to color space.
+        return coords[1] < ACHROMATIC_THRESHOLD
+```
 
-`normalize` can be specified as an function of the `Space` plugin.
+## Resolve Undefined Values
+
+By default, undefined color channels are resolved as `0`, but there are color spaces where zero just does not work well
+for a given channel. Such color spaces can choose a different default for undefined values. Generally, `0` is
+encouraged, but if zero is fundamentally a bad value for a color space and/or can decrease accuracy of colors, a
+different default can be specified when defining a channel on the color space via the `nans` parameter.
 
 ```py
-    def normalize(self, coords: Vector) -> Vector:
-        """Normalize color."""
+class ACEScct(sRGB):
+    """The ACEScct color class."""
+
+    BASE = "acescg"
+    NAME = "acescct"
+    SERIALIZE = ("--acescct",)
+    WHITE = (0.32168, 0.33767)
+    CHANNELS = (
+        Channel("r", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN),
+        Channel("g", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN),
+        Channel("b", CCT_MIN, CCT_MAX, bound=True, nans=CCT_MIN)
+    )
+```
 
-        coords = alg.no_nans(coords)
-        if coords[1] == 0 or coords[2] in (0, 1):
-            coords[0] = alg.NaN
+If the channel requires more advanced handling, you can override `resolve_channel()` on the color space itself:
 
-        return coords
+```py
+    def resolve_channel(self, index: int, coords: Vector) -> float:
+        """Resove channels."""
+
+        if index in (1, 2):
+            if not math.isnan(coords[index]):
+                return coords[index]
+
+            return self.ACHROMATIC.get_ideal_ab(coords[0])[index - 1]
+
+        value = coords[index]
+        return self.channels[index].nans if math.isnan(value) else value
 ```
 
 ## Mix-ins
 
-If the color is a cylindrical space, Lab-ish space, or LCh-ish space, you can additionally add in the respective
-mix-in class: `Cylindrical`, `Labish`, or `LChish`. It should be noted that `LChish` is subclassed from `Cylindrical`.
+ColorAide provides some various mixins for some common color space types. It should be noted that all cylindrical type
+color mixins are derived from `Cylindrical`.
 
 /// tab | Cylindrical
 ```py
@@ -224,25 +245,62 @@ class Cylindrical:
 
         return "h"
 
-    def hue_index(self) -> int:  # pragma: no cover
+    def hue_index(self) -> int:
         """Get hue index."""
 
         return cast('Space', self).get_channel_index(self.hue_name())
+```
+///
 
-    def achromatic_hue(self) -> float:
-        """
-        Ideal achromatic hue.
-
-        Normally, we assume 0 when a cylindrical color space has a powerless hue.
-        For most color spaces, the hue has little affect when the color is achromatic,
-        but on rare occasions, a color space algorithm may require a specific hue
-        in order to more accurately translate an achromatic hue, CAM16 JMh (without
-        discounting) being an example. Color spaces internally handle this during
-        conversion, but there are times such as when plotting where knowing the
-        hue can be useful.
-        """
+/// tab | RGBish
+```py
+class RGBish:
+    """RGB-ish space."""
+
+    def names(self) -> tuple[str, ...]:
+        """Return RGB-ish names in order R G B."""
+
+        return self.channels[:-1]
 
-        return 0.0
+    def indexes(self) -> list[int]:
+        """Return the index of RGB-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]
+```
+///
+
+
+/// tab | HSLish
+```py
+class HSLish(Cylindrical):
+    """HSL-ish space."""
+
+    def names(self) -> tuple[str, ...]:
+        """Return HSL-ish names in order H S L."""
+
+        return self.channels[:-1]
+
+    def indexes(self) -> list[int]:
+        """Return the index of HSL-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]
+```
+///
+
+/// tab | HSVish
+```py
+class HSVish(Cylindrical):
+    """HSV-ish space."""
+
+    def names(self) -> tuple[str, ...]:
+        """Return HSV-ish names in order H S V."""
+
+        return self.channels[:-1]
+
+    def indexes(self) -> list[int]:
+        """Return the index of HSV-ish channels."""
+
+        return [self.get_channel_index(name) for name in self.names()]
 ```
 ///
 
@@ -251,15 +309,15 @@ class Cylindrical:
 class Labish:
     """Lab-ish color spaces."""
 
-    def labish_names(self) -> Tuple[str, ...]:
+    def names(self) -> tuple[str, ...]:
         """Return Lab-ish names in the order L a b."""
 
-        return cast('Space', self).channels[:-1]
+        return self.channels[:-1]
 
-    def labish_indexes(self) -> List[int]:  # pragma: no cover
+    def indexes(self) -> list[int]:
         """Return the index of the Lab-ish channels."""
 
-        return [cast('Space', self).get_channel_index(name) for name in self.labish_names()]
+        return [self.get_channel_index(name) for name in self.names()]
 ```
 ///
 
@@ -268,15 +326,15 @@ class Labish:
 class LChish(Cylindrical):
     """LCh-ish color spaces."""
 
-    def lchish_names(self) -> Tuple[str, ...]:  # pragma: no cover
+    def names(self) -> tuple[str, ...]:
         """Return LCh-ish names in the order L c h."""
 
-        return cast('Space', self).channels[:-1]
+        return self.channels[:-1]
 
-    def lchish_indexes(self) -> List[int]:  # pragma: no cover
+    def indexes(self) -> list[int]:
         """Return the index of the Lab-ish channels."""
 
-        return [cast('Space', self).get_channel_index(name) for name in self.lchish_names()]
+        return [self.get_channel_index(name) for name in self.names()]
 ```
 ///
 
@@ -301,17 +359,11 @@ ictcp = srgb.convert('ictcp')
 for c in (srgb, jzazbz, ictcp):
     if isinstance(c._space, Labish):
         print('color: ', c)
-        l = c._space.labish_names()[0]
+        l = c._space.names()[0]
         print('channel: ', l)
         print('value: ', c.get(l))
 ```
 
-It should be noted that just because a color space identifies in a specific, generic category, it doesn't mean it is
-precisely that thing. For instance, the color space CIELab is clearly a Lab-ish space, or more precisely, it is a Lab
-color space. Oklab, DIN99o, and others are very much Lab spaces as well. ICtCp, on the other hand, is very Lab *like* as
-its `Ct` and `Cp` channels control redness/greenness and blueness/yellowness, but the `I` channel represents intensity,
-not lightness which is similar but not precisely the same thing.
-
 ## Adding New Input/Output Formats
 
 One common thing that may be desired is altering an existing color space to accept and output a specialized format.
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 61c95fb95..8e78836e6 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -262,7 +262,7 @@ extra_css:
   - assets/coloraide-extras/extra.css
 extra_javascript:
   - https://unpkg.com/mermaid@8.13.3/dist/mermaid.min.js
-  - playground-config-430c3c65.js
+  - playground-config-4c088c14.js
   - https://cdn.jsdelivr.net/pyodide/v0.22.1/full/pyodide.js
   - assets/coloraide-extras/extra-notebook.js
 
diff --git a/docs/src/py/notebook.py b/docs/src/py/notebook.py
index b9196bb5e..6286d48c4 100644
--- a/docs/src/py/notebook.py
+++ b/docs/src/py/notebook.py
@@ -94,20 +94,20 @@
 code_id = 0
 
 
-class HtmlGradient(list):
-    """HTML color gradient."""
+class Ramp(list):
+    """Create a gradient from a list of colors."""
 
 
-class HtmlSteps(list):
-    """HTML color steps."""
+class Steps(list):
+    """Create a special display of steps from a list of colors."""
 
 
-class ColorTuple(namedtuple('ColorTuple', ['string', 'color'])):
-    """Color tuple."""
+class Row(list):
+    """Restrict only the provided colors to a row."""
 
 
-class HtmlRow(list):
-    """Create a row with the given colors."""
+class ColorTuple(namedtuple('ColorTuple', ['string', 'color'])):
+    """Color tuple."""
 
 
 class AtomicString(str):
@@ -122,6 +122,12 @@ class Continue(Exception):
     """Continue exception."""
 
 
+# Legacy names
+HtmlRow = Row
+HtmlSteps = Steps
+HtmlGradient = Ramp
+
+
 def _escape(txt):
     """Basic HTML escaping."""
 
@@ -170,14 +176,14 @@ def get_colors(result):
     if isinstance(result, AtomicString):
         yield find_colors(result)
 
-    if isinstance(result, HtmlRow):
-        yield HtmlRow(
+    if isinstance(result, Row):
+        yield Row(
             [
                 ColorTuple(c.to_string(fit=False), c.clone()) if isinstance(c, Color) else ColorTuple(c, ColorAll(c))
                 for c in result
             ]
         )
-    elif isinstance(result, (HtmlSteps, HtmlGradient)):
+    elif isinstance(result, (Steps, Ramp)):
         t = type(result)
         yield t([c.clone() if isinstance(c, Color) else ColorAll(c) for c in result])
     elif isinstance(result, Color):
@@ -187,7 +193,7 @@ def get_colors(result):
         if result.domain:
             domain = result.domain
             result.domain = normalize_domain(result.domain)
-        grad = HtmlGradient(result.steps(steps=5, max_delta_e=2.3))
+        grad = Ramp(result.steps(steps=5, max_delta_e=2.3))
         if domain:
             result.domain = domain
             domain = []
@@ -436,6 +442,9 @@ def execute(cmd, no_except=True, inline=False, init='', g=None):
     # Setup global initialization
     if g is None:
         g = {
+            "Ramp": Ramp,
+            "Steps": Steps,
+            "Row": Row,
             'HtmlRow': HtmlRow,
             'HtmlSteps': HtmlSteps,
             'HtmlGradient': HtmlGradient
@@ -556,9 +565,9 @@ def _color_command_console(colors, gamut=WEBSPACE):
     bar = False
     values = []
     for item in colors:
-        if isinstance(item, (HtmlGradient, HtmlSteps)):
+        if isinstance(item, (HtmlGradient, Steps)):
             current = total = percent = last = 0
-            if isinstance(item, HtmlSteps):
+            if isinstance(item, Steps):
                 total = len(item)
                 percent = 100 / total
                 current = percent
@@ -591,7 +600,7 @@ def _color_command_console(colors, gamut=WEBSPACE):
             bar = False
         else:
             is_row = False
-            if isinstance(item, HtmlRow):
+            if isinstance(item, Row):
                 is_row = True
                 if bar and values:
                     el += '<div class="swatch-bar">{}</div>'.format(' '.join(values))
diff --git a/docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js b/docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js
new file mode 100644
index 000000000..0f1191748
--- /dev/null
+++ b/docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js
@@ -0,0 +1,2 @@
+function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}var runtime=function(t){"use strict";var e,n=Object.prototype,o=n.hasOwnProperty,r="function"==typeof Symbol?Symbol:{},a=r.iterator||"@@iterator",i=r.asyncIterator||"@@asyncIterator",s=r.toStringTag||"@@toStringTag";function l(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{l({},"")}catch(t){l=function(t,e,n){return t[e]=n}}function c(t,e,n,o){var r=e&&e.prototype instanceof g?e:g,a=Object.create(r.prototype),i=new C(o||[]);return a._invoke=function(t,e,n){var o=d;return function(r,a){if(o===f)throw new Error("Generator is already running");if(o===m){if("throw"===r)throw a;return I()}for(n.method=r,n.arg=a;;){var i=n.delegate;if(i){var s=L(i,n);if(s){if(s===h)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(o===d)throw o=m,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o=f;var l=u(t,e,n);if("normal"===l.type){if(o=n.done?m:p,l.arg===h)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(o=m,n.method="throw",n.arg=l.arg)}}}(t,n,i),a}function u(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}t.wrap=c;var d="suspendedStart",p="suspendedYield",f="executing",m="completed",h={};function g(){}function _(){}function y(){}var v={};l(v,a,(function(){return this}));var w=Object.getPrototypeOf,x=w&&w(w(B([])));x&&x!==n&&o.call(x,a)&&(v=x);var b=y.prototype=g.prototype=Object.create(v);function E(t){["next","throw","return"].forEach((function(e){l(t,e,(function(t){return this._invoke(e,t)}))}))}function k(t,e){function n(r,a,i,s){var l=u(t[r],t,a);if("throw"!==l.type){var c=l.arg,d=c.value;return d&&"object"===_typeof(d)&&o.call(d,"__await")?e.resolve(d.__await).then((function(t){n("next",t,i,s)}),(function(t){n("throw",t,i,s)})):e.resolve(d).then((function(t){c.value=t,i(c)}),(function(t){return n("throw",t,i,s)}))}s(l.arg)}var r;this._invoke=function(t,o){function a(){return new e((function(e,r){n(t,o,e,r)}))}return r=r?r.then(a,a):a()}}function L(t,n){var o=t.iterator[n.method];if(o===e){if(n.delegate=null,"throw"===n.method){if(t.iterator.return&&(n.method="return",n.arg=e,L(t,n),"throw"===n.method))return h;n.method="throw",n.arg=new TypeError("The iterator does not provide a 'throw' method")}return h}var r=u(o,t.iterator,n.arg);if("throw"===r.type)return n.method="throw",n.arg=r.arg,n.delegate=null,h;var a=r.arg;return a?a.done?(n[t.resultName]=a.value,n.next=t.nextLoc,"return"!==n.method&&(n.method="next",n.arg=e),n.delegate=null,h):a:(n.method="throw",n.arg=new TypeError("iterator result is not an object"),n.delegate=null,h)}function S(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function A(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function C(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(S,this),this.reset(!0)}function B(t){if(t){var n=t[a];if(n)return n.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var r=-1,i=function n(){for(;++r<t.length;)if(o.call(t,r))return n.value=t[r],n.done=!1,n;return n.value=e,n.done=!0,n};return i.next=i}}return{next:I}}function I(){return{value:e,done:!0}}return _.prototype=y,l(b,"constructor",y),l(y,"constructor",_),_.displayName=l(y,s,"GeneratorFunction"),t.isGeneratorFunction=function(t){var e="function"==typeof t&&t.constructor;return!!e&&(e===_||"GeneratorFunction"===(e.displayName||e.name))},t.mark=function(t){return Object.setPrototypeOf?Object.setPrototypeOf(t,y):(t.__proto__=y,l(t,s,"GeneratorFunction")),t.prototype=Object.create(b),t},t.awrap=function(t){return{__await:t}},E(k.prototype),l(k.prototype,i,(function(){return this})),t.AsyncIterator=k,t.async=function(e,n,o,r,a){void 0===a&&(a=Promise);var i=new k(c(e,n,o,r),a);return t.isGeneratorFunction(n)?i:i.next().then((function(t){return t.done?t.value:i.next()}))},E(b),l(b,s,"Generator"),l(b,a,(function(){return this})),l(b,"toString",(function(){return"[object Generator]"})),t.keys=function(t){var e=[];for(var n in t)e.push(n);return e.reverse(),function n(){for(;e.length;){var o=e.pop();if(o in t)return n.value=o,n.done=!1,n}return n.done=!0,n}},t.values=B,C.prototype={constructor:C,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=e,this.done=!1,this.delegate=null,this.method="next",this.arg=e,this.tryEntries.forEach(A),!t)for(var n in this)"t"===n.charAt(0)&&o.call(this,n)&&!isNaN(+n.slice(1))&&(this[n]=e)},stop:function(){this.done=!0;var t=this.tryEntries[0].completion;if("throw"===t.type)throw t.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var n=this;function r(o,r){return s.type="throw",s.arg=t,n.next=o,r&&(n.method="next",n.arg=e),!!r}for(var a=this.tryEntries.length-1;a>=0;--a){var i=this.tryEntries[a],s=i.completion;if("root"===i.tryLoc)return r("end");if(i.tryLoc<=this.prev){var l=o.call(i,"catchLoc"),c=o.call(i,"finallyLoc");if(l&&c){if(this.prev<i.catchLoc)return r(i.catchLoc,!0);if(this.prev<i.finallyLoc)return r(i.finallyLoc)}else if(l){if(this.prev<i.catchLoc)return r(i.catchLoc,!0)}else{if(!c)throw new Error("try statement without catch or finally");if(this.prev<i.finallyLoc)return r(i.finallyLoc)}}}},abrupt:function(t,e){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&o.call(r,"finallyLoc")&&this.prev<r.finallyLoc){var a=r;break}}a&&("break"===t||"continue"===t)&&a.tryLoc<=e&&e<=a.finallyLoc&&(a=null);var i=a?a.completion:{};return i.type=t,i.arg=e,a?(this.method="next",this.next=a.finallyLoc,h):this.complete(i)},complete:function(t,e){if("throw"===t.type)throw t.arg;return"break"===t.type||"continue"===t.type?this.next=t.arg:"return"===t.type?(this.rval=this.arg=t.arg,this.method="return",this.next="end"):"normal"===t.type&&e&&(this.next=e),h},finish:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),A(n),h}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var o=n.completion;if("throw"===o.type){var r=o.arg;A(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,o){return this.delegate={iterator:B(t),resultName:n,nextLoc:o},"next"===this.method&&(this.arg=e),h}},t}("object"===("undefined"==typeof module?"undefined":_typeof(module))?module.exports:{});try{regeneratorRuntime=runtime}catch(t){"object"===("undefined"==typeof globalThis?"undefined":_typeof(globalThis))?globalThis.regeneratorRuntime=runtime:Function("r","regeneratorRuntime = r")(runtime)}!function(){"use strict";function t(){t=function(){return e};var e={},n=Object.prototype,o=n.hasOwnProperty,r=Object.defineProperty||function(t,e,n){t[e]=n.value},a="function"==typeof Symbol?Symbol:{},i=a.iterator||"@@iterator",s=a.asyncIterator||"@@asyncIterator",l=a.toStringTag||"@@toStringTag";function c(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{c({},"")}catch(t){c=function(t,e,n){return t[e]=n}}function u(t,e,n,o){var a=e&&e.prototype instanceof f?e:f,i=Object.create(a.prototype),s=new S(o||[]);return r(i,"_invoke",{value:b(t,n,s)}),i}function d(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}e.wrap=u;var p={};function f(){}function m(){}function h(){}var g={};c(g,i,(function(){return this}));var _=Object.getPrototypeOf,y=_&&_(_(A([])));y&&y!==n&&o.call(y,i)&&(g=y);var v=h.prototype=f.prototype=Object.create(g);function w(t){["next","throw","return"].forEach((function(e){c(t,e,(function(t){return this._invoke(e,t)}))}))}function x(t,e){function n(r,a,i,s){var l=d(t[r],t,a);if("throw"!==l.type){var c=l.arg,u=c.value;return u&&"object"==_typeof(u)&&o.call(u,"__await")?e.resolve(u.__await).then((function(t){n("next",t,i,s)}),(function(t){n("throw",t,i,s)})):e.resolve(u).then((function(t){c.value=t,i(c)}),(function(t){return n("throw",t,i,s)}))}s(l.arg)}var a;r(this,"_invoke",{value:function(t,o){function r(){return new e((function(e,r){n(t,o,e,r)}))}return a=a?a.then(r,r):r()}})}function b(t,e,n){var o="suspendedStart";return function(r,a){if("executing"===o)throw new Error("Generator is already running");if("completed"===o){if("throw"===r)throw a;return C()}for(n.method=r,n.arg=a;;){var i=n.delegate;if(i){var s=E(i,n);if(s){if(s===p)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if("suspendedStart"===o)throw o="completed",n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o="executing";var l=d(t,e,n);if("normal"===l.type){if(o=n.done?"completed":"suspendedYield",l.arg===p)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(o="completed",n.method="throw",n.arg=l.arg)}}}function E(t,e){var n=e.method,o=t.iterator[n];if(void 0===o)return e.delegate=null,"throw"===n&&t.iterator.return&&(e.method="return",e.arg=void 0,E(t,e),"throw"===e.method)||"return"!==n&&(e.method="throw",e.arg=new TypeError("The iterator does not provide a '"+n+"' method")),p;var r=d(o,t.iterator,e.arg);if("throw"===r.type)return e.method="throw",e.arg=r.arg,e.delegate=null,p;var a=r.arg;return a?a.done?(e[t.resultName]=a.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=void 0),e.delegate=null,p):a:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,p)}function k(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function L(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function S(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(k,this),this.reset(!0)}function A(t){if(t){var e=t[i];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var n=-1,r=function e(){for(;++n<t.length;)if(o.call(t,n))return e.value=t[n],e.done=!1,e;return e.value=void 0,e.done=!0,e};return r.next=r}}return{next:C}}function C(){return{value:void 0,done:!0}}return m.prototype=h,r(v,"constructor",{value:h,configurable:!0}),r(h,"constructor",{value:m,configurable:!0}),m.displayName=c(h,l,"GeneratorFunction"),e.isGeneratorFunction=function(t){var e="function"==typeof t&&t.constructor;return!!e&&(e===m||"GeneratorFunction"===(e.displayName||e.name))},e.mark=function(t){return Object.setPrototypeOf?Object.setPrototypeOf(t,h):(t.__proto__=h,c(t,l,"GeneratorFunction")),t.prototype=Object.create(v),t},e.awrap=function(t){return{__await:t}},w(x.prototype),c(x.prototype,s,(function(){return this})),e.AsyncIterator=x,e.async=function(t,n,o,r,a){void 0===a&&(a=Promise);var i=new x(u(t,n,o,r),a);return e.isGeneratorFunction(n)?i:i.next().then((function(t){return t.done?t.value:i.next()}))},w(v),c(v,l,"Generator"),c(v,i,(function(){return this})),c(v,"toString",(function(){return"[object Generator]"})),e.keys=function(t){var e=Object(t),n=[];for(var o in e)n.push(o);return n.reverse(),function t(){for(;n.length;){var o=n.pop();if(o in e)return t.value=o,t.done=!1,t}return t.done=!0,t}},e.values=A,S.prototype={constructor:S,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=void 0,this.done=!1,this.delegate=null,this.method="next",this.arg=void 0,this.tryEntries.forEach(L),!t)for(var e in this)"t"===e.charAt(0)&&o.call(this,e)&&!isNaN(+e.slice(1))&&(this[e]=void 0)},stop:function(){this.done=!0;var t=this.tryEntries[0].completion;if("throw"===t.type)throw t.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var e=this;function n(n,o){return i.type="throw",i.arg=t,e.next=n,o&&(e.method="next",e.arg=void 0),!!o}for(var r=this.tryEntries.length-1;r>=0;--r){var a=this.tryEntries[r],i=a.completion;if("root"===a.tryLoc)return n("end");if(a.tryLoc<=this.prev){var s=o.call(a,"catchLoc"),l=o.call(a,"finallyLoc");if(s&&l){if(this.prev<a.catchLoc)return n(a.catchLoc,!0);if(this.prev<a.finallyLoc)return n(a.finallyLoc)}else if(s){if(this.prev<a.catchLoc)return n(a.catchLoc,!0)}else{if(!l)throw new Error("try statement without catch or finally");if(this.prev<a.finallyLoc)return n(a.finallyLoc)}}}},abrupt:function(t,e){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&o.call(r,"finallyLoc")&&this.prev<r.finallyLoc){var a=r;break}}a&&("break"===t||"continue"===t)&&a.tryLoc<=e&&e<=a.finallyLoc&&(a=null);var i=a?a.completion:{};return i.type=t,i.arg=e,a?(this.method="next",this.next=a.finallyLoc,p):this.complete(i)},complete:function(t,e){if("throw"===t.type)throw t.arg;return"break"===t.type||"continue"===t.type?this.next=t.arg:"return"===t.type?(this.rval=this.arg=t.arg,this.method="return",this.next="end"):"normal"===t.type&&e&&(this.next=e),p},finish:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),L(n),p}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var o=n.completion;if("throw"===o.type){var r=o.arg;L(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:A(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=void 0),p}},e}function e(t,e,n,o,r,a,i){try{var s=t[a](i),l=s.value}catch(t){return void n(t)}s.done?e(l):Promise.resolve(l).then(o,r)}function n(t){return function(){var n=this,o=arguments;return new Promise((function(r,a){var i=t.apply(n,o);function s(t){e(i,r,a,s,l,"next",t)}function l(t){e(i,r,a,s,l,"throw",t)}s(void 0)}))}}function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function r(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=function(t,e){if(t){if("string"==typeof t)return o(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){n&&(t=n);var r=0,a=function(){};return{s:a,n:function(){return r>=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:a}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){l=!0,i=t},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw i}}}}!function(){var e="";try{var o="srgb";window.matchMedia("(color-gamut: rec2020)").matches?o="rec2020":window.matchMedia("(color-gamut: p3)").matches&&(o="display-p3"),e=CSS.supports("color: color(display-p3 1 0 0)")?o:"srgb"}catch(t){e="srgb"}var a=null,i=!1,s="",l="",c={},u=/.*?_(\d+)$/,d=!1,p="",f=!1,m=/^( {1,4}|\t)/,h="\n_O='<div class=\"color-command\">{}</div>'\n_N='swatch'\n_M='transparent'\n_L='pycon'\n_K='playground'\n_J='gamut'\n_I='exceptions'\n_H='color'\n_G='highlight'\n_F='class'\n_E='eval'\n_D='<string>'\n_C=None\n_B=True\n_A=False\nimport xml.etree.ElementTree as Etree\nfrom collections.abc import Sequence,Mapping\nfrom collections import namedtuple\nimport ast\nfrom io import StringIO\nimport sys,re\nfrom functools import partial\nfrom pygments import highlight\nfrom pygments.lexers import get_lexer_by_name\nfrom pygments.formatters import find_formatter_class\nfrom coloraide import Color\nfrom coloraide.interpolate import Interpolator,normalize_domain\ntry:from coloraide_extras.everything import ColorAll\nexcept ImportError:from coloraide.everything import ColorAll\nPY310=(3,10)<=sys.version_info\nPY311=(3,11)<=sys.version_info\nWEBSPACE='srgb'\nAST_BLOCKS=ast.If,ast.For,ast.While,ast.Try,ast.With,ast.FunctionDef,ast.ClassDef,ast.AsyncFor,ast.AsyncWith,ast.AsyncFunctionDef\nif PY310:AST_BLOCKS=AST_BLOCKS+(ast.Match,)\nif PY311:AST_BLOCKS=AST_BLOCKS+(ast.TryStar,)\nRE_INIT=re.compile('^\\\\s*#\\\\s*pragma:\\\\s*init\\\\n(.*?)#\\\\s*pragma:\\\\s*init\\\\n',re.DOTALL|re.I)\nRE_COLOR_START=re.compile('(?i)(?:\\\\b(?<![-#&$])(?:color|hsla?|lch|lab|hwb|rgba?)\\\\(|\\\\b(?<![-#&$])[\\\\w]{3,}(?![(-])\\\\b|(?<![&])#)')\nLIVE_INIT='\\nfrom coloraide import *\\nimport coloraide\\ntry:\\n    import coloraide_extras\\n    from coloraide_extras.everything import ColorAll as Color\\nexcept ImportError:\\n    from coloraide.everything import ColorAll as Color\\n'\ntemplate='<div class=\"playground\" id=\"__playground_{el_id}\">\\n<div class=\"playground-results\" id=\"__playground-results_{el_id}\">\\n{results}\\n</div>\\n<div class=\"playground-code hidden\" id=\"__playground-code_{el_id}\" data-search-exclude>\\n<form autocomplete=\"off\">\\n<textarea class=\"playground-inputs\" id=\"__playground-inputs_{el_id}\" spellcheck=\"false\">{raw_source}</textarea>\\n</form>\\n</div>\\n<div class=\"playground-footer\" data-search-exclude>\\n<hr>\\n<button id=\"__playground-edit_{el_id}\" class=\"playground-edit\" title=\"Edit the current snippet\">Edit</button>\\n<button id=\"__playground-share_{el_id}\" class=\"playground-share\" title=\"Copy URL to current snippet\">Share</button>\\n<button id=\"__playground-run_{el_id}\" class=\"playground-run hidden\" title=\"Run code (Ctrl + Enter)\">Run</button>\\n<button id=\"__playground-cancel_{el_id}\" class=\"playground-cancel hidden\" title=\"Cancel edit (Escape)\">Cancel</button>\\n<span class=\\'gamut\\'>Gamut: {gamut}</span>\\n</div>\\n</div>'\ncode_id=0\nclass Ramp(list):0\nclass Steps(list):0\nclass Row(list):0\nclass ColorTuple(namedtuple('ColorTuple',['string',_H])):0\nclass AtomicString(str):0\nclass Break(Exception):0\nclass Continue(Exception):0\nHtmlRow=Row\nHtmlSteps=Steps\nHtmlGradient=Ramp\ndef _escape(txt):txt=txt.replace('&','&amp;');txt=txt.replace('<','&lt;');txt=txt.replace('>','&gt;');return txt\nclass StreamOut:\n\tdef __init__(self):self.old=sys.stdout;self.stdout=StringIO();sys.stdout=self.stdout\n\tdef read(self):\n\t\tvalue=''\n\t\tif self.stdout is not _C:self.stdout.flush();value=self.stdout.getvalue();self.stdout=StringIO();sys.stdout=self.stdout\n\t\treturn value\n\tdef __enter__(self):return self\n\tdef __exit__(self,type,value,traceback):sys.stdout=self.old;self.old=_C;self.stdout=_C\ndef get_colors(result):\n\tdomain=[]\n\tif isinstance(result,AtomicString):yield find_colors(result)\n\tif isinstance(result,Row):yield Row([ColorTuple(c.to_string(fit=_A),c.clone())if isinstance(c,Color)else ColorTuple(c,ColorAll(c))for c in result])\n\telif isinstance(result,(Steps,Ramp)):t=type(result);yield t([c.clone()if isinstance(c,Color)else ColorAll(c)for c in result])\n\telif isinstance(result,Color):yield[ColorTuple(result.to_string(fit=_A),result.clone())]\n\telif isinstance(result,Interpolator):\n\t\tif result.domain:domain=result.domain;result.domain=normalize_domain(result.domain)\n\t\tgrad=Ramp(result.steps(steps=5,max_delta_e=2.3))\n\t\tif domain:result.domain=domain;domain=[]\n\t\tyield grad\n\telif isinstance(result,str):\n\t\ttry:yield[ColorTuple(result,ColorAll(result))]\n\t\texcept Exception:pass\n\telif isinstance(result,(list,tuple)):\n\t\tfor r in result:\n\t\t\tfor x in get_colors(r):\n\t\t\t\tif x:yield x\ndef find_colors(text):\n\tcolors=[]\n\tfor m in RE_COLOR_START.finditer(text):\n\t\tstart=m.start();mcolor=ColorAll.match(text,start=start)\n\t\tif mcolor is not _C:colors.append(ColorTuple(text[mcolor.start:mcolor.end],mcolor.color))\n\treturn colors\ndef evaluate_with(node,g,loop,index=0):\n\tl=len(node.items)-1;withitem=node.items[index]\n\tif withitem.context_expr:\n\t\twith eval(compile(ast.Expression(withitem.context_expr),_D,_E),g)as w:\n\t\t\tg[withitem.optional_vars.id]=w\n\t\t\tif index<l:evaluate_with(node,g,loop,index+1)\n\t\t\telse:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\telse:\n\t\twith eval(compile(ast.Expression(withitem.context_expr),_D,_E),g):\n\t\t\tif index<l:evaluate_with(node,g,loop,index+1)\n\t\t\telse:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\ndef compare_match(s,g,node):\n\tif isinstance(node,ast.MatchOr):\n\t\tfor pattern in node.patterns:\n\t\t\tif compare_match(s,g,pattern):return _B\n\telif isinstance(node,ast.MatchValue):p=eval(compile(ast.Expression(node.value),_D,_E),g);return s==p\n\telif isinstance(node,ast.MatchSingleton):return s is node.value\n\telif isinstance(node,ast.MatchSequence):\n\t\tif isinstance(s,Sequence):\n\t\t\tstar=isinstance(node.patterns[-1],ast.MatchStar);l1,l2=len(s),len(node.patterns)\n\t\t\tif star and l1>=l2-1 or l1==l2:\n\t\t\t\tfor (e,p) in enumerate(node.patterns[:-1]if star else node.patterns):\n\t\t\t\t\tif not compare_match(s[e],g,p):return _A\n\t\t\t\tif star and node.patterns[-1].name:g[node.patterns[-1].name]=s[l2-1:]\n\t\t\t\treturn _B\n\t\treturn _A\n\telif isinstance(node,ast.MatchMapping):\n\t\tif isinstance(s,Mapping):\n\t\t\tstar=node.rest;l1,l2=len(s),len(node.patterns)\n\t\t\tif star and l1>=l2 or l1==l2:\n\t\t\t\tkeys=set()\n\t\t\t\tfor (kp,vp) in zip(node.keys,node.patterns):\n\t\t\t\t\tkey=eval(compile(ast.Expression(kp),_D,_E),g);keys.add(key)\n\t\t\t\t\tif key not in s:return _A\n\t\t\t\t\tif not compare_match(s[key],g,vp):return _A\n\t\t\t\tif star:g[star]={k:v for(k,v)in s.items()if k not in keys}\n\t\t\t\treturn _B\n\t\treturn _A\n\telif isinstance(node,ast.MatchClass):\n\t\tname=g.get(node.cls.id,_C)\n\t\tif name is _C:raise NameError(\"name '{}' is not defined\".format(node.cls.id))\n\t\tif not isinstance(s,name):return _A\n\t\tma=getattr(s,'__match_args__',tuple());l1=len(ma);l2=len(node.patterns)\n\t\tif l1<l2:raise TypeError('{}() accepts {} positional sub-patterns ({} given)'.format(name,l1,l2))\n\t\tfor (e,p) in enumerate(node.patterns):\n\t\t\tif not hasattr(s,ma[e]):return _A\n\t\t\tif not compare_match(getattr(s,ma[e]),g,p):return _A\n\t\tfor (a,p) in zip(node.kwd_attrs,node.kwd_patterns):\n\t\t\tif not hasattr(s,a):return _A\n\t\t\tif not compare_match(getattr(s,a),g,p):return _A\n\t\treturn _B\n\telif isinstance(node,ast.MatchAs):\n\t\tif node.name is not _C:g[node.name]=s\n\t\tif node.pattern:return compare_match(s,g,node.pattern)\n\t\treturn _B\n\traise RuntimeError('Unknown Match pattern {}'.format(str(node)))\ndef evaluate_except(node,e,g,loop=_A):\n\tfor n in node.handlers:\n\t\tif n.name:g[n.name]=e\n\t\tif n.type is _C:\n\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\tbreak\n\t\telif isinstance(e,eval(compile(ast.Expression(n.type),_D,_E),g)):\n\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\tbreak\n\telse:raise\ndef evaluate(node,g,loop=_A):\n\tif loop and isinstance(node,ast.Break):raise Break\n\tif loop and isinstance(node,ast.Continue):raise Continue\n\tif isinstance(node,ast.Expr):_eval=ast.Expression(node.value);yield eval(compile(_eval,_D,_E),g)\n\telif isinstance(node,ast.If):\n\t\tif eval(compile(ast.Expression(node.test),_D,_E),g):\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\telif node.orelse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.While):\n\t\twhile eval(compile(ast.Expression(node.test),_D,_E),g):\n\t\t\ttry:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,_B)\n\t\t\texcept Break:break\n\t\t\texcept Continue:continue\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.For):\n\t\tfor x in eval(compile(ast.Expression(node.iter),_D,_E),g):\n\t\t\tg[node.target.id]=x\n\t\t\ttry:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,_B)\n\t\t\texcept Break:break\n\t\t\texcept Continue:continue\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.Try):\n\t\ttry:\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\texcept Exception as e:yield from evaluate_except(node,e,g,loop)\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\t\tfinally:\n\t\t\tfor n in node.finalbody:yield from evaluate(n,g,loop)\n\telif PY311 and isinstance(node,ast.TryStar):\n\t\ttry:\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\texcept ExceptionGroup as e:\n\t\t\tfor n in node.handlers:\n\t\t\t\tif n.name:g[n.name]=e\n\t\t\t\tm,e=e.split(eval(compile(ast.Expression(n.type),_D,_E),g))\n\t\t\t\tif m is not _C:\n\t\t\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\t\tif e is _C:break\n\t\t\tif e is not _C:raise e\n\t\texcept Exception as e:yield from evaluate_except(node,e,g,loop)\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\t\tfinally:\n\t\t\tfor n in node.finalbody:yield from evaluate(n,g,loop)\n\telif PY310 and isinstance(node,ast.Match):\n\t\ts=eval(compile(ast.Expression(node.subject),_D,_E),g)\n\t\tfor c in node.cases:\n\t\t\tif compare_match(s,g,c.pattern):\n\t\t\t\tif not c.guard or eval(compile(ast.Expression(c.guard),_D,_E),g):\n\t\t\t\t\tfor n in c.body:yield from evaluate(n,g,loop)\n\t\t\t\t\tbreak\n\telif isinstance(node,ast.With):yield from evaluate_with(node,g,loop)\n\telse:_exec=ast.Module([node],[]);exec(compile(_exec,_D,'exec'),g);yield _C\ndef execute(cmd,no_except=_B,inline=_A,init='',g=_C):\n\tA='\\n';console='';colors=[]\n\tif g is _C:g={'Ramp':Ramp,'Steps':Steps,'Row':Row,'HtmlRow':HtmlRow,'HtmlSteps':HtmlSteps,'HtmlGradient':HtmlGradient}\n\tif init:execute(init.strip(),g=g)\n\tm=RE_INIT.match(cmd)\n\tif m:block_init=m.group(1);src=cmd[m.end():];execute(block_init,g=g)\n\telse:src=cmd\n\tlines=src.split(A)\n\ttry:tree=ast.parse(src)\n\texcept Exception as e:\n\t\tif no_except:\n\t\t\tif not inline:from pymdownx.superfences import SuperFencesException;raise SuperFencesException from e\n\t\t\telse:from pymdownx.inlinehilite import InlineHiliteException;raise InlineHiliteException from e\n\t\timport traceback;return '{}'.format(traceback.format_exc()),colors\n\tfor node in tree.body:\n\t\tresult=[];start=node.lineno;end=node.end_lineno;stmt=lines[start-1:end];command=''\n\t\tfor (i,line) in enumerate(stmt,0):\n\t\t\tif i==0:stmt[i]='>>> '+line\n\t\t\telse:stmt[i]='... '+line\n\t\tcommand+=A.join(stmt)\n\t\tif isinstance(node,AST_BLOCKS):command+='\\n... '\n\t\ttry:\n\t\t\twith StreamOut()as s:\n\t\t\t\tfor x in evaluate(node,g):\n\t\t\t\t\tresult.append(x);text=s.read()\n\t\t\t\t\tif text:result.append(AtomicString(text))\n\t\t\t\tconsole+=command\n\t\texcept Exception as e:\n\t\t\tif no_except:\n\t\t\t\tif not inline:from pymdownx.superfences import SuperFencesException;raise SuperFencesException from e\n\t\t\t\telse:from pymdownx.inlinehilite import InlineHiliteException;raise InlineHiliteException from e\n\t\t\timport traceback;console+='{}\\n{}'.format(command,traceback.format_exc());break\n\t\tresult_text=A\n\t\tfor r in result:\n\t\t\tif r is _C:continue\n\t\t\tfor clist in get_colors(r):\n\t\t\t\tif clist:colors.append(clist)\n\t\t\tresult_text+='{}{}'.format(repr(r)if isinstance(r,str)and not isinstance(r,AtomicString)else str(r),A if not isinstance(r,AtomicString)else'')\n\t\tconsole+=result_text\n\treturn console,colors\ndef colorize(src,lang,**options):HtmlFormatter=find_formatter_class('html');lexer=get_lexer_by_name(lang,**options);formatter=HtmlFormatter(cssclass=_G,wrapcode=_B);return highlight(src,lexer,formatter).strip()\ndef color_command_validator(language,inputs,options,attrs,md):\n\tvalid_inputs=set([_I,'play'])\n\tfor (k,v) in inputs.items():\n\t\tif k in valid_inputs:options[k]=_B;continue\n\t\tattrs[k]=v\n\treturn _B\ndef _color_command_console(colors,gamut=WEBSPACE):\n\tC='{} {}%';B=' ';A='<div class=\"swatch-bar\">{}</div>';el='';bar=_A;values=[]\n\tfor item in colors:\n\t\tif isinstance(item,(HtmlGradient,Steps)):\n\t\t\tcurrent=total=percent=last=0\n\t\t\tif isinstance(item,Steps):total=len(item);percent=100/total;current=percent\n\t\t\tif bar:el+=A.format(B.join(values));values=[]\n\t\t\tsub_el1='<div class=\"swatch-bar\"><span class=\"swatch swatch-gradient\">{}</span></div>';style='--swatch-stops: ';stops=[]\n\t\t\tfor (e,color) in enumerate(item):\n\t\t\t\tcolor.fit(gamut)\n\t\t\t\tif current:\n\t\t\t\t\tstops.append(C.format(color.convert(gamut).to_string(),str(last)));stops.append(C.format(color.convert(gamut).to_string(),str(current)));last=current\n\t\t\t\t\tif e<total-1:current+=percent\n\t\t\t\t\telse:current=100\n\t\t\t\telse:stops.append(color.convert(gamut).to_string())\n\t\t\tif not stops:stops.extend([_M]*2)\n\t\t\tif len(stops)==1:stops.append(stops[0])\n\t\t\tstyle+=','.join(stops);sub_el2='<span class=\"swatch-color\" style=\"{}\"></span>'.format(style);el+=sub_el1.format(sub_el2);bar=_A\n\t\telse:\n\t\t\tis_row=_A\n\t\t\tif isinstance(item,Row):\n\t\t\t\tis_row=_B\n\t\t\t\tif bar and values:el+=A.format(B.join(values));values=[]\n\t\t\t\tbar=_A\n\t\t\tbar=_B\n\t\t\tfor color in item:\n\t\t\t\tbase_classes=_N\n\t\t\t\tif not color.color.in_gamut(gamut):base_classes+=' out-of-gamut'\n\t\t\t\tcolor.color.fit(gamut);srgb=color.color.convert(gamut);value1=srgb.to_string(alpha=_A);value2=srgb.to_string();style='--swatch-stops: {} 50%, {} 50%'.format(value1,value2);title=color.string;classes=base_classes;c='<span class=\"swatch-color\" style=\"{style}\"></span>'.format(style=style);c='<span class=\"{classes}\" title=\"{title}&#013;Copy to clipboard\">{color}</span>'.format(classes=classes,color=c,title=title);values.append(c)\n\t\t\tif is_row and values:el+=A.format(B.join(values));values=[];bar=_A\n\tif bar:el+=A.format(B.join(values));values=[]\n\treturn el\ndef _color_command_formatter(src='',language='',class_name=_C,options=_C,md='',init='',**kwargs):\n\tB='formatter';A='fenced_code_block';global code_id;from pymdownx.superfences import SuperFencesException;gamut=kwargs.get(_J,WEBSPACE);play=options.get('play',_A)if options is not _C else _A\n\tif not play and language==_K:play=_B\n\tif not play:return md.preprocessors[A].extension.superfences[0][B](src=src,class_name=class_name,language='py',md=md,options=options,**kwargs)\n\ttry:\n\t\tif len(md.preprocessors[A].extension.stash)==0:code_id=0\n\t\texceptions=options.get(_I,_A)if options is not _C else _A;console,colors=execute(src.strip(),not exceptions,init=init);el=_color_command_console(colors,gamut=gamut);el+=md.preprocessors[A].extension.superfences[0][B](src=console,class_name=_G,language=_L,md=md,options=options,**kwargs);el=_O.format(el);el=template.format(el_id=code_id,raw_source=_escape(src),results=el,gamut=gamut);code_id+=1\n\texcept SuperFencesException:raise\n\texcept Exception:from pymdownx import superfences;import traceback;print(traceback.format_exc());return superfences.fence_code_format(src,'text',class_name,options,md,**kwargs)\n\treturn el\ndef color_command_formatter(init='',gamut=WEBSPACE):return partial(_color_command_formatter,init=init,gamut=gamut)\ndef _color_formatter(src='',language='',class_name=_C,md='',exceptions=_B,init='',gamut=WEBSPACE):\n\tE='backtick';D='title';C='Only one color allowed';B=' 50%';A='span';from pymdownx.inlinehilite import InlineHiliteException\n\ttry:\n\t\tresult=src.strip()\n\t\ttry:color=ColorAll(result.strip())\n\t\texcept Exception:\n\t\t\t_,colors=execute(result,exceptions,inline=_B,init=init)\n\t\t\tif len(colors)!=1 or len(colors[0])!=1:\n\t\t\t\tif exceptions:raise InlineHiliteException(C)\n\t\t\t\telse:raise ValueError(C)\n\t\t\tcolor=colors[0][0].color;result=colors[0][0].string\n\t\tel=Etree.Element(A);stops=[]\n\t\tif not color.in_gamut(gamut):\n\t\t\tcolor.fit(gamut);attributes={_F:'swatch out-of-gamut',D:result};sub_el=Etree.SubElement(el,A,attributes);stops.append(color.convert(gamut).to_string(hex=_B,alpha=_A))\n\t\t\tif color[-1]<1.0:stops[-1]+=B;stops.append(color.convert(gamut).to_string(hex=_B)+B)\n\t\telse:\n\t\t\tattributes={_F:_N,D:result};sub_el=Etree.SubElement(el,A,attributes);stops.append(color.convert(gamut).to_string(hex=_B,alpha=_A))\n\t\t\tif color[-1]<1.0:stops[-1]+=B;stops.append(color.convert(gamut).to_string(hex=_B)+B)\n\t\tif not stops:stops.extend([_M]*2)\n\t\tif len(stops)==1:stops.append(stops[0])\n\t\tEtree.SubElement(sub_el,A,{_F:'swatch-color','style':'--swatch-stops: {};'.format(','.join(stops))});el.append(md.inlinePatterns[E].handle_code('css-color',result))\n\texcept InlineHiliteException:raise\n\texcept Exception:import traceback;print(traceback.format_exc());el=md.inlinePatterns[E].handle_code('text',src)\n\treturn el\ndef color_formatter(init='',gamut=WEBSPACE):return partial(_color_formatter,init=init,gamut=gamut)\ndef _live_color_command_formatter(src,init='',gamut=WEBSPACE):\n\ttry:\n\t\tconsole,colors=execute(src.strip(),_A,init=init);el=_color_command_console(colors,gamut=gamut)\n\t\tif not colors:el+='<div class=\"swatch-bar\"></div>'\n\t\tel+=colorize(console,_L,**{'python3':_B,'stripnl':_A});el=_O.format(el)\n\texcept Exception:import traceback;return '<div class=\"color-command\"><div class=\"swatch-bar\"></div>{}</div>'.format(colorize(traceback.format_exc(),_L))\n\treturn el\ndef live_color_command_formatter(init='',gamut=WEBSPACE):return partial(_live_color_command_formatter,init=init,gamut=gamut)\ndef live_color_command_validator(language,inputs,options,attrs,md):value=color_command_validator(language,inputs,options,attrs,md);options[_I]=_B;return value\ndef render_console(*args,**kwargs):\n\tC='.swatch-bar';B='code';A='id_num';from js import document;gamut=kwargs.get(_J,WEBSPACE)\n\ttry:\n\t\tinputs=document.getElementById('__playground-inputs_{}'.format(globals()[A]));results=document.getElementById('__playground-results_{}'.format(globals()[A]));footer=document.querySelector('#__playground_{} .gamut'.format(globals()[A]));result=live_color_command_formatter(LIVE_INIT,gamut)(inputs.value);temp=document.createElement('div');temp.innerHTML=result;cmd=results.querySelector('.color-command')\n\t\tfor el in cmd.querySelectorAll(C):el.remove()\n\t\tfor el in temp.querySelectorAll(C):cmd.insertBefore(el,cmd.lastChild)\n\t\tfooter.innerHTML='Gamut: {}'.format(gamut);pre=cmd.querySelector('pre');pre.replaceChild(temp.querySelector(B),pre.querySelector(B));temp.remove();scrollingElement=results.querySelector(B);scrollingElement.scrollTop=scrollingElement.scrollHeight\n\texcept Exception as e:print(e)\ndef render_notebook(*args,**kwargs):\n\tM='diagram';L='pymdownx.blocks.tab';K='pymdownx.blocks.admonition';J='pymdownx.arithmatex';I='pymdownx.keys';H='pymdownx.magiclink';G='pymdownx.inlinehilite';F='pymdownx.superfences';E='markdown.extensions.smarty';D='markdown.extensions.toc';C='validator';B='format';A='name';import markdown;from pymdownx import slugs,superfences;from js import document;gamut=kwargs.get(_J,WEBSPACE);text=globals().get('content','');extensions=[D,E,'pymdownx.betterem','markdown.extensions.attr_list','markdown.extensions.tables','markdown.extensions.abbr','markdown.extensions.footnotes',F,'pymdownx.highlight',G,H,'pymdownx.tilde','pymdownx.caret','pymdownx.smartsymbols','pymdownx.emoji','pymdownx.escapeall','pymdownx.tasklist','pymdownx.striphtml','pymdownx.snippets',I,'pymdownx.saneheaders',J,K,'pymdownx.blocks.details','pymdownx.blocks.html','pymdownx.blocks.definition',L];extension_configs={D:{'slugify':slugs.slugify(case='lower'),'permalink':''},E:{'smart_quotes':_A},J:{'generic':_B,'block_tag':'pre'},F:{'preserve_tabs':_B,'custom_fences':[{A:M,_F:M,B:superfences.fence_code_format},{A:_K,_F:_K,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator},{A:'python',_F:_G,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator},{A:'py',_F:_G,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator}]},G:{'custom_inline':[{A:_H,_F:_H,B:color_formatter(LIVE_INIT,gamut)}]},H:{'repo_url_shortener':_B,'repo_url_shorthand':_B,'social_url_shorthand':_B,'user':'facelessuser','repo':'coloraide'},I:{'separator':'＋'},L:{'alternate_style':_B},K:{'types':['new','settings','note','abstract','info','tip','success','question','warning','failure','danger','bug','example','quote']}}\n\ttry:html=markdown.markdown(text,extensions=extensions,extension_configs=extension_configs)\n\texcept Exception:html=''\n\tcontent=document.getElementById('__notebook-render');content.innerHTML=html\n\naction = globals().get('action')\nif action == 'notebook':\n    callback = render_notebook\nelse:\n    callback = render_console\n\ncallback(gamut='".concat(e,"')\n"),g=window.colorNotebook.defaultPlayground,_=function(t){return"\n/// new | This notebook is powered by [Pyodide](https://github.com/pyodide/pyodide). Learn more [here](?notebook=https://gist.githubusercontent.com/facelessuser/7c819668b5eb248ecb9ac608d91391cf/raw/playground.md). Preview, convert, interpolate, and explore!\n///\n\n````````py play\n".concat(t,"\n````````\n")},y=!1,v=!1,w=function(){f=!0,window.document.dispatchEvent(new Event("DOMContentLoaded",{bubbles:!0,cancelable:!0})),window.document$.next()},x=function(t){var e=window.pageXOffset||(document.documentElement||document.body.parentNode||document.body).scrollLeft,n=window.pageYOffset||(document.documentElement||document.body.parentNode||document.body).scrollTop;t.style.height="5px",t.style.height="".concat(t.scrollHeight,"px"),window.scrollTo(e,n)},b=function(t){return encodeURIComponent(t).replace(/[.!'()*]/g,(function(t){return"%".concat(t.charCodeAt(0).toString(16))}))},E=function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return(o=document.getElementById("__playground-inputs_".concat(n))).setAttribute("readonly",""),a.globals.set("id_num",n),a.globals.set("action","playground"),t.next=6,a.runPythonAsync(h);case 6:o.removeAttribute("readonly");case 7:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),k=function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return a.globals.set("content",n),a.globals.set("action","notebook"),t.next=4,a.runPythonAsync(h);case 4:(o=document.getElementById("__notebook-input"))&&(s=n,o.value=n),window.location.hash&&(window.location.href=window.location.href);case 7:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),L=function(){var e=n(t().mark((function e(n){var o,i,s,l,c,u;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(d){t.next=5;break}return d=!0,t.next=4,loadPyodide({indexURL:"https://cdn.jsdelivr.net/pyodide/v0.22.1/full/",fullStdLib:!1});case 4:a=t.sent;case 5:if((y||!n)&&(v||n)){t.next=14;break}o="".concat(window.location.origin,"/").concat(window.location.pathname.split("/")[1],"/playground/"),i=n?window.colorNotebook.notebookWheels:window.colorNotebook.playgroundWheels,s=[],n?y=!0:v=!0,l=r(i);try{for(l.s();!(c=l.n()).done;)(u=c.value).endsWith(".whl")?s.push(o+u):s.push(u)}catch(t){l.e(t)}finally{l.f()}return t.next=14,a.loadPackage(s);case 14:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),S=function(t,e,n){var o=null==e?"Loading...":e,r=n?"loading relative":"loading",a=document.createElement("template");a.innerHTML='<div class="'.concat(r,'"><div class="loader"></div><div>').concat(o,"</div></div>"),t.appendChild(a.content.firstChild)},A=function(t){t.querySelector(".loading")&&t.removeChild(t.querySelector(".loading"))},C=function(t){if("Tab"===t.key){var e=t.target;if(e.selectionStart!==e.selectionEnd){t.preventDefault();for(var n=e.selectionStart,o=e.selectionEnd,r=e.value;n>0&&"\n"!==r[n-1];)n--;for(;o>0&&"\n"!==r[o-1]&&o<r.length;)o++;for(var a=r.substr(n,o-n).split("\n"),i=0;i<a.length;i++)i===a.length-1&&0===a[i].length||(t.shiftKey?a[i]=a[i].replace(m,""):a[i]="    ".concat(a[i]));a=a.join("\n"),e.value=r.substr(0,n)+a+r.substr(o),e.selectionStart=n,e.selectionEnd=n+a.length}}},B=function(){var e=n(t().mark((function e(o){var r;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:r=document.getElementById("__notebook-source"),document.querySelectorAll(".playground").forEach((function(e){var a=e.id.replace(u,"$1"),d=document.getElementById("__playground-inputs_".concat(a)),p=document.getElementById("__playground-results_".concat(a)),f=document.getElementById("__playground-code_".concat(a)),m=document.querySelector("button#__playground-edit_".concat(a)),h=document.querySelector("button#__playground-share_".concat(a)),g=document.querySelector("button#__playground-run_".concat(a)),_=document.querySelector("button#__playground-cancel_".concat(a));if(d.addEventListener("input",(function(){x(d)})),d.addEventListener("keydown",C),r&&o){var y=document.getElementById("__notebook-input");y.addEventListener("input",(function(t){x(t.target)})),y.addEventListener("keydown",C),document.getElementById("__notebook-edit").addEventListener("click",(function(){c[y.id]=y.value,document.getElementById("__notebook-render").classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden"),x(document.getElementById("__notebook-input"))})),document.getElementById("__notebook-md-gist").addEventListener("click",function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:null!==(o=prompt("Please enter link to the Markdown page source:",l))&&(o=b(o),n.preventDefault(),history.pushState({notebook:o},"","?".concat(new URLSearchParams("notebook=".concat(o)).toString())),I(!1));case 2:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()),document.getElementById("__notebook-py-gist").addEventListener("click",function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:null!==(o=prompt("Please enter the link to the Python code source:",l))&&(o=b(o),n.preventDefault(),history.pushState({source:o},"","?".concat(new URLSearchParams("source=".concat(o)).toString())),I(!1));case 2:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()),document.getElementById("__notebook-input").value=s,document.getElementById("__notebook-cancel").addEventListener("click",(function(){y.value=c[y.id],delete c[y.id],document.getElementById("__notebook-render").classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden")})),document.getElementById("__notebook-submit").addEventListener("click",n(t().mark((function e(){var n,o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return n=document.getElementById("__notebook-render"),s=document.getElementById("__notebook-input").value,n.classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden"),o=document.querySelector("article"),S(o,"Loading Notebook..."),n.innerHTML="",c={},t.next=10,L(!0);case 10:return t.next=12,k(s);case 12:return t.next=14,B();case 14:A(o),w();case 16:case"end":return t.stop()}}),e)}))))}d.addEventListener("touchmove",(function(t){t.stopPropagation()})),p.addEventListener("click",(function(t){var e=t.target;if(e.matches("span.swatch-color")){var n="",o=e.parentNode;if(!o.matches("span.swatch-gradient")){if(n=(n=o.getAttribute("title").replace("Copy to clipboard","")).replace("\n",""),window.clipboardData&&window.clipboardData.setData)return window.clipboardData.setData("Text",n);if(document.queryCommandSupported&&document.queryCommandSupported("copy")){var r=document.createElement("textarea");r.textContent=n,r.style.position="fixed",document.body.appendChild(r),r.select();try{return document.execCommand("copy")}catch(t){return prompt("Copy to clipboard: Ctrl+C, Enter",n)}finally{document.body.removeChild(r)}}}}})),m.addEventListener("click",n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:c[a]=d.value,f.classList.toggle("hidden"),p.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),x(d),d.focus();case 9:case"end":return t.stop()}}),e)})))),h.addEventListener("click",n(t().mark((function e(){var o,r,a,i,s;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:o=window.location.pathname.split("/")[1],r=b(d.value),a=window.location,i="/playground/",a.pathname.startsWith("/".concat(o,"/"))&&(i="/".concat(o,"/playground/")),(s="".concat(a.protocol,"//").concat(a.host).concat(i,"?code=").concat(r)).length>2e3?alert("Code must be small enough to generate a shareable URL under 2000 characters!"):navigator.clipboard.writeText(s).then(n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:alert("Link copied to clipboard :)");case 1:case"end":return t.stop()}}),e)}))),n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:alert("Failed to copy link clipboard!");case 1:case"end":return t.stop()}}),e)}))));case 7:case"end":return e.stop()}}),e)})))),g.addEventListener("click",n(t().mark((function e(){var n,o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(!i){t.next=2;break}return t.abrupt("return");case 2:return i=!0,n=f.querySelector("form"),S(n,null,!0),(o=document.querySelectorAll(".playground .playground-run"))&&o.forEach((function(t){t.setAttribute("disabled","")})),t.next=9,L(!1);case 9:return p.querySelector("code").innerHTML="",t.next=12,E(a);case 12:o&&o.forEach((function(t){t.removeAttribute("disabled")})),A(n),f.classList.toggle("hidden"),p.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden"),delete c[a],i=!1;case 22:case"end":return t.stop()}}),e)})))),_.addEventListener("click",(function(){d.value=c[a],delete c[a],f.classList.toggle("hidden"),p.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden")}))}));case 3:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),I=function(){var e=n(t().mark((function e(o,r){var a,i,s,u,d,f,m,h,y;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(c={},!window.location.pathname.endsWith("/playground/")){e.next=32;break}if(a=r||new URLSearchParams(window.location.search),i="Loading Pyodide...",s="Loading Notebook...",u=a.has("source")?a.get("source"):a.get("notebook"),d=document.querySelector("article"),null===u||!u.trim()){e.next=16;break}return S(d,i),e.next=11,L(!0);case 11:A(d),S(d,s);try{f=a.has("source")?"source":"notebook",p=decodeURIComponent(a.toString()),m="",h=new XMLHttpRequest,l=u,h.open("GET",u,!0),h.onload=n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return 4===h.readyState&&200===h.status&&(m=h.responseText),"source"===f&&(m=_(m)),t.next=4,k(m);case 4:return t.next=6,B(o);case 6:A(d),w();case 8:case"end":return t.stop()}}),e)}))),h.send()}catch(t){}e.next=30;break;case 16:return l="",y=_(a.has("code")?a.get("code"):g),p=decodeURIComponent(a.toString()),S(d,i),e.next=22,L(!0);case 22:return A(d),S(d,s),e.next=26,k(y);case 26:return e.next=28,B(o);case 28:A(d),w();case 30:e.next=35;break;case 32:l="",p="",B(o);case 35:case"end":return e.stop()}}),e)})));return function(t,n){return e.apply(this,arguments)}}();document.addEventListener("click",(function(t){var e=window.location.pathname.split("/")[1],n=t.target||t.srcElement;if("A"===n.tagName&&I&&n.getAttribute("href")&&n.host===window.location.host&&window.location.pathname==="/".concat(e,"/playground/")&&window.location.pathname===n.pathname&&window.location.search!==n.search){t.preventDefault();var o=new URLSearchParams(n.search);I(!1,o)}}),!0),window.addEventListener("popstate",(function(){var t=window.location.pathname.split("/")[1];window.location.pathname==="/".concat(t,"/playground/")&&(decodeURIComponent(new URLSearchParams(window.location.search).toString())!==p&&I(!1))})),window.addEventListener("unload",(function(){f=!1})),window.document$.subscribe((function(){f?f=!1:I(!0)}))}()}();
+//# sourceMappingURL=extra-notebook-23a5be25.js.map
diff --git a/docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js.map b/docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js.map
similarity index 99%
rename from docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js.map
rename to docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js.map
index cf95521d3..e0b0bfe61 100644
--- a/docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js.map
+++ b/docs/theme/assets/coloraide-extras/extra-notebook-23a5be25.js.map
@@ -1,1 +1,1 @@
-{"version":3,"file":"extra-notebook-37ed2f98.js","sources":["extra-notebook.js"],"sourcesContent":["(() => {\n  let webspace = ''\n  try {\n    let gamut = 'srgb'\n    if (window.matchMedia(\"(color-gamut: rec2020)\").matches) {\n      gamut = 'rec2020'\n    } else if (window.matchMedia(\"(color-gamut: p3)\").matches) {\n      gamut = 'display-p3'\n    }\n    webspace = (CSS.supports('color: color(display-p3 1 0 0)')) ? gamut : 'srgb'\n  } catch {\n    webspace = 'srgb'\n  }\n  let pyodide = null\n  let busy = false\n  let raw = \"\"\n  let gist = \"\"\n  let editTemp = {}\n  const reIdNum = /.*?_(\\d+)$/\n  let initialized = false\n  let lastSearch = \"\"\n  let fake = false\n  const tabStart = /^( {1,4}|\\t)/\n  // This is the Python payload that will be executed when the user\n  // presses the `Run` button. It will execute the code, create a\n  // Python console output, find color references, steps, and interpolation\n  // references and render the appropriate preview.\n  const pycode = `\n{{pycode}}\n\naction = globals().get('action')\nif action == 'notebook':\n    callback = render_notebook\nelse:\n    callback = render_console\n\ncallback(gamut='${webspace}')\n`\n\n  const defContent = window.colorNotebook.defaultPlayground\n\n  const getContent = content => {\n    return `\n/// new | This notebook is powered by [Pyodide](https://github.com/pyodide/pyodide). \\\nLearn more [here](\\\n?notebook=https://gist.githubusercontent.com/facelessuser/7c819668b5eb248ecb9ac608d91391cf/raw/playground.md\\\n). Preview, convert, interpolate, and explore!\n///\n\n\\`\\`\\`\\`\\`\\`\\`\\`py play\n${content}\n\\`\\`\\`\\`\\`\\`\\`\\`\n`\n  }\n\n  let notebookInstalled = false\n  let playgroundInstalled = false\n\n  const fakeDOMContentLoaded = () => {\n    // Send a fake `DOMContentLoaded`\n    fake = true\n    window.document.dispatchEvent(new Event(\"DOMContentLoaded\", {\n      bubbles: true,\n      cancelable: true\n    }))\n    window.document$.next()\n  }\n\n  const textResize = inpt => {\n    // Resize inputs based on text height.\n\n    const scrollLeft = window.pageXOffset ||\n      (document.documentElement || document.body.parentNode || document.body).scrollLeft\n\n    const scrollTop  = window.pageYOffset ||\n      (document.documentElement || document.body.parentNode || document.body).scrollTop\n\n    inpt.style.height = \"5px\"\n    inpt.style.height = `${inpt.scrollHeight}px`\n\n    window.scrollTo(scrollLeft, scrollTop)\n  }\n\n  const encodeuri = uri => {\n    // Encode the URI component.\n\n    return encodeURIComponent(uri).replace(/[.!'()*]/g, c => {\n      return `%${c.charCodeAt(0).toString(16)}`\n    })\n  }\n\n  const pyexecute = async currentID => {\n    // Execute Python code inside a playground\n\n    const currentInputs = document.getElementById(`__playground-inputs_${currentID}`)\n    currentInputs.setAttribute(\"readonly\", \"\")\n    pyodide.globals.set(\"id_num\", currentID)\n    pyodide.globals.set(\"action\", \"playground\")\n    await pyodide.runPythonAsync(pycode)\n    currentInputs.removeAttribute(\"readonly\")\n  }\n\n  const pyrender = async text => {\n    // Render an entire notebook page\n\n    pyodide.globals.set(\"content\", text)\n    pyodide.globals.set(\"action\", \"notebook\")\n    await pyodide.runPythonAsync(pycode)\n    const src = document.getElementById(\"__notebook-input\")\n    if (src) {\n      raw = text\n      src.value = text\n    }\n    if (window.location.hash) {\n      // Force jumping to hashes\n      window.location.href = window.location.href // eslint-disable-line no-self-assign\n    }\n  }\n\n  const setupPyodide = async full => {\n    // Load `Pyodide` and the any default packages we can need and can load.\n\n    if (!initialized) {\n      initialized = true\n      pyodide = await loadPyodide({ // eslint-disable-line no-undef\n        indexURL: \"https://cdn.jsdelivr.net/pyodide/v0.22.1/full/\",\n        fullStdLib: false\n      })\n    }\n\n    if ((!notebookInstalled && full) || (!playgroundInstalled && !full)) {\n      const base = `${window.location.origin}/${window.location.pathname.split('/')[1]}/playground/`\n      const packages = (full) ? window.colorNotebook.notebookWheels : window.colorNotebook.playgroundWheels\n      const installs = []\n      if (full) {\n        notebookInstalled = true\n      } else {\n        playgroundInstalled = true\n      }\n      for (const s of packages) {\n        if (s.endsWith('.whl')) {\n          installs.push(base + s)\n        } else {\n          installs.push(s)\n        }\n      }\n      await pyodide.loadPackage(installs)\n    }\n  }\n\n  const showBusy = (target, label, relative) => {\n    // Show busy indicator\n\n    const loaderLabel = (typeof label === \"undefined\" || label === null) ? \"Loading...\" : label\n    const classes = relative ? \"loading relative\" : \"loading\"\n    const template = document.createElement(\"template\")\n    template.innerHTML = `<div class=\"${classes}\"><div class=\"loader\"></div><div>${loaderLabel}</div></div>`\n    target.appendChild(template.content.firstChild)\n  }\n\n  const hideBusy = target => {\n    // Hide busy indicator\n\n    const loading = target.querySelector(\".loading\")\n    if (loading) {\n      target.removeChild(target.querySelector(\".loading\"))\n    }\n  }\n\n  const popState = () => {\n    // Handle notebook history\n\n    const base = window.location.pathname.split('/')[1]\n    if (\n      window.location.pathname === `/${base}/playground/`\n    ) {\n      const current = decodeURIComponent(new URLSearchParams(window.location.search).toString())\n      if (current !== lastSearch) {\n        main(false) // eslint-disable-line no-use-before-define\n      }\n    }\n  }\n\n  const interceptClickEvent = e => {\n    // Catch links to other notebook pages and handle them\n\n    const base = window.location.pathname.split('/')[1]\n    const target = e.target || e.srcElement\n    if (target.tagName === \"A\" && main) { // eslint-disable-line no-use-before-define\n      if (\n        target.getAttribute(\"href\") &&\n        target.host === window.location.host &&\n        window.location.pathname === `/${base}/playground/` &&\n        window.location.pathname === target.pathname &&\n        window.location.search !== target.search\n      ) {\n        e.preventDefault()\n        const search = new URLSearchParams(target.search)\n        main(false, search) // eslint-disable-line no-use-before-define\n      }\n    }\n  }\n\n  const handleTab = e => {\n    // Prevent tab from tabbing out.\n\n    if (e.key === 'Tab') {\n      const target = e.target\n\n      if (target.selectionStart !== target.selectionEnd) {\n        e.preventDefault()\n\n        let start = target.selectionStart\n        let end = target.selectionEnd\n\n        const text = target.value\n\n        while (start > 0 && text[start - 1] !== '\\n') {\n          start--\n        }\n        while (end > 0 && text[end - 1] !== '\\n' && end < text.length) {\n          end++\n        }\n\n        let lines = text.substr(start, end - start).split('\\n')\n\n        for (let i = 0; i < lines.length; i++) {\n\n          // Don't indent last line if cursor at start of line\n          if (i === lines.length - 1 && lines[i].length === 0) {\n            continue\n          }\n\n          // Indent or deindent\n          if (e.shiftKey) {\n            lines[i] = lines[i].replace(tabStart, '')\n          } else {\n            lines[i] = `    ${lines[i]}`\n          }\n        }\n        lines = lines.join('\\n')\n\n        // Update the text area\n        target.value = text.substr(0, start) + lines + text.substr(end)\n        target.selectionStart = start\n        target.selectionEnd = start + lines.length\n      }\n    }\n  }\n\n  const init = async first => {\n    // Setup input highlighting and events to run Python code blocks.\n\n    const notebook = document.getElementById(\"__notebook-source\")\n    const playgrounds = document.querySelectorAll(\".playground\")\n    playgrounds.forEach(pg => {\n\n      const currentID = pg.id.replace(reIdNum, \"$1\")\n      const inputs = document.getElementById(`__playground-inputs_${currentID}`)\n      const results = document.getElementById(`__playground-results_${currentID}`)\n      const pgcode = document.getElementById(`__playground-code_${currentID}`)\n      const buttonEdit = document.querySelector(`button#__playground-edit_${currentID}`)\n      const buttonShare = document.querySelector(`button#__playground-share_${currentID}`)\n      const buttonRun = document.querySelector(`button#__playground-run_${currentID}`)\n      const buttonCancel = document.querySelector(`button#__playground-cancel_${currentID}`)\n\n      inputs.addEventListener(\"input\", () => {\n        // Adjust textarea height on text input.\n\n        textResize(inputs)\n      })\n\n      inputs.addEventListener('keydown', handleTab)\n\n      if (notebook && first) {\n        const notebookInput = document.getElementById(\"__notebook-input\")\n\n        notebookInput.addEventListener(\"input\", e => {\n          // Adjust textarea height on text input.\n\n          textResize(e.target)\n        })\n\n        notebookInput.addEventListener('keydown', handleTab)\n\n        const editPage = document.getElementById(\"__notebook-edit\")\n        editPage.addEventListener(\"click\", () => {\n          editTemp[notebookInput.id] = notebookInput.value\n          document.getElementById(\"__notebook-render\").classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n          textResize(document.getElementById(\"__notebook-input\"))\n        })\n\n        document.getElementById(\"__notebook-md-gist\").addEventListener(\"click\", async e => {\n          let uri = prompt(\"Please enter link to the Markdown page source:\", gist) // eslint-disable-line no-alert\n          if (uri !== null) {\n            uri = encodeuri(uri)\n            e.preventDefault()\n            history.pushState({notebook: uri}, \"\", `?${new URLSearchParams(`notebook=${uri}`).toString()}`)\n            main(false) // eslint-disable-line no-use-before-define\n          }\n        })\n\n        document.getElementById(\"__notebook-py-gist\").addEventListener(\"click\", async e => {\n          let uri = prompt(\"Please enter the link to the Python code source:\", gist) // eslint-disable-line no-alert\n          if (uri !== null) {\n            uri = encodeuri(uri)\n            e.preventDefault()\n            history.pushState({source: uri}, \"\", `?${new URLSearchParams(`source=${uri}`).toString()}`)\n            main(false) // eslint-disable-line no-use-before-define\n          }\n        })\n\n        document.getElementById(\"__notebook-input\").value = raw\n        document.getElementById(\"__notebook-cancel\").addEventListener(\"click\", () => {\n          notebookInput.value = editTemp[notebookInput.id]\n          delete editTemp[notebookInput.id]\n          document.getElementById(\"__notebook-render\").classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n        })\n\n        document.getElementById(\"__notebook-submit\").addEventListener(\"click\", async() => {\n          const render = document.getElementById(\"__notebook-render\")\n          raw = document.getElementById(\"__notebook-input\").value\n          render.classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n          const article = document.querySelector(\"article\")\n          showBusy(article, \"Loading Notebook...\")\n          render.innerHTML = \"\"\n          editTemp = {}\n          await setupPyodide(true)\n          await pyrender(raw)\n          await init()\n          hideBusy(article)\n          fakeDOMContentLoaded()\n        })\n      }\n\n      inputs.addEventListener(\"touchmove\", e => {\n        // Stop propagation on \"touchmove\".\n\n        e.stopPropagation()\n      })\n\n      results.addEventListener(\"click\", e => {\n        // Handle clicks on results and copies color from single color swatch when clicked.\n\n        const el = e.target\n        if (el.matches('span.swatch-color')) {\n          let content = ''\n          const parent = el.parentNode\n          if (!parent.matches('span.swatch-gradient')) {\n            content = parent.getAttribute('title').replace('Copy to clipboard', '')\n            content = content.replace('\\n', '')\n            if (window.clipboardData && window.clipboardData.setData) {\n              // Old `IE`` handling, do we really need this?\n              return window.clipboardData.setData(\"Text\", content)\n            } else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n              const textarea = document.createElement(\"textarea\")\n              textarea.textContent = content\n              textarea.style.position = \"fixed\"\n              document.body.appendChild(textarea)\n              textarea.select()\n              try {\n                return document.execCommand(\"copy\")\n              } catch (ex) {\n                return prompt(\"Copy to clipboard: Ctrl+C, Enter\", content) // eslint-disable-line no-alert\n              } finally {\n                document.body.removeChild(textarea)\n              }\n            }\n          }\n        }\n      })\n\n      buttonEdit.addEventListener(\"click\", async() => {\n        // Handle the button click: show source or execute source.\n\n        editTemp[currentID] = inputs.value\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        textResize(inputs)\n        inputs.focus()\n      })\n\n      buttonShare.addEventListener(\"click\", async() => {\n        // Handle the share click: copy URL with code as parameter.\n\n        const base = window.location.pathname.split('/')[1]\n        const uri = encodeuri(inputs.value)\n        const loc = window.location\n        let pathname = \"/playground/\"\n        if (loc.pathname.startsWith(`/${base}/`)) {\n          pathname = `/${base}/playground/`\n        }\n        const path = `${loc.protocol}//${loc.host}${pathname}?code=${uri}`\n        if (path.length > 2000) {\n          alert( // eslint-disable-line no-alert\n            \"Code must be small enough to generate a shareable URL under 2000 characters!\"\n          )\n        } else {\n          navigator.clipboard.writeText(path).then(async() => {\n            alert(\"Link copied to clipboard :)\") // eslint-disable-line no-alert\n          }, async() => {\n            alert(\"Failed to copy link clipboard!\") // eslint-disable-line no-alert\n          })\n        }\n      })\n\n      buttonRun.addEventListener(\"click\", async() => {\n        // Handle the button click: show source or execute source.\n\n        if (busy) {\n          return\n        }\n\n        busy = true\n        // Load Pyodide and related packages.\n        const form = pgcode.querySelector(\"form\")\n        showBusy(form, null, true)\n        const buttons = document.querySelectorAll(\".playground .playground-run\")\n        if (buttons) {\n          buttons.forEach(b => {\n            b.setAttribute(\"disabled\", \"\")\n          })\n        }\n        await setupPyodide(false)\n        results.querySelector(\"code\").innerHTML = \"\"\n        await pyexecute(currentID)\n        if (buttons) {\n          buttons.forEach(b => {\n            b.removeAttribute(\"disabled\")\n          })\n        }\n        hideBusy(form)\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n\n        delete editTemp[currentID]\n        busy = false\n      })\n\n      buttonCancel.addEventListener(\"click\", () => {\n        // Cancel edit.\n\n        inputs.value = editTemp[currentID]\n        delete editTemp[currentID]\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n      })\n    })\n  }\n\n  const main = async(first, search) => {\n    // Load external source to render in a playground.\n    // This can be something like a file on a gist we must read in (?source=)\n    // or raw code (?code=).\n\n    editTemp = {}\n\n    if (window.location.pathname.endsWith(\"/playground/\")) {\n      const params = search || new URLSearchParams(window.location.search)\n      const loadMsg = \"Loading Pyodide...\"\n      const pageMsg = \"Loading Notebook...\"\n      const uri = params.has(\"source\") ? params.get(\"source\") : params.get(\"notebook\")\n      const article = document.querySelector(\"article\")\n      if (uri !== null && uri.trim()) {\n        // A source was specified, so load it.\n        showBusy(article, loadMsg)\n        await setupPyodide(true)\n        hideBusy(article)\n        showBusy(article, pageMsg)\n        try {\n          const gistType = params.has(\"source\") ? \"source\" : \"notebook\"\n          lastSearch = decodeURIComponent(params.toString())\n          let value = \"\"\n          const xhr = new XMLHttpRequest()\n          gist = uri\n          xhr.open(\"GET\", uri, true)\n          xhr.onload = async() => {\n            // Try and load the requested content\n            if (xhr.readyState === 4) {\n              if (xhr.status === 200) {\n                value = xhr.responseText\n              }\n            }\n\n            if (gistType === \"source\") {\n              value = getContent(value)\n            }\n            await pyrender(value)\n            await init(first)\n            hideBusy(article)\n            fakeDOMContentLoaded()\n          }\n          xhr.send()\n        } catch (err) {} // eslint-disable-line no-empty\n      } else {\n        gist = \"\"\n        const content = getContent(params.has(\"code\") ? params.get(\"code\") : defContent)\n        lastSearch = decodeURIComponent(params.toString())\n        showBusy(article, loadMsg)\n        await setupPyodide(true)\n        hideBusy(article)\n        showBusy(article, pageMsg)\n        await pyrender(content)\n        await init(first)\n        hideBusy(article)\n        fakeDOMContentLoaded()\n      }\n    } else {\n      gist = \"\"\n      lastSearch = \"\"\n      init(first)\n    }\n  }\n\n  // Capture links in notebook pages so that we can make playgound links load instantly\n  document.addEventListener(\"click\", interceptClickEvent, true)\n\n  // Handle history of notebook pages as they are loaded dynamically\n  window.addEventListener(\"popstate\", popState)\n\n  // Before leaving, turn off fake, just in case we navigated away before finished\n  window.addEventListener(\"unload\", () => {\n    fake = false\n  })\n\n  // Attach main via subscribe (subscribes to Materials on page load and instant page loads)\n  window.document$.subscribe(() => {\n    // To get other libraries to reload, we may create a fake `DOMContentLoaded`\n    // No need to process these events.\n    if (fake) {\n      fake = false\n      return\n    }\n    main(true)\n  })\n})()\n"],"names":["webspace","gamut","window","matchMedia","matches","CSS","supports","_unused","pyodide","busy","raw","gist","editTemp","reIdNum","initialized","lastSearch","fake","tabStart","pycode","concat","defContent","colorNotebook","defaultPlayground","getContent","content","notebookInstalled","playgroundInstalled","fakeDOMContentLoaded","document","dispatchEvent","Event","bubbles","cancelable","document$","next","textResize","inpt","scrollLeft","pageXOffset","documentElement","body","parentNode","scrollTop","pageYOffset","style","height","scrollHeight","scrollTo","encodeuri","uri","encodeURIComponent","replace","c","charCodeAt","toString","pyexecute","_ref","_asyncToGenerator","_regeneratorRuntime","mark","_callee","currentID","currentInputs","wrap","_context","prev","getElementById","setAttribute","globals","set","runPythonAsync","removeAttribute","stop","_x","apply","this","arguments","pyrender","_ref2","_callee2","text","src","_context2","value","location","hash","href","_x2","setupPyodide","_ref3","_callee3","full","base","packages","installs","_iterator","_step","s","_context3","loadPyodide","indexURL","fullStdLib","sent","origin","pathname","split","notebookWheels","playgroundWheels","_createForOfIteratorHelper","n","done","endsWith","push","err","e","f","loadPackage","_x3","showBusy","target","label","relative","loaderLabel","classes","template","createElement","innerHTML","appendChild","firstChild","hideBusy","querySelector","removeChild","handleTab","key","selectionStart","selectionEnd","preventDefault","start","end","length","lines","substr","i","shiftKey","join","init","_ref4","_callee12","first","notebook","_context12","querySelectorAll","forEach","pg","id","inputs","results","pgcode","buttonEdit","buttonShare","buttonRun","buttonCancel","addEventListener","notebookInput","classList","toggle","_ref5","_callee4","_context4","prompt","history","pushState","URLSearchParams","main","_x5","_ref6","_callee5","_context5","source","_x6","_callee6","render","article","_context6","stopPropagation","el","parent","getAttribute","clipboardData","setData","queryCommandSupported","textarea","textContent","position","select","execCommand","ex","_callee7","_context7","focus","_callee10","loc","path","_context10","startsWith","protocol","host","alert","navigator","clipboard","writeText","then","_callee8","_context8","_callee9","_context9","_callee11","form","buttons","_context11","abrupt","b","_x4","_ref13","_callee14","search","params","loadMsg","pageMsg","gistType","xhr","_context14","has","get","trim","decodeURIComponent","XMLHttpRequest","open","onload","_callee13","_context13","readyState","status","responseText","send","_x7","_x8","srcElement","tagName","subscribe"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"extra-notebook-23a5be25.js","sources":["extra-notebook.js"],"sourcesContent":["(() => {\n  let webspace = ''\n  try {\n    let gamut = 'srgb'\n    if (window.matchMedia(\"(color-gamut: rec2020)\").matches) {\n      gamut = 'rec2020'\n    } else if (window.matchMedia(\"(color-gamut: p3)\").matches) {\n      gamut = 'display-p3'\n    }\n    webspace = (CSS.supports('color: color(display-p3 1 0 0)')) ? gamut : 'srgb'\n  } catch {\n    webspace = 'srgb'\n  }\n  let pyodide = null\n  let busy = false\n  let raw = \"\"\n  let gist = \"\"\n  let editTemp = {}\n  const reIdNum = /.*?_(\\d+)$/\n  let initialized = false\n  let lastSearch = \"\"\n  let fake = false\n  const tabStart = /^( {1,4}|\\t)/\n  // This is the Python payload that will be executed when the user\n  // presses the `Run` button. It will execute the code, create a\n  // Python console output, find color references, steps, and interpolation\n  // references and render the appropriate preview.\n  const pycode = `\n{{pycode}}\n\naction = globals().get('action')\nif action == 'notebook':\n    callback = render_notebook\nelse:\n    callback = render_console\n\ncallback(gamut='${webspace}')\n`\n\n  const defContent = window.colorNotebook.defaultPlayground\n\n  const getContent = content => {\n    return `\n/// new | This notebook is powered by [Pyodide](https://github.com/pyodide/pyodide). \\\nLearn more [here](\\\n?notebook=https://gist.githubusercontent.com/facelessuser/7c819668b5eb248ecb9ac608d91391cf/raw/playground.md\\\n). Preview, convert, interpolate, and explore!\n///\n\n\\`\\`\\`\\`\\`\\`\\`\\`py play\n${content}\n\\`\\`\\`\\`\\`\\`\\`\\`\n`\n  }\n\n  let notebookInstalled = false\n  let playgroundInstalled = false\n\n  const fakeDOMContentLoaded = () => {\n    // Send a fake `DOMContentLoaded`\n    fake = true\n    window.document.dispatchEvent(new Event(\"DOMContentLoaded\", {\n      bubbles: true,\n      cancelable: true\n    }))\n    window.document$.next()\n  }\n\n  const textResize = inpt => {\n    // Resize inputs based on text height.\n\n    const scrollLeft = window.pageXOffset ||\n      (document.documentElement || document.body.parentNode || document.body).scrollLeft\n\n    const scrollTop  = window.pageYOffset ||\n      (document.documentElement || document.body.parentNode || document.body).scrollTop\n\n    inpt.style.height = \"5px\"\n    inpt.style.height = `${inpt.scrollHeight}px`\n\n    window.scrollTo(scrollLeft, scrollTop)\n  }\n\n  const encodeuri = uri => {\n    // Encode the URI component.\n\n    return encodeURIComponent(uri).replace(/[.!'()*]/g, c => {\n      return `%${c.charCodeAt(0).toString(16)}`\n    })\n  }\n\n  const pyexecute = async currentID => {\n    // Execute Python code inside a playground\n\n    const currentInputs = document.getElementById(`__playground-inputs_${currentID}`)\n    currentInputs.setAttribute(\"readonly\", \"\")\n    pyodide.globals.set(\"id_num\", currentID)\n    pyodide.globals.set(\"action\", \"playground\")\n    await pyodide.runPythonAsync(pycode)\n    currentInputs.removeAttribute(\"readonly\")\n  }\n\n  const pyrender = async text => {\n    // Render an entire notebook page\n\n    pyodide.globals.set(\"content\", text)\n    pyodide.globals.set(\"action\", \"notebook\")\n    await pyodide.runPythonAsync(pycode)\n    const src = document.getElementById(\"__notebook-input\")\n    if (src) {\n      raw = text\n      src.value = text\n    }\n    if (window.location.hash) {\n      // Force jumping to hashes\n      window.location.href = window.location.href // eslint-disable-line no-self-assign\n    }\n  }\n\n  const setupPyodide = async full => {\n    // Load `Pyodide` and the any default packages we can need and can load.\n\n    if (!initialized) {\n      initialized = true\n      pyodide = await loadPyodide({ // eslint-disable-line no-undef\n        indexURL: \"https://cdn.jsdelivr.net/pyodide/v0.22.1/full/\",\n        fullStdLib: false\n      })\n    }\n\n    if ((!notebookInstalled && full) || (!playgroundInstalled && !full)) {\n      const base = `${window.location.origin}/${window.location.pathname.split('/')[1]}/playground/`\n      const packages = (full) ? window.colorNotebook.notebookWheels : window.colorNotebook.playgroundWheels\n      const installs = []\n      if (full) {\n        notebookInstalled = true\n      } else {\n        playgroundInstalled = true\n      }\n      for (const s of packages) {\n        if (s.endsWith('.whl')) {\n          installs.push(base + s)\n        } else {\n          installs.push(s)\n        }\n      }\n      await pyodide.loadPackage(installs)\n    }\n  }\n\n  const showBusy = (target, label, relative) => {\n    // Show busy indicator\n\n    const loaderLabel = (typeof label === \"undefined\" || label === null) ? \"Loading...\" : label\n    const classes = relative ? \"loading relative\" : \"loading\"\n    const template = document.createElement(\"template\")\n    template.innerHTML = `<div class=\"${classes}\"><div class=\"loader\"></div><div>${loaderLabel}</div></div>`\n    target.appendChild(template.content.firstChild)\n  }\n\n  const hideBusy = target => {\n    // Hide busy indicator\n\n    const loading = target.querySelector(\".loading\")\n    if (loading) {\n      target.removeChild(target.querySelector(\".loading\"))\n    }\n  }\n\n  const popState = () => {\n    // Handle notebook history\n\n    const base = window.location.pathname.split('/')[1]\n    if (\n      window.location.pathname === `/${base}/playground/`\n    ) {\n      const current = decodeURIComponent(new URLSearchParams(window.location.search).toString())\n      if (current !== lastSearch) {\n        main(false) // eslint-disable-line no-use-before-define\n      }\n    }\n  }\n\n  const interceptClickEvent = e => {\n    // Catch links to other notebook pages and handle them\n\n    const base = window.location.pathname.split('/')[1]\n    const target = e.target || e.srcElement\n    if (target.tagName === \"A\" && main) { // eslint-disable-line no-use-before-define\n      if (\n        target.getAttribute(\"href\") &&\n        target.host === window.location.host &&\n        window.location.pathname === `/${base}/playground/` &&\n        window.location.pathname === target.pathname &&\n        window.location.search !== target.search\n      ) {\n        e.preventDefault()\n        const search = new URLSearchParams(target.search)\n        main(false, search) // eslint-disable-line no-use-before-define\n      }\n    }\n  }\n\n  const handleTab = e => {\n    // Prevent tab from tabbing out.\n\n    if (e.key === 'Tab') {\n      const target = e.target\n\n      if (target.selectionStart !== target.selectionEnd) {\n        e.preventDefault()\n\n        let start = target.selectionStart\n        let end = target.selectionEnd\n\n        const text = target.value\n\n        while (start > 0 && text[start - 1] !== '\\n') {\n          start--\n        }\n        while (end > 0 && text[end - 1] !== '\\n' && end < text.length) {\n          end++\n        }\n\n        let lines = text.substr(start, end - start).split('\\n')\n\n        for (let i = 0; i < lines.length; i++) {\n\n          // Don't indent last line if cursor at start of line\n          if (i === lines.length - 1 && lines[i].length === 0) {\n            continue\n          }\n\n          // Indent or deindent\n          if (e.shiftKey) {\n            lines[i] = lines[i].replace(tabStart, '')\n          } else {\n            lines[i] = `    ${lines[i]}`\n          }\n        }\n        lines = lines.join('\\n')\n\n        // Update the text area\n        target.value = text.substr(0, start) + lines + text.substr(end)\n        target.selectionStart = start\n        target.selectionEnd = start + lines.length\n      }\n    }\n  }\n\n  const init = async first => {\n    // Setup input highlighting and events to run Python code blocks.\n\n    const notebook = document.getElementById(\"__notebook-source\")\n    const playgrounds = document.querySelectorAll(\".playground\")\n    playgrounds.forEach(pg => {\n\n      const currentID = pg.id.replace(reIdNum, \"$1\")\n      const inputs = document.getElementById(`__playground-inputs_${currentID}`)\n      const results = document.getElementById(`__playground-results_${currentID}`)\n      const pgcode = document.getElementById(`__playground-code_${currentID}`)\n      const buttonEdit = document.querySelector(`button#__playground-edit_${currentID}`)\n      const buttonShare = document.querySelector(`button#__playground-share_${currentID}`)\n      const buttonRun = document.querySelector(`button#__playground-run_${currentID}`)\n      const buttonCancel = document.querySelector(`button#__playground-cancel_${currentID}`)\n\n      inputs.addEventListener(\"input\", () => {\n        // Adjust textarea height on text input.\n\n        textResize(inputs)\n      })\n\n      inputs.addEventListener('keydown', handleTab)\n\n      if (notebook && first) {\n        const notebookInput = document.getElementById(\"__notebook-input\")\n\n        notebookInput.addEventListener(\"input\", e => {\n          // Adjust textarea height on text input.\n\n          textResize(e.target)\n        })\n\n        notebookInput.addEventListener('keydown', handleTab)\n\n        const editPage = document.getElementById(\"__notebook-edit\")\n        editPage.addEventListener(\"click\", () => {\n          editTemp[notebookInput.id] = notebookInput.value\n          document.getElementById(\"__notebook-render\").classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n          textResize(document.getElementById(\"__notebook-input\"))\n        })\n\n        document.getElementById(\"__notebook-md-gist\").addEventListener(\"click\", async e => {\n          let uri = prompt(\"Please enter link to the Markdown page source:\", gist) // eslint-disable-line no-alert\n          if (uri !== null) {\n            uri = encodeuri(uri)\n            e.preventDefault()\n            history.pushState({notebook: uri}, \"\", `?${new URLSearchParams(`notebook=${uri}`).toString()}`)\n            main(false) // eslint-disable-line no-use-before-define\n          }\n        })\n\n        document.getElementById(\"__notebook-py-gist\").addEventListener(\"click\", async e => {\n          let uri = prompt(\"Please enter the link to the Python code source:\", gist) // eslint-disable-line no-alert\n          if (uri !== null) {\n            uri = encodeuri(uri)\n            e.preventDefault()\n            history.pushState({source: uri}, \"\", `?${new URLSearchParams(`source=${uri}`).toString()}`)\n            main(false) // eslint-disable-line no-use-before-define\n          }\n        })\n\n        document.getElementById(\"__notebook-input\").value = raw\n        document.getElementById(\"__notebook-cancel\").addEventListener(\"click\", () => {\n          notebookInput.value = editTemp[notebookInput.id]\n          delete editTemp[notebookInput.id]\n          document.getElementById(\"__notebook-render\").classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n        })\n\n        document.getElementById(\"__notebook-submit\").addEventListener(\"click\", async() => {\n          const render = document.getElementById(\"__notebook-render\")\n          raw = document.getElementById(\"__notebook-input\").value\n          render.classList.toggle(\"hidden\")\n          document.getElementById(\"__notebook-source\").classList.toggle(\"hidden\")\n          const article = document.querySelector(\"article\")\n          showBusy(article, \"Loading Notebook...\")\n          render.innerHTML = \"\"\n          editTemp = {}\n          await setupPyodide(true)\n          await pyrender(raw)\n          await init()\n          hideBusy(article)\n          fakeDOMContentLoaded()\n        })\n      }\n\n      inputs.addEventListener(\"touchmove\", e => {\n        // Stop propagation on \"touchmove\".\n\n        e.stopPropagation()\n      })\n\n      results.addEventListener(\"click\", e => {\n        // Handle clicks on results and copies color from single color swatch when clicked.\n\n        const el = e.target\n        if (el.matches('span.swatch-color')) {\n          let content = ''\n          const parent = el.parentNode\n          if (!parent.matches('span.swatch-gradient')) {\n            content = parent.getAttribute('title').replace('Copy to clipboard', '')\n            content = content.replace('\\n', '')\n            if (window.clipboardData && window.clipboardData.setData) {\n              // Old `IE`` handling, do we really need this?\n              return window.clipboardData.setData(\"Text\", content)\n            } else if (document.queryCommandSupported && document.queryCommandSupported(\"copy\")) {\n              const textarea = document.createElement(\"textarea\")\n              textarea.textContent = content\n              textarea.style.position = \"fixed\"\n              document.body.appendChild(textarea)\n              textarea.select()\n              try {\n                return document.execCommand(\"copy\")\n              } catch (ex) {\n                return prompt(\"Copy to clipboard: Ctrl+C, Enter\", content) // eslint-disable-line no-alert\n              } finally {\n                document.body.removeChild(textarea)\n              }\n            }\n          }\n        }\n      })\n\n      buttonEdit.addEventListener(\"click\", async() => {\n        // Handle the button click: show source or execute source.\n\n        editTemp[currentID] = inputs.value\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        textResize(inputs)\n        inputs.focus()\n      })\n\n      buttonShare.addEventListener(\"click\", async() => {\n        // Handle the share click: copy URL with code as parameter.\n\n        const base = window.location.pathname.split('/')[1]\n        const uri = encodeuri(inputs.value)\n        const loc = window.location\n        let pathname = \"/playground/\"\n        if (loc.pathname.startsWith(`/${base}/`)) {\n          pathname = `/${base}/playground/`\n        }\n        const path = `${loc.protocol}//${loc.host}${pathname}?code=${uri}`\n        if (path.length > 2000) {\n          alert( // eslint-disable-line no-alert\n            \"Code must be small enough to generate a shareable URL under 2000 characters!\"\n          )\n        } else {\n          navigator.clipboard.writeText(path).then(async() => {\n            alert(\"Link copied to clipboard :)\") // eslint-disable-line no-alert\n          }, async() => {\n            alert(\"Failed to copy link clipboard!\") // eslint-disable-line no-alert\n          })\n        }\n      })\n\n      buttonRun.addEventListener(\"click\", async() => {\n        // Handle the button click: show source or execute source.\n\n        if (busy) {\n          return\n        }\n\n        busy = true\n        // Load Pyodide and related packages.\n        const form = pgcode.querySelector(\"form\")\n        showBusy(form, null, true)\n        const buttons = document.querySelectorAll(\".playground .playground-run\")\n        if (buttons) {\n          buttons.forEach(b => {\n            b.setAttribute(\"disabled\", \"\")\n          })\n        }\n        await setupPyodide(false)\n        results.querySelector(\"code\").innerHTML = \"\"\n        await pyexecute(currentID)\n        if (buttons) {\n          buttons.forEach(b => {\n            b.removeAttribute(\"disabled\")\n          })\n        }\n        hideBusy(form)\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n\n        delete editTemp[currentID]\n        busy = false\n      })\n\n      buttonCancel.addEventListener(\"click\", () => {\n        // Cancel edit.\n\n        inputs.value = editTemp[currentID]\n        delete editTemp[currentID]\n        pgcode.classList.toggle(\"hidden\")\n        results.classList.toggle(\"hidden\")\n        buttonEdit.classList.toggle(\"hidden\")\n        buttonShare.classList.toggle(\"hidden\")\n        buttonRun.classList.toggle(\"hidden\")\n        buttonCancel.classList.toggle(\"hidden\")\n      })\n    })\n  }\n\n  const main = async(first, search) => {\n    // Load external source to render in a playground.\n    // This can be something like a file on a gist we must read in (?source=)\n    // or raw code (?code=).\n\n    editTemp = {}\n\n    if (window.location.pathname.endsWith(\"/playground/\")) {\n      const params = search || new URLSearchParams(window.location.search)\n      const loadMsg = \"Loading Pyodide...\"\n      const pageMsg = \"Loading Notebook...\"\n      const uri = params.has(\"source\") ? params.get(\"source\") : params.get(\"notebook\")\n      const article = document.querySelector(\"article\")\n      if (uri !== null && uri.trim()) {\n        // A source was specified, so load it.\n        showBusy(article, loadMsg)\n        await setupPyodide(true)\n        hideBusy(article)\n        showBusy(article, pageMsg)\n        try {\n          const gistType = params.has(\"source\") ? \"source\" : \"notebook\"\n          lastSearch = decodeURIComponent(params.toString())\n          let value = \"\"\n          const xhr = new XMLHttpRequest()\n          gist = uri\n          xhr.open(\"GET\", uri, true)\n          xhr.onload = async() => {\n            // Try and load the requested content\n            if (xhr.readyState === 4) {\n              if (xhr.status === 200) {\n                value = xhr.responseText\n              }\n            }\n\n            if (gistType === \"source\") {\n              value = getContent(value)\n            }\n            await pyrender(value)\n            await init(first)\n            hideBusy(article)\n            fakeDOMContentLoaded()\n          }\n          xhr.send()\n        } catch (err) {} // eslint-disable-line no-empty\n      } else {\n        gist = \"\"\n        const content = getContent(params.has(\"code\") ? params.get(\"code\") : defContent)\n        lastSearch = decodeURIComponent(params.toString())\n        showBusy(article, loadMsg)\n        await setupPyodide(true)\n        hideBusy(article)\n        showBusy(article, pageMsg)\n        await pyrender(content)\n        await init(first)\n        hideBusy(article)\n        fakeDOMContentLoaded()\n      }\n    } else {\n      gist = \"\"\n      lastSearch = \"\"\n      init(first)\n    }\n  }\n\n  // Capture links in notebook pages so that we can make playgound links load instantly\n  document.addEventListener(\"click\", interceptClickEvent, true)\n\n  // Handle history of notebook pages as they are loaded dynamically\n  window.addEventListener(\"popstate\", popState)\n\n  // Before leaving, turn off fake, just in case we navigated away before finished\n  window.addEventListener(\"unload\", () => {\n    fake = false\n  })\n\n  // Attach main via subscribe (subscribes to Materials on page load and instant page loads)\n  window.document$.subscribe(() => {\n    // To get other libraries to reload, we may create a fake `DOMContentLoaded`\n    // No need to process these events.\n    if (fake) {\n      fake = false\n      return\n    }\n    main(true)\n  })\n})()\n"],"names":["webspace","gamut","window","matchMedia","matches","CSS","supports","_unused","pyodide","busy","raw","gist","editTemp","reIdNum","initialized","lastSearch","fake","tabStart","pycode","concat","defContent","colorNotebook","defaultPlayground","getContent","content","notebookInstalled","playgroundInstalled","fakeDOMContentLoaded","document","dispatchEvent","Event","bubbles","cancelable","document$","next","textResize","inpt","scrollLeft","pageXOffset","documentElement","body","parentNode","scrollTop","pageYOffset","style","height","scrollHeight","scrollTo","encodeuri","uri","encodeURIComponent","replace","c","charCodeAt","toString","pyexecute","_ref","_asyncToGenerator","_regeneratorRuntime","mark","_callee","currentID","currentInputs","wrap","_context","prev","getElementById","setAttribute","globals","set","runPythonAsync","removeAttribute","stop","_x","apply","this","arguments","pyrender","_ref2","_callee2","text","src","_context2","value","location","hash","href","_x2","setupPyodide","_ref3","_callee3","full","base","packages","installs","_iterator","_step","s","_context3","loadPyodide","indexURL","fullStdLib","sent","origin","pathname","split","notebookWheels","playgroundWheels","_createForOfIteratorHelper","n","done","endsWith","push","err","e","f","loadPackage","_x3","showBusy","target","label","relative","loaderLabel","classes","template","createElement","innerHTML","appendChild","firstChild","hideBusy","querySelector","removeChild","handleTab","key","selectionStart","selectionEnd","preventDefault","start","end","length","lines","substr","i","shiftKey","join","init","_ref4","_callee12","first","notebook","_context12","querySelectorAll","forEach","pg","id","inputs","results","pgcode","buttonEdit","buttonShare","buttonRun","buttonCancel","addEventListener","notebookInput","classList","toggle","_ref5","_callee4","_context4","prompt","history","pushState","URLSearchParams","main","_x5","_ref6","_callee5","_context5","source","_x6","_callee6","render","article","_context6","stopPropagation","el","parent","getAttribute","clipboardData","setData","queryCommandSupported","textarea","textContent","position","select","execCommand","ex","_callee7","_context7","focus","_callee10","loc","path","_context10","startsWith","protocol","host","alert","navigator","clipboard","writeText","then","_callee8","_context8","_callee9","_context9","_callee11","form","buttons","_context11","abrupt","b","_x4","_ref13","_callee14","search","params","loadMsg","pageMsg","gistType","xhr","_context14","has","get","trim","decodeURIComponent","XMLHttpRequest","open","onload","_callee13","_context13","readyState","status","responseText","send","_x7","_x8","srcElement","tagName","subscribe"],"mappings":"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"}
\ No newline at end of file
diff --git a/docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js b/docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js
deleted file mode 100644
index 6c9976202..000000000
--- a/docs/theme/assets/coloraide-extras/extra-notebook-37ed2f98.js
+++ /dev/null
@@ -1,2 +0,0 @@
-function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}var runtime=function(t){"use strict";var e,n=Object.prototype,o=n.hasOwnProperty,r="function"==typeof Symbol?Symbol:{},a=r.iterator||"@@iterator",i=r.asyncIterator||"@@asyncIterator",s=r.toStringTag||"@@toStringTag";function l(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{l({},"")}catch(t){l=function(t,e,n){return t[e]=n}}function c(t,e,n,o){var r=e&&e.prototype instanceof g?e:g,a=Object.create(r.prototype),i=new C(o||[]);return a._invoke=function(t,e,n){var o=d;return function(r,a){if(o===f)throw new Error("Generator is already running");if(o===m){if("throw"===r)throw a;return I()}for(n.method=r,n.arg=a;;){var i=n.delegate;if(i){var s=L(i,n);if(s){if(s===h)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(o===d)throw o=m,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o=f;var l=u(t,e,n);if("normal"===l.type){if(o=n.done?m:p,l.arg===h)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(o=m,n.method="throw",n.arg=l.arg)}}}(t,n,i),a}function u(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}t.wrap=c;var d="suspendedStart",p="suspendedYield",f="executing",m="completed",h={};function g(){}function _(){}function y(){}var v={};l(v,a,(function(){return this}));var w=Object.getPrototypeOf,x=w&&w(w(B([])));x&&x!==n&&o.call(x,a)&&(v=x);var b=y.prototype=g.prototype=Object.create(v);function E(t){["next","throw","return"].forEach((function(e){l(t,e,(function(t){return this._invoke(e,t)}))}))}function k(t,e){function n(r,a,i,s){var l=u(t[r],t,a);if("throw"!==l.type){var c=l.arg,d=c.value;return d&&"object"===_typeof(d)&&o.call(d,"__await")?e.resolve(d.__await).then((function(t){n("next",t,i,s)}),(function(t){n("throw",t,i,s)})):e.resolve(d).then((function(t){c.value=t,i(c)}),(function(t){return n("throw",t,i,s)}))}s(l.arg)}var r;this._invoke=function(t,o){function a(){return new e((function(e,r){n(t,o,e,r)}))}return r=r?r.then(a,a):a()}}function L(t,n){var o=t.iterator[n.method];if(o===e){if(n.delegate=null,"throw"===n.method){if(t.iterator.return&&(n.method="return",n.arg=e,L(t,n),"throw"===n.method))return h;n.method="throw",n.arg=new TypeError("The iterator does not provide a 'throw' method")}return h}var r=u(o,t.iterator,n.arg);if("throw"===r.type)return n.method="throw",n.arg=r.arg,n.delegate=null,h;var a=r.arg;return a?a.done?(n[t.resultName]=a.value,n.next=t.nextLoc,"return"!==n.method&&(n.method="next",n.arg=e),n.delegate=null,h):a:(n.method="throw",n.arg=new TypeError("iterator result is not an object"),n.delegate=null,h)}function S(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function A(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function C(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(S,this),this.reset(!0)}function B(t){if(t){var n=t[a];if(n)return n.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var r=-1,i=function n(){for(;++r<t.length;)if(o.call(t,r))return n.value=t[r],n.done=!1,n;return n.value=e,n.done=!0,n};return i.next=i}}return{next:I}}function I(){return{value:e,done:!0}}return _.prototype=y,l(b,"constructor",y),l(y,"constructor",_),_.displayName=l(y,s,"GeneratorFunction"),t.isGeneratorFunction=function(t){var e="function"==typeof t&&t.constructor;return!!e&&(e===_||"GeneratorFunction"===(e.displayName||e.name))},t.mark=function(t){return Object.setPrototypeOf?Object.setPrototypeOf(t,y):(t.__proto__=y,l(t,s,"GeneratorFunction")),t.prototype=Object.create(b),t},t.awrap=function(t){return{__await:t}},E(k.prototype),l(k.prototype,i,(function(){return this})),t.AsyncIterator=k,t.async=function(e,n,o,r,a){void 0===a&&(a=Promise);var i=new k(c(e,n,o,r),a);return t.isGeneratorFunction(n)?i:i.next().then((function(t){return t.done?t.value:i.next()}))},E(b),l(b,s,"Generator"),l(b,a,(function(){return this})),l(b,"toString",(function(){return"[object Generator]"})),t.keys=function(t){var e=[];for(var n in t)e.push(n);return e.reverse(),function n(){for(;e.length;){var o=e.pop();if(o in t)return n.value=o,n.done=!1,n}return n.done=!0,n}},t.values=B,C.prototype={constructor:C,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=e,this.done=!1,this.delegate=null,this.method="next",this.arg=e,this.tryEntries.forEach(A),!t)for(var n in this)"t"===n.charAt(0)&&o.call(this,n)&&!isNaN(+n.slice(1))&&(this[n]=e)},stop:function(){this.done=!0;var t=this.tryEntries[0].completion;if("throw"===t.type)throw t.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var n=this;function r(o,r){return s.type="throw",s.arg=t,n.next=o,r&&(n.method="next",n.arg=e),!!r}for(var a=this.tryEntries.length-1;a>=0;--a){var i=this.tryEntries[a],s=i.completion;if("root"===i.tryLoc)return r("end");if(i.tryLoc<=this.prev){var l=o.call(i,"catchLoc"),c=o.call(i,"finallyLoc");if(l&&c){if(this.prev<i.catchLoc)return r(i.catchLoc,!0);if(this.prev<i.finallyLoc)return r(i.finallyLoc)}else if(l){if(this.prev<i.catchLoc)return r(i.catchLoc,!0)}else{if(!c)throw new Error("try statement without catch or finally");if(this.prev<i.finallyLoc)return r(i.finallyLoc)}}}},abrupt:function(t,e){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&o.call(r,"finallyLoc")&&this.prev<r.finallyLoc){var a=r;break}}a&&("break"===t||"continue"===t)&&a.tryLoc<=e&&e<=a.finallyLoc&&(a=null);var i=a?a.completion:{};return i.type=t,i.arg=e,a?(this.method="next",this.next=a.finallyLoc,h):this.complete(i)},complete:function(t,e){if("throw"===t.type)throw t.arg;return"break"===t.type||"continue"===t.type?this.next=t.arg:"return"===t.type?(this.rval=this.arg=t.arg,this.method="return",this.next="end"):"normal"===t.type&&e&&(this.next=e),h},finish:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),A(n),h}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var o=n.completion;if("throw"===o.type){var r=o.arg;A(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,o){return this.delegate={iterator:B(t),resultName:n,nextLoc:o},"next"===this.method&&(this.arg=e),h}},t}("object"===("undefined"==typeof module?"undefined":_typeof(module))?module.exports:{});try{regeneratorRuntime=runtime}catch(t){"object"===("undefined"==typeof globalThis?"undefined":_typeof(globalThis))?globalThis.regeneratorRuntime=runtime:Function("r","regeneratorRuntime = r")(runtime)}!function(){"use strict";function t(){t=function(){return e};var e={},n=Object.prototype,o=n.hasOwnProperty,r=Object.defineProperty||function(t,e,n){t[e]=n.value},a="function"==typeof Symbol?Symbol:{},i=a.iterator||"@@iterator",s=a.asyncIterator||"@@asyncIterator",l=a.toStringTag||"@@toStringTag";function c(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{c({},"")}catch(t){c=function(t,e,n){return t[e]=n}}function u(t,e,n,o){var a=e&&e.prototype instanceof f?e:f,i=Object.create(a.prototype),s=new S(o||[]);return r(i,"_invoke",{value:b(t,n,s)}),i}function d(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}e.wrap=u;var p={};function f(){}function m(){}function h(){}var g={};c(g,i,(function(){return this}));var _=Object.getPrototypeOf,y=_&&_(_(A([])));y&&y!==n&&o.call(y,i)&&(g=y);var v=h.prototype=f.prototype=Object.create(g);function w(t){["next","throw","return"].forEach((function(e){c(t,e,(function(t){return this._invoke(e,t)}))}))}function x(t,e){function n(r,a,i,s){var l=d(t[r],t,a);if("throw"!==l.type){var c=l.arg,u=c.value;return u&&"object"==_typeof(u)&&o.call(u,"__await")?e.resolve(u.__await).then((function(t){n("next",t,i,s)}),(function(t){n("throw",t,i,s)})):e.resolve(u).then((function(t){c.value=t,i(c)}),(function(t){return n("throw",t,i,s)}))}s(l.arg)}var a;r(this,"_invoke",{value:function(t,o){function r(){return new e((function(e,r){n(t,o,e,r)}))}return a=a?a.then(r,r):r()}})}function b(t,e,n){var o="suspendedStart";return function(r,a){if("executing"===o)throw new Error("Generator is already running");if("completed"===o){if("throw"===r)throw a;return C()}for(n.method=r,n.arg=a;;){var i=n.delegate;if(i){var s=E(i,n);if(s){if(s===p)continue;return s}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if("suspendedStart"===o)throw o="completed",n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o="executing";var l=d(t,e,n);if("normal"===l.type){if(o=n.done?"completed":"suspendedYield",l.arg===p)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(o="completed",n.method="throw",n.arg=l.arg)}}}function E(t,e){var n=e.method,o=t.iterator[n];if(void 0===o)return e.delegate=null,"throw"===n&&t.iterator.return&&(e.method="return",e.arg=void 0,E(t,e),"throw"===e.method)||"return"!==n&&(e.method="throw",e.arg=new TypeError("The iterator does not provide a '"+n+"' method")),p;var r=d(o,t.iterator,e.arg);if("throw"===r.type)return e.method="throw",e.arg=r.arg,e.delegate=null,p;var a=r.arg;return a?a.done?(e[t.resultName]=a.value,e.next=t.nextLoc,"return"!==e.method&&(e.method="next",e.arg=void 0),e.delegate=null,p):a:(e.method="throw",e.arg=new TypeError("iterator result is not an object"),e.delegate=null,p)}function k(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function L(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function S(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(k,this),this.reset(!0)}function A(t){if(t){var e=t[i];if(e)return e.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var n=-1,r=function e(){for(;++n<t.length;)if(o.call(t,n))return e.value=t[n],e.done=!1,e;return e.value=void 0,e.done=!0,e};return r.next=r}}return{next:C}}function C(){return{value:void 0,done:!0}}return m.prototype=h,r(v,"constructor",{value:h,configurable:!0}),r(h,"constructor",{value:m,configurable:!0}),m.displayName=c(h,l,"GeneratorFunction"),e.isGeneratorFunction=function(t){var e="function"==typeof t&&t.constructor;return!!e&&(e===m||"GeneratorFunction"===(e.displayName||e.name))},e.mark=function(t){return Object.setPrototypeOf?Object.setPrototypeOf(t,h):(t.__proto__=h,c(t,l,"GeneratorFunction")),t.prototype=Object.create(v),t},e.awrap=function(t){return{__await:t}},w(x.prototype),c(x.prototype,s,(function(){return this})),e.AsyncIterator=x,e.async=function(t,n,o,r,a){void 0===a&&(a=Promise);var i=new x(u(t,n,o,r),a);return e.isGeneratorFunction(n)?i:i.next().then((function(t){return t.done?t.value:i.next()}))},w(v),c(v,l,"Generator"),c(v,i,(function(){return this})),c(v,"toString",(function(){return"[object Generator]"})),e.keys=function(t){var e=Object(t),n=[];for(var o in e)n.push(o);return n.reverse(),function t(){for(;n.length;){var o=n.pop();if(o in e)return t.value=o,t.done=!1,t}return t.done=!0,t}},e.values=A,S.prototype={constructor:S,reset:function(t){if(this.prev=0,this.next=0,this.sent=this._sent=void 0,this.done=!1,this.delegate=null,this.method="next",this.arg=void 0,this.tryEntries.forEach(L),!t)for(var e in this)"t"===e.charAt(0)&&o.call(this,e)&&!isNaN(+e.slice(1))&&(this[e]=void 0)},stop:function(){this.done=!0;var t=this.tryEntries[0].completion;if("throw"===t.type)throw t.arg;return this.rval},dispatchException:function(t){if(this.done)throw t;var e=this;function n(n,o){return i.type="throw",i.arg=t,e.next=n,o&&(e.method="next",e.arg=void 0),!!o}for(var r=this.tryEntries.length-1;r>=0;--r){var a=this.tryEntries[r],i=a.completion;if("root"===a.tryLoc)return n("end");if(a.tryLoc<=this.prev){var s=o.call(a,"catchLoc"),l=o.call(a,"finallyLoc");if(s&&l){if(this.prev<a.catchLoc)return n(a.catchLoc,!0);if(this.prev<a.finallyLoc)return n(a.finallyLoc)}else if(s){if(this.prev<a.catchLoc)return n(a.catchLoc,!0)}else{if(!l)throw new Error("try statement without catch or finally");if(this.prev<a.finallyLoc)return n(a.finallyLoc)}}}},abrupt:function(t,e){for(var n=this.tryEntries.length-1;n>=0;--n){var r=this.tryEntries[n];if(r.tryLoc<=this.prev&&o.call(r,"finallyLoc")&&this.prev<r.finallyLoc){var a=r;break}}a&&("break"===t||"continue"===t)&&a.tryLoc<=e&&e<=a.finallyLoc&&(a=null);var i=a?a.completion:{};return i.type=t,i.arg=e,a?(this.method="next",this.next=a.finallyLoc,p):this.complete(i)},complete:function(t,e){if("throw"===t.type)throw t.arg;return"break"===t.type||"continue"===t.type?this.next=t.arg:"return"===t.type?(this.rval=this.arg=t.arg,this.method="return",this.next="end"):"normal"===t.type&&e&&(this.next=e),p},finish:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),L(n),p}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var o=n.completion;if("throw"===o.type){var r=o.arg;L(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:A(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=void 0),p}},e}function e(t,e,n,o,r,a,i){try{var s=t[a](i),l=s.value}catch(t){return void n(t)}s.done?e(l):Promise.resolve(l).then(o,r)}function n(t){return function(){var n=this,o=arguments;return new Promise((function(r,a){var i=t.apply(n,o);function s(t){e(i,r,a,s,l,"next",t)}function l(t){e(i,r,a,s,l,"throw",t)}s(void 0)}))}}function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function r(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=function(t,e){if(t){if("string"==typeof t)return o(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){n&&(t=n);var r=0,a=function(){};return{s:a,n:function(){return r>=t.length?{done:!0}:{done:!1,value:t[r++]}},e:function(t){throw t},f:a}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return s=t.done,t},e:function(t){l=!0,i=t},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw i}}}}!function(){var e="";try{var o="srgb";window.matchMedia("(color-gamut: rec2020)").matches?o="rec2020":window.matchMedia("(color-gamut: p3)").matches&&(o="display-p3"),e=CSS.supports("color: color(display-p3 1 0 0)")?o:"srgb"}catch(t){e="srgb"}var a=null,i=!1,s="",l="",c={},u=/.*?_(\d+)$/,d=!1,p="",f=!1,m=/^( {1,4}|\t)/,h="\n_O='<div class=\"color-command\">{}</div>'\n_N='swatch'\n_M='transparent'\n_L='pycon'\n_K='playground'\n_J='gamut'\n_I='exceptions'\n_H='color'\n_G='highlight'\n_F='class'\n_E='eval'\n_D='<string>'\n_C=None\n_B=True\n_A=False\nimport xml.etree.ElementTree as Etree\nfrom collections.abc import Sequence,Mapping\nfrom collections import namedtuple\nimport ast\nfrom io import StringIO\nimport sys,re\nfrom functools import partial\nfrom pygments import highlight\nfrom pygments.lexers import get_lexer_by_name\nfrom pygments.formatters import find_formatter_class\nfrom coloraide import Color\nfrom coloraide.interpolate import Interpolator,normalize_domain\ntry:from coloraide_extras.everything import ColorAll\nexcept ImportError:from coloraide.everything import ColorAll\nPY310=(3,10)<=sys.version_info\nPY311=(3,11)<=sys.version_info\nWEBSPACE='srgb'\nAST_BLOCKS=ast.If,ast.For,ast.While,ast.Try,ast.With,ast.FunctionDef,ast.ClassDef,ast.AsyncFor,ast.AsyncWith,ast.AsyncFunctionDef\nif PY310:AST_BLOCKS=AST_BLOCKS+(ast.Match,)\nif PY311:AST_BLOCKS=AST_BLOCKS+(ast.TryStar,)\nRE_INIT=re.compile('^\\\\s*#\\\\s*pragma:\\\\s*init\\\\n(.*?)#\\\\s*pragma:\\\\s*init\\\\n',re.DOTALL|re.I)\nRE_COLOR_START=re.compile('(?i)(?:\\\\b(?<![-#&$])(?:color|hsla?|lch|lab|hwb|rgba?)\\\\(|\\\\b(?<![-#&$])[\\\\w]{3,}(?![(-])\\\\b|(?<![&])#)')\nLIVE_INIT='\\nfrom coloraide import *\\nimport coloraide\\ntry:\\n    import coloraide_extras\\n    from coloraide_extras.everything import ColorAll as Color\\nexcept ImportError:\\n    from coloraide.everything import ColorAll as Color\\n'\ntemplate='<div class=\"playground\" id=\"__playground_{el_id}\">\\n<div class=\"playground-results\" id=\"__playground-results_{el_id}\">\\n{results}\\n</div>\\n<div class=\"playground-code hidden\" id=\"__playground-code_{el_id}\" data-search-exclude>\\n<form autocomplete=\"off\">\\n<textarea class=\"playground-inputs\" id=\"__playground-inputs_{el_id}\" spellcheck=\"false\">{raw_source}</textarea>\\n</form>\\n</div>\\n<div class=\"playground-footer\" data-search-exclude>\\n<hr>\\n<button id=\"__playground-edit_{el_id}\" class=\"playground-edit\" title=\"Edit the current snippet\">Edit</button>\\n<button id=\"__playground-share_{el_id}\" class=\"playground-share\" title=\"Copy URL to current snippet\">Share</button>\\n<button id=\"__playground-run_{el_id}\" class=\"playground-run hidden\" title=\"Run code (Ctrl + Enter)\">Run</button>\\n<button id=\"__playground-cancel_{el_id}\" class=\"playground-cancel hidden\" title=\"Cancel edit (Escape)\">Cancel</button>\\n<span class=\\'gamut\\'>Gamut: {gamut}</span>\\n</div>\\n</div>'\ncode_id=0\nclass HtmlGradient(list):0\nclass HtmlSteps(list):0\nclass ColorTuple(namedtuple('ColorTuple',['string',_H])):0\nclass HtmlRow(list):0\nclass AtomicString(str):0\nclass Break(Exception):0\nclass Continue(Exception):0\ndef _escape(txt):txt=txt.replace('&','&amp;');txt=txt.replace('<','&lt;');txt=txt.replace('>','&gt;');return txt\nclass StreamOut:\n\tdef __init__(self):self.old=sys.stdout;self.stdout=StringIO();sys.stdout=self.stdout\n\tdef read(self):\n\t\tvalue=''\n\t\tif self.stdout is not _C:self.stdout.flush();value=self.stdout.getvalue();self.stdout=StringIO();sys.stdout=self.stdout\n\t\treturn value\n\tdef __enter__(self):return self\n\tdef __exit__(self,type,value,traceback):sys.stdout=self.old;self.old=_C;self.stdout=_C\ndef get_colors(result):\n\tdomain=[]\n\tif isinstance(result,AtomicString):yield find_colors(result)\n\tif isinstance(result,HtmlRow):yield HtmlRow([ColorTuple(c.to_string(fit=_A),c.clone())if isinstance(c,Color)else ColorTuple(c,ColorAll(c))for c in result])\n\telif isinstance(result,(HtmlSteps,HtmlGradient)):t=type(result);yield t([c.clone()if isinstance(c,Color)else ColorAll(c)for c in result])\n\telif isinstance(result,Color):yield[ColorTuple(result.to_string(fit=_A),result.clone())]\n\telif isinstance(result,Interpolator):\n\t\tif result.domain:domain=result.domain;result.domain=normalize_domain(result.domain)\n\t\tgrad=HtmlGradient(result.steps(steps=5,max_delta_e=2.3))\n\t\tif domain:result.domain=domain;domain=[]\n\t\tyield grad\n\telif isinstance(result,str):\n\t\ttry:yield[ColorTuple(result,ColorAll(result))]\n\t\texcept Exception:pass\n\telif isinstance(result,(list,tuple)):\n\t\tfor r in result:\n\t\t\tfor x in get_colors(r):\n\t\t\t\tif x:yield x\ndef find_colors(text):\n\tcolors=[]\n\tfor m in RE_COLOR_START.finditer(text):\n\t\tstart=m.start();mcolor=ColorAll.match(text,start=start)\n\t\tif mcolor is not _C:colors.append(ColorTuple(text[mcolor.start:mcolor.end],mcolor.color))\n\treturn colors\ndef evaluate_with(node,g,loop,index=0):\n\tl=len(node.items)-1;withitem=node.items[index]\n\tif withitem.context_expr:\n\t\twith eval(compile(ast.Expression(withitem.context_expr),_D,_E),g)as w:\n\t\t\tg[withitem.optional_vars.id]=w\n\t\t\tif index<l:evaluate_with(node,g,loop,index+1)\n\t\t\telse:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\telse:\n\t\twith eval(compile(ast.Expression(withitem.context_expr),_D,_E),g):\n\t\t\tif index<l:evaluate_with(node,g,loop,index+1)\n\t\t\telse:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\ndef compare_match(s,g,node):\n\tif isinstance(node,ast.MatchOr):\n\t\tfor pattern in node.patterns:\n\t\t\tif compare_match(s,g,pattern):return _B\n\telif isinstance(node,ast.MatchValue):p=eval(compile(ast.Expression(node.value),_D,_E),g);return s==p\n\telif isinstance(node,ast.MatchSingleton):return s is node.value\n\telif isinstance(node,ast.MatchSequence):\n\t\tif isinstance(s,Sequence):\n\t\t\tstar=isinstance(node.patterns[-1],ast.MatchStar);l1,l2=len(s),len(node.patterns)\n\t\t\tif star and l1>=l2-1 or l1==l2:\n\t\t\t\tfor (e,p) in enumerate(node.patterns[:-1]if star else node.patterns):\n\t\t\t\t\tif not compare_match(s[e],g,p):return _A\n\t\t\t\tif star and node.patterns[-1].name:g[node.patterns[-1].name]=s[l2-1:]\n\t\t\t\treturn _B\n\t\treturn _A\n\telif isinstance(node,ast.MatchMapping):\n\t\tif isinstance(s,Mapping):\n\t\t\tstar=node.rest;l1,l2=len(s),len(node.patterns)\n\t\t\tif star and l1>=l2 or l1==l2:\n\t\t\t\tkeys=set()\n\t\t\t\tfor (kp,vp) in zip(node.keys,node.patterns):\n\t\t\t\t\tkey=eval(compile(ast.Expression(kp),_D,_E),g);keys.add(key)\n\t\t\t\t\tif key not in s:return _A\n\t\t\t\t\tif not compare_match(s[key],g,vp):return _A\n\t\t\t\tif star:g[star]={k:v for(k,v)in s.items()if k not in keys}\n\t\t\t\treturn _B\n\t\treturn _A\n\telif isinstance(node,ast.MatchClass):\n\t\tname=g.get(node.cls.id,_C)\n\t\tif name is _C:raise NameError(\"name '{}' is not defined\".format(node.cls.id))\n\t\tif not isinstance(s,name):return _A\n\t\tma=getattr(s,'__match_args__',tuple());l1=len(ma);l2=len(node.patterns)\n\t\tif l1<l2:raise TypeError('{}() accepts {} positional sub-patterns ({} given)'.format(name,l1,l2))\n\t\tfor (e,p) in enumerate(node.patterns):\n\t\t\tif not hasattr(s,ma[e]):return _A\n\t\t\tif not compare_match(getattr(s,ma[e]),g,p):return _A\n\t\tfor (a,p) in zip(node.kwd_attrs,node.kwd_patterns):\n\t\t\tif not hasattr(s,a):return _A\n\t\t\tif not compare_match(getattr(s,a),g,p):return _A\n\t\treturn _B\n\telif isinstance(node,ast.MatchAs):\n\t\tif node.name is not _C:g[node.name]=s\n\t\tif node.pattern:return compare_match(s,g,node.pattern)\n\t\treturn _B\n\traise RuntimeError('Unknown Match pattern {}'.format(str(node)))\ndef evaluate_except(node,e,g,loop=_A):\n\tfor n in node.handlers:\n\t\tif n.name:g[n.name]=e\n\t\tif n.type is _C:\n\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\tbreak\n\t\telif isinstance(e,eval(compile(ast.Expression(n.type),_D,_E),g)):\n\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\tbreak\n\telse:raise\ndef evaluate(node,g,loop=_A):\n\tif loop and isinstance(node,ast.Break):raise Break\n\tif loop and isinstance(node,ast.Continue):raise Continue\n\tif isinstance(node,ast.Expr):_eval=ast.Expression(node.value);yield eval(compile(_eval,_D,_E),g)\n\telif isinstance(node,ast.If):\n\t\tif eval(compile(ast.Expression(node.test),_D,_E),g):\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\telif node.orelse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.While):\n\t\twhile eval(compile(ast.Expression(node.test),_D,_E),g):\n\t\t\ttry:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,_B)\n\t\t\texcept Break:break\n\t\t\texcept Continue:continue\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.For):\n\t\tfor x in eval(compile(ast.Expression(node.iter),_D,_E),g):\n\t\t\tg[node.target.id]=x\n\t\t\ttry:\n\t\t\t\tfor n in node.body:yield from evaluate(n,g,_B)\n\t\t\texcept Break:break\n\t\t\texcept Continue:continue\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\telif isinstance(node,ast.Try):\n\t\ttry:\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\texcept Exception as e:yield from evaluate_except(node,e,g,loop)\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\t\tfinally:\n\t\t\tfor n in node.finalbody:yield from evaluate(n,g,loop)\n\telif PY311 and isinstance(node,ast.TryStar):\n\t\ttry:\n\t\t\tfor n in node.body:yield from evaluate(n,g,loop)\n\t\texcept ExceptionGroup as e:\n\t\t\tfor n in node.handlers:\n\t\t\t\tif n.name:g[n.name]=e\n\t\t\t\tm,e=e.split(eval(compile(ast.Expression(n.type),_D,_E),g))\n\t\t\t\tif m is not _C:\n\t\t\t\t\tfor ne in n.body:yield from evaluate(ne,g,loop)\n\t\t\t\tif e is _C:break\n\t\t\tif e is not _C:raise e\n\t\texcept Exception as e:yield from evaluate_except(node,e,g,loop)\n\t\telse:\n\t\t\tfor n in node.orelse:yield from evaluate(n,g,loop)\n\t\tfinally:\n\t\t\tfor n in node.finalbody:yield from evaluate(n,g,loop)\n\telif PY310 and isinstance(node,ast.Match):\n\t\ts=eval(compile(ast.Expression(node.subject),_D,_E),g)\n\t\tfor c in node.cases:\n\t\t\tif compare_match(s,g,c.pattern):\n\t\t\t\tif not c.guard or eval(compile(ast.Expression(c.guard),_D,_E),g):\n\t\t\t\t\tfor n in c.body:yield from evaluate(n,g,loop)\n\t\t\t\t\tbreak\n\telif isinstance(node,ast.With):yield from evaluate_with(node,g,loop)\n\telse:_exec=ast.Module([node],[]);exec(compile(_exec,_D,'exec'),g);yield _C\ndef execute(cmd,no_except=_B,inline=_A,init='',g=_C):\n\tA='\\n';console='';colors=[]\n\tif g is _C:g={'HtmlRow':HtmlRow,'HtmlSteps':HtmlSteps,'HtmlGradient':HtmlGradient}\n\tif init:execute(init.strip(),g=g)\n\tm=RE_INIT.match(cmd)\n\tif m:block_init=m.group(1);src=cmd[m.end():];execute(block_init,g=g)\n\telse:src=cmd\n\tlines=src.split(A)\n\ttry:tree=ast.parse(src)\n\texcept Exception as e:\n\t\tif no_except:\n\t\t\tif not inline:from pymdownx.superfences import SuperFencesException;raise SuperFencesException from e\n\t\t\telse:from pymdownx.inlinehilite import InlineHiliteException;raise InlineHiliteException from e\n\t\timport traceback;return '{}'.format(traceback.format_exc()),colors\n\tfor node in tree.body:\n\t\tresult=[];start=node.lineno;end=node.end_lineno;stmt=lines[start-1:end];command=''\n\t\tfor (i,line) in enumerate(stmt,0):\n\t\t\tif i==0:stmt[i]='>>> '+line\n\t\t\telse:stmt[i]='... '+line\n\t\tcommand+=A.join(stmt)\n\t\tif isinstance(node,AST_BLOCKS):command+='\\n... '\n\t\ttry:\n\t\t\twith StreamOut()as s:\n\t\t\t\tfor x in evaluate(node,g):\n\t\t\t\t\tresult.append(x);text=s.read()\n\t\t\t\t\tif text:result.append(AtomicString(text))\n\t\t\t\tconsole+=command\n\t\texcept Exception as e:\n\t\t\tif no_except:\n\t\t\t\tif not inline:from pymdownx.superfences import SuperFencesException;raise SuperFencesException from e\n\t\t\t\telse:from pymdownx.inlinehilite import InlineHiliteException;raise InlineHiliteException from e\n\t\t\timport traceback;console+='{}\\n{}'.format(command,traceback.format_exc());break\n\t\tresult_text=A\n\t\tfor r in result:\n\t\t\tif r is _C:continue\n\t\t\tfor clist in get_colors(r):\n\t\t\t\tif clist:colors.append(clist)\n\t\t\tresult_text+='{}{}'.format(repr(r)if isinstance(r,str)and not isinstance(r,AtomicString)else str(r),A if not isinstance(r,AtomicString)else'')\n\t\tconsole+=result_text\n\treturn console,colors\ndef colorize(src,lang,**options):HtmlFormatter=find_formatter_class('html');lexer=get_lexer_by_name(lang,**options);formatter=HtmlFormatter(cssclass=_G,wrapcode=_B);return highlight(src,lexer,formatter).strip()\ndef color_command_validator(language,inputs,options,attrs,md):\n\tvalid_inputs=set([_I,'play'])\n\tfor (k,v) in inputs.items():\n\t\tif k in valid_inputs:options[k]=_B;continue\n\t\tattrs[k]=v\n\treturn _B\ndef _color_command_console(colors,gamut=WEBSPACE):\n\tC='{} {}%';B=' ';A='<div class=\"swatch-bar\">{}</div>';el='';bar=_A;values=[]\n\tfor item in colors:\n\t\tif isinstance(item,(HtmlGradient,HtmlSteps)):\n\t\t\tcurrent=total=percent=last=0\n\t\t\tif isinstance(item,HtmlSteps):total=len(item);percent=100/total;current=percent\n\t\t\tif bar:el+=A.format(B.join(values));values=[]\n\t\t\tsub_el1='<div class=\"swatch-bar\"><span class=\"swatch swatch-gradient\">{}</span></div>';style='--swatch-stops: ';stops=[]\n\t\t\tfor (e,color) in enumerate(item):\n\t\t\t\tcolor.fit(gamut)\n\t\t\t\tif current:\n\t\t\t\t\tstops.append(C.format(color.convert(gamut).to_string(),str(last)));stops.append(C.format(color.convert(gamut).to_string(),str(current)));last=current\n\t\t\t\t\tif e<total-1:current+=percent\n\t\t\t\t\telse:current=100\n\t\t\t\telse:stops.append(color.convert(gamut).to_string())\n\t\t\tif not stops:stops.extend([_M]*2)\n\t\t\tif len(stops)==1:stops.append(stops[0])\n\t\t\tstyle+=','.join(stops);sub_el2='<span class=\"swatch-color\" style=\"{}\"></span>'.format(style);el+=sub_el1.format(sub_el2);bar=_A\n\t\telse:\n\t\t\tis_row=_A\n\t\t\tif isinstance(item,HtmlRow):\n\t\t\t\tis_row=_B\n\t\t\t\tif bar and values:el+=A.format(B.join(values));values=[]\n\t\t\t\tbar=_A\n\t\t\tbar=_B\n\t\t\tfor color in item:\n\t\t\t\tbase_classes=_N\n\t\t\t\tif not color.color.in_gamut(gamut):base_classes+=' out-of-gamut'\n\t\t\t\tcolor.color.fit(gamut);srgb=color.color.convert(gamut);value1=srgb.to_string(alpha=_A);value2=srgb.to_string();style='--swatch-stops: {} 50%, {} 50%'.format(value1,value2);title=color.string;classes=base_classes;c='<span class=\"swatch-color\" style=\"{style}\"></span>'.format(style=style);c='<span class=\"{classes}\" title=\"{title}&#013;Copy to clipboard\">{color}</span>'.format(classes=classes,color=c,title=title);values.append(c)\n\t\t\tif is_row and values:el+=A.format(B.join(values));values=[];bar=_A\n\tif bar:el+=A.format(B.join(values));values=[]\n\treturn el\ndef _color_command_formatter(src='',language='',class_name=_C,options=_C,md='',init='',**kwargs):\n\tB='formatter';A='fenced_code_block';global code_id;from pymdownx.superfences import SuperFencesException;gamut=kwargs.get(_J,WEBSPACE);play=options.get('play',_A)if options is not _C else _A\n\tif not play and language==_K:play=_B\n\tif not play:return md.preprocessors[A].extension.superfences[0][B](src=src,class_name=class_name,language='py',md=md,options=options,**kwargs)\n\ttry:\n\t\tif len(md.preprocessors[A].extension.stash)==0:code_id=0\n\t\texceptions=options.get(_I,_A)if options is not _C else _A;console,colors=execute(src.strip(),not exceptions,init=init);el=_color_command_console(colors,gamut=gamut);el+=md.preprocessors[A].extension.superfences[0][B](src=console,class_name=_G,language=_L,md=md,options=options,**kwargs);el=_O.format(el);el=template.format(el_id=code_id,raw_source=_escape(src),results=el,gamut=gamut);code_id+=1\n\texcept SuperFencesException:raise\n\texcept Exception:from pymdownx import superfences;import traceback;print(traceback.format_exc());return superfences.fence_code_format(src,'text',class_name,options,md,**kwargs)\n\treturn el\ndef color_command_formatter(init='',gamut=WEBSPACE):return partial(_color_command_formatter,init=init,gamut=gamut)\ndef _color_formatter(src='',language='',class_name=_C,md='',exceptions=_B,init='',gamut=WEBSPACE):\n\tE='backtick';D='title';C='Only one color allowed';B=' 50%';A='span';from pymdownx.inlinehilite import InlineHiliteException\n\ttry:\n\t\tresult=src.strip()\n\t\ttry:color=ColorAll(result.strip())\n\t\texcept Exception:\n\t\t\t_,colors=execute(result,exceptions,inline=_B,init=init)\n\t\t\tif len(colors)!=1 or len(colors[0])!=1:\n\t\t\t\tif exceptions:raise InlineHiliteException(C)\n\t\t\t\telse:raise ValueError(C)\n\t\t\tcolor=colors[0][0].color;result=colors[0][0].string\n\t\tel=Etree.Element(A);stops=[]\n\t\tif not color.in_gamut(gamut):\n\t\t\tcolor.fit(gamut);attributes={_F:'swatch out-of-gamut',D:result};sub_el=Etree.SubElement(el,A,attributes);stops.append(color.convert(gamut).to_string(hex=_B,alpha=_A))\n\t\t\tif color[-1]<1.0:stops[-1]+=B;stops.append(color.convert(gamut).to_string(hex=_B)+B)\n\t\telse:\n\t\t\tattributes={_F:_N,D:result};sub_el=Etree.SubElement(el,A,attributes);stops.append(color.convert(gamut).to_string(hex=_B,alpha=_A))\n\t\t\tif color[-1]<1.0:stops[-1]+=B;stops.append(color.convert(gamut).to_string(hex=_B)+B)\n\t\tif not stops:stops.extend([_M]*2)\n\t\tif len(stops)==1:stops.append(stops[0])\n\t\tEtree.SubElement(sub_el,A,{_F:'swatch-color','style':'--swatch-stops: {};'.format(','.join(stops))});el.append(md.inlinePatterns[E].handle_code('css-color',result))\n\texcept InlineHiliteException:raise\n\texcept Exception:import traceback;print(traceback.format_exc());el=md.inlinePatterns[E].handle_code('text',src)\n\treturn el\ndef color_formatter(init='',gamut=WEBSPACE):return partial(_color_formatter,init=init,gamut=gamut)\ndef _live_color_command_formatter(src,init='',gamut=WEBSPACE):\n\ttry:\n\t\tconsole,colors=execute(src.strip(),_A,init=init);el=_color_command_console(colors,gamut=gamut)\n\t\tif not colors:el+='<div class=\"swatch-bar\"></div>'\n\t\tel+=colorize(console,_L,**{'python3':_B,'stripnl':_A});el=_O.format(el)\n\texcept Exception:import traceback;return '<div class=\"color-command\"><div class=\"swatch-bar\"></div>{}</div>'.format(colorize(traceback.format_exc(),_L))\n\treturn el\ndef live_color_command_formatter(init='',gamut=WEBSPACE):return partial(_live_color_command_formatter,init=init,gamut=gamut)\ndef live_color_command_validator(language,inputs,options,attrs,md):value=color_command_validator(language,inputs,options,attrs,md);options[_I]=_B;return value\ndef render_console(*args,**kwargs):\n\tC='.swatch-bar';B='code';A='id_num';from js import document;gamut=kwargs.get(_J,WEBSPACE)\n\ttry:\n\t\tinputs=document.getElementById('__playground-inputs_{}'.format(globals()[A]));results=document.getElementById('__playground-results_{}'.format(globals()[A]));footer=document.querySelector('#__playground_{} .gamut'.format(globals()[A]));result=live_color_command_formatter(LIVE_INIT,gamut)(inputs.value);temp=document.createElement('div');temp.innerHTML=result;cmd=results.querySelector('.color-command')\n\t\tfor el in cmd.querySelectorAll(C):el.remove()\n\t\tfor el in temp.querySelectorAll(C):cmd.insertBefore(el,cmd.lastChild)\n\t\tfooter.innerHTML='Gamut: {}'.format(gamut);pre=cmd.querySelector('pre');pre.replaceChild(temp.querySelector(B),pre.querySelector(B));temp.remove();scrollingElement=results.querySelector(B);scrollingElement.scrollTop=scrollingElement.scrollHeight\n\texcept Exception as e:print(e)\ndef render_notebook(*args,**kwargs):\n\tM='diagram';L='pymdownx.blocks.tab';K='pymdownx.blocks.admonition';J='pymdownx.arithmatex';I='pymdownx.keys';H='pymdownx.magiclink';G='pymdownx.inlinehilite';F='pymdownx.superfences';E='markdown.extensions.smarty';D='markdown.extensions.toc';C='validator';B='format';A='name';import markdown;from pymdownx import slugs,superfences;from js import document;gamut=kwargs.get(_J,WEBSPACE);text=globals().get('content','');extensions=[D,E,'pymdownx.betterem','markdown.extensions.attr_list','markdown.extensions.tables','markdown.extensions.abbr','markdown.extensions.footnotes',F,'pymdownx.highlight',G,H,'pymdownx.tilde','pymdownx.caret','pymdownx.smartsymbols','pymdownx.emoji','pymdownx.escapeall','pymdownx.tasklist','pymdownx.striphtml','pymdownx.snippets',I,'pymdownx.saneheaders',J,K,'pymdownx.blocks.details','pymdownx.blocks.html','pymdownx.blocks.definition',L];extension_configs={D:{'slugify':slugs.slugify(case='lower'),'permalink':''},E:{'smart_quotes':_A},J:{'generic':_B,'block_tag':'pre'},F:{'preserve_tabs':_B,'custom_fences':[{A:M,_F:M,B:superfences.fence_code_format},{A:_K,_F:_K,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator},{A:'python',_F:_G,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator},{A:'py',_F:_G,B:color_command_formatter(LIVE_INIT,gamut),C:live_color_command_validator}]},G:{'custom_inline':[{A:_H,_F:_H,B:color_formatter(LIVE_INIT,gamut)}]},H:{'repo_url_shortener':_B,'repo_url_shorthand':_B,'social_url_shorthand':_B,'user':'facelessuser','repo':'coloraide'},I:{'separator':'＋'},L:{'alternate_style':_B},K:{'types':['new','settings','note','abstract','info','tip','success','question','warning','failure','danger','bug','example','quote']}}\n\ttry:html=markdown.markdown(text,extensions=extensions,extension_configs=extension_configs)\n\texcept Exception:html=''\n\tcontent=document.getElementById('__notebook-render');content.innerHTML=html\n\naction = globals().get('action')\nif action == 'notebook':\n    callback = render_notebook\nelse:\n    callback = render_console\n\ncallback(gamut='".concat(e,"')\n"),g=window.colorNotebook.defaultPlayground,_=function(t){return"\n/// new | This notebook is powered by [Pyodide](https://github.com/pyodide/pyodide). Learn more [here](?notebook=https://gist.githubusercontent.com/facelessuser/7c819668b5eb248ecb9ac608d91391cf/raw/playground.md). Preview, convert, interpolate, and explore!\n///\n\n````````py play\n".concat(t,"\n````````\n")},y=!1,v=!1,w=function(){f=!0,window.document.dispatchEvent(new Event("DOMContentLoaded",{bubbles:!0,cancelable:!0})),window.document$.next()},x=function(t){var e=window.pageXOffset||(document.documentElement||document.body.parentNode||document.body).scrollLeft,n=window.pageYOffset||(document.documentElement||document.body.parentNode||document.body).scrollTop;t.style.height="5px",t.style.height="".concat(t.scrollHeight,"px"),window.scrollTo(e,n)},b=function(t){return encodeURIComponent(t).replace(/[.!'()*]/g,(function(t){return"%".concat(t.charCodeAt(0).toString(16))}))},E=function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return(o=document.getElementById("__playground-inputs_".concat(n))).setAttribute("readonly",""),a.globals.set("id_num",n),a.globals.set("action","playground"),t.next=6,a.runPythonAsync(h);case 6:o.removeAttribute("readonly");case 7:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),k=function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return a.globals.set("content",n),a.globals.set("action","notebook"),t.next=4,a.runPythonAsync(h);case 4:(o=document.getElementById("__notebook-input"))&&(s=n,o.value=n),window.location.hash&&(window.location.href=window.location.href);case 7:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),L=function(){var e=n(t().mark((function e(n){var o,i,s,l,c,u;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(d){t.next=5;break}return d=!0,t.next=4,loadPyodide({indexURL:"https://cdn.jsdelivr.net/pyodide/v0.22.1/full/",fullStdLib:!1});case 4:a=t.sent;case 5:if((y||!n)&&(v||n)){t.next=14;break}o="".concat(window.location.origin,"/").concat(window.location.pathname.split("/")[1],"/playground/"),i=n?window.colorNotebook.notebookWheels:window.colorNotebook.playgroundWheels,s=[],n?y=!0:v=!0,l=r(i);try{for(l.s();!(c=l.n()).done;)(u=c.value).endsWith(".whl")?s.push(o+u):s.push(u)}catch(t){l.e(t)}finally{l.f()}return t.next=14,a.loadPackage(s);case 14:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),S=function(t,e,n){var o=null==e?"Loading...":e,r=n?"loading relative":"loading",a=document.createElement("template");a.innerHTML='<div class="'.concat(r,'"><div class="loader"></div><div>').concat(o,"</div></div>"),t.appendChild(a.content.firstChild)},A=function(t){t.querySelector(".loading")&&t.removeChild(t.querySelector(".loading"))},C=function(t){if("Tab"===t.key){var e=t.target;if(e.selectionStart!==e.selectionEnd){t.preventDefault();for(var n=e.selectionStart,o=e.selectionEnd,r=e.value;n>0&&"\n"!==r[n-1];)n--;for(;o>0&&"\n"!==r[o-1]&&o<r.length;)o++;for(var a=r.substr(n,o-n).split("\n"),i=0;i<a.length;i++)i===a.length-1&&0===a[i].length||(t.shiftKey?a[i]=a[i].replace(m,""):a[i]="    ".concat(a[i]));a=a.join("\n"),e.value=r.substr(0,n)+a+r.substr(o),e.selectionStart=n,e.selectionEnd=n+a.length}}},B=function(){var e=n(t().mark((function e(o){var r;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:r=document.getElementById("__notebook-source"),document.querySelectorAll(".playground").forEach((function(e){var a=e.id.replace(u,"$1"),d=document.getElementById("__playground-inputs_".concat(a)),p=document.getElementById("__playground-results_".concat(a)),f=document.getElementById("__playground-code_".concat(a)),m=document.querySelector("button#__playground-edit_".concat(a)),h=document.querySelector("button#__playground-share_".concat(a)),g=document.querySelector("button#__playground-run_".concat(a)),_=document.querySelector("button#__playground-cancel_".concat(a));if(d.addEventListener("input",(function(){x(d)})),d.addEventListener("keydown",C),r&&o){var y=document.getElementById("__notebook-input");y.addEventListener("input",(function(t){x(t.target)})),y.addEventListener("keydown",C),document.getElementById("__notebook-edit").addEventListener("click",(function(){c[y.id]=y.value,document.getElementById("__notebook-render").classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden"),x(document.getElementById("__notebook-input"))})),document.getElementById("__notebook-md-gist").addEventListener("click",function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:null!==(o=prompt("Please enter link to the Markdown page source:",l))&&(o=b(o),n.preventDefault(),history.pushState({notebook:o},"","?".concat(new URLSearchParams("notebook=".concat(o)).toString())),I(!1));case 2:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()),document.getElementById("__notebook-py-gist").addEventListener("click",function(){var e=n(t().mark((function e(n){var o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:null!==(o=prompt("Please enter the link to the Python code source:",l))&&(o=b(o),n.preventDefault(),history.pushState({source:o},"","?".concat(new URLSearchParams("source=".concat(o)).toString())),I(!1));case 2:case"end":return t.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}()),document.getElementById("__notebook-input").value=s,document.getElementById("__notebook-cancel").addEventListener("click",(function(){y.value=c[y.id],delete c[y.id],document.getElementById("__notebook-render").classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden")})),document.getElementById("__notebook-submit").addEventListener("click",n(t().mark((function e(){var n,o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return n=document.getElementById("__notebook-render"),s=document.getElementById("__notebook-input").value,n.classList.toggle("hidden"),document.getElementById("__notebook-source").classList.toggle("hidden"),o=document.querySelector("article"),S(o,"Loading Notebook..."),n.innerHTML="",c={},t.next=10,L(!0);case 10:return t.next=12,k(s);case 12:return t.next=14,B();case 14:A(o),w();case 16:case"end":return t.stop()}}),e)}))))}d.addEventListener("touchmove",(function(t){t.stopPropagation()})),p.addEventListener("click",(function(t){var e=t.target;if(e.matches("span.swatch-color")){var n="",o=e.parentNode;if(!o.matches("span.swatch-gradient")){if(n=(n=o.getAttribute("title").replace("Copy to clipboard","")).replace("\n",""),window.clipboardData&&window.clipboardData.setData)return window.clipboardData.setData("Text",n);if(document.queryCommandSupported&&document.queryCommandSupported("copy")){var r=document.createElement("textarea");r.textContent=n,r.style.position="fixed",document.body.appendChild(r),r.select();try{return document.execCommand("copy")}catch(t){return prompt("Copy to clipboard: Ctrl+C, Enter",n)}finally{document.body.removeChild(r)}}}}})),m.addEventListener("click",n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:c[a]=d.value,f.classList.toggle("hidden"),p.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),x(d),d.focus();case 9:case"end":return t.stop()}}),e)})))),h.addEventListener("click",n(t().mark((function e(){var o,r,a,i,s;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:o=window.location.pathname.split("/")[1],r=b(d.value),a=window.location,i="/playground/",a.pathname.startsWith("/".concat(o,"/"))&&(i="/".concat(o,"/playground/")),(s="".concat(a.protocol,"//").concat(a.host).concat(i,"?code=").concat(r)).length>2e3?alert("Code must be small enough to generate a shareable URL under 2000 characters!"):navigator.clipboard.writeText(s).then(n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:alert("Link copied to clipboard :)");case 1:case"end":return t.stop()}}),e)}))),n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:alert("Failed to copy link clipboard!");case 1:case"end":return t.stop()}}),e)}))));case 7:case"end":return e.stop()}}),e)})))),g.addEventListener("click",n(t().mark((function e(){var n,o;return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(!i){t.next=2;break}return t.abrupt("return");case 2:return i=!0,n=f.querySelector("form"),S(n,null,!0),(o=document.querySelectorAll(".playground .playground-run"))&&o.forEach((function(t){t.setAttribute("disabled","")})),t.next=9,L(!1);case 9:return p.querySelector("code").innerHTML="",t.next=12,E(a);case 12:o&&o.forEach((function(t){t.removeAttribute("disabled")})),A(n),f.classList.toggle("hidden"),p.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden"),delete c[a],i=!1;case 22:case"end":return t.stop()}}),e)})))),_.addEventListener("click",(function(){d.value=c[a],delete c[a],f.classList.toggle("hidden"),p.classList.toggle("hidden"),m.classList.toggle("hidden"),h.classList.toggle("hidden"),g.classList.toggle("hidden"),_.classList.toggle("hidden")}))}));case 3:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),I=function(){var e=n(t().mark((function e(o,r){var a,i,s,u,d,f,m,h,y;return t().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(c={},!window.location.pathname.endsWith("/playground/")){e.next=32;break}if(a=r||new URLSearchParams(window.location.search),i="Loading Pyodide...",s="Loading Notebook...",u=a.has("source")?a.get("source"):a.get("notebook"),d=document.querySelector("article"),null===u||!u.trim()){e.next=16;break}return S(d,i),e.next=11,L(!0);case 11:A(d),S(d,s);try{f=a.has("source")?"source":"notebook",p=decodeURIComponent(a.toString()),m="",h=new XMLHttpRequest,l=u,h.open("GET",u,!0),h.onload=n(t().mark((function e(){return t().wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return 4===h.readyState&&200===h.status&&(m=h.responseText),"source"===f&&(m=_(m)),t.next=4,k(m);case 4:return t.next=6,B(o);case 6:A(d),w();case 8:case"end":return t.stop()}}),e)}))),h.send()}catch(t){}e.next=30;break;case 16:return l="",y=_(a.has("code")?a.get("code"):g),p=decodeURIComponent(a.toString()),S(d,i),e.next=22,L(!0);case 22:return A(d),S(d,s),e.next=26,k(y);case 26:return e.next=28,B(o);case 28:A(d),w();case 30:e.next=35;break;case 32:l="",p="",B(o);case 35:case"end":return e.stop()}}),e)})));return function(t,n){return e.apply(this,arguments)}}();document.addEventListener("click",(function(t){var e=window.location.pathname.split("/")[1],n=t.target||t.srcElement;if("A"===n.tagName&&I&&n.getAttribute("href")&&n.host===window.location.host&&window.location.pathname==="/".concat(e,"/playground/")&&window.location.pathname===n.pathname&&window.location.search!==n.search){t.preventDefault();var o=new URLSearchParams(n.search);I(!1,o)}}),!0),window.addEventListener("popstate",(function(){var t=window.location.pathname.split("/")[1];window.location.pathname==="/".concat(t,"/playground/")&&(decodeURIComponent(new URLSearchParams(window.location.search).toString())!==p&&I(!1))})),window.addEventListener("unload",(function(){f=!1})),window.document$.subscribe((function(){f?f=!1:I(!0)}))}()}();
-//# sourceMappingURL=extra-notebook-37ed2f98.js.map
diff --git a/docs/theme/playground-config-430c3c65.js b/docs/theme/playground-config-4c088c14.js
similarity index 76%
rename from docs/theme/playground-config-430c3c65.js
rename to docs/theme/playground-config-4c088c14.js
index ea8f62a75..8d3f36e10 100644
--- a/docs/theme/playground-config-430c3c65.js
+++ b/docs/theme/playground-config-4c088c14.js
@@ -1,5 +1,5 @@
 var colorNotebook = {
-    "playgroundWheels": ['Pygments-2.12.0-py3-none-any.whl', 'coloraide-1.8.2-py3-none-any.whl'],
-    "notebookWheels": ['pyyaml', 'Markdown-3.3.4-py3-none-any.whl', 'pymdown_extensions-9.10-py3-none-any.whl', 'Pygments-2.12.0-py3-none-any.whl', 'coloraide-1.8.2-py3-none-any.whl'],
+    "playgroundWheels": ['Pygments-2.12.0-py3-none-any.whl', 'coloraide-2.0-py3-none-any.whl'],
+    "notebookWheels": ['pyyaml', 'Markdown-3.3.4-py3-none-any.whl', 'pymdown_extensions-9.10-py3-none-any.whl', 'Pygments-2.12.0-py3-none-any.whl', 'coloraide-2.0-py3-none-any.whl'],
     "defaultPlayground": "import coloraide\ncoloraide.__version__\nColor('red')"
 }
diff --git a/mkdocs.yml b/mkdocs.yml
index a7759eef2..7bb0ab512 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -262,9 +262,9 @@ extra_css:
   - assets/coloraide-extras/extra-6d1f4dbbc6.css
 extra_javascript:
   - https://unpkg.com/mermaid@8.13.3/dist/mermaid.min.js
-  - playground-config-430c3c65.js
+  - playground-config-4c088c14.js
   - https://cdn.jsdelivr.net/pyodide/v0.22.1/full/pyodide.js
-  - assets/coloraide-extras/extra-notebook-37ed2f98.js
+  - assets/coloraide-extras/extra-notebook-23a5be25.js
 
 extra:
   social:
diff --git a/tools/calc_cam16_min_m.py b/tools/calc_cam16_min_m.py
index e1d4f781b..b93764e23 100644
--- a/tools/calc_cam16_min_m.py
+++ b/tools/calc_cam16_min_m.py
@@ -12,8 +12,7 @@
 sys.path.insert(0, os.getcwd())
 
 from coloraide.everything import ColorAll as Color  # noqa: E402
-from coloraide.spaces.cam16 import Environment, xyz_d65_to_cam16  # noqa: E402
-from coloraide.spaces.cam16_jmh import Achromatic  # noqa: E402
+from coloraide.spaces.cam16_jmh import Environment, xyz_d65_to_cam16, Achromatic  # noqa: E402
 from coloraide.cat import WHITES  # noqa: E402
 
 
@@ -47,16 +46,11 @@ def main():
         '--spline', '-S', type=str, default='catrom', help="Spline to use for approximation of achromatic line"
     )
     parser.add_argument(
-        '--low', '-L', type=str, default='0:25:1:100.0',
-        help="Tuning for low range: start:end:step:scale (int:int:int:float)"
+        '--tuning', '-t', type=str, action='append',
+        help="Spline tuning parameters: start:end:step:scale (int:int:int:float)"
     )
     parser.add_argument(
-        '--mid', '-M', type=str, default='25:101:9:80.0',
-        help="Tuning for mid range: start:end:step:scale (int:int:int:float)"
-    )
-    parser.add_argument(
-        '--high', '-H', type=str, default='101:302:5:60.0',
-        help="Tuning for high range: start:end:step:scale (int:int:int:float)"
+        '--dump', action='store_true', help="Dump calculated values."
     )
     args = parser.parse_args()
 
@@ -67,40 +61,47 @@ def main():
         args.surround,
         args.discounting,
         args.spline,
-        args.low,
-        args.mid,
-        args.high,
-        args.res
+        args.tuning,
+        args.res,
+        args.dump
     )
 
 
-def run(white_point, la, ba, surround, discounting, spline, low, mid, high, res):
+def run(white_point, la, ba, surround, discounting, spline, tuning, res, dump):
     """Run."""
 
     deg, wp = white_point.split(':')
-    tuning = {
-        "low": [int(i) if e < 3 else float(i) for e, i in enumerate(low.split(':'))],
-        "mid": [int(i) if e < 3 else float(i) for e, i in enumerate(mid.split(':'))],
-        "high": [int(i) if e < 3 else float(i) for e, i in enumerate(high.split(':'))]
-    }
+    tune = []
+    for x in tuning:
+        tune.append([int(i) if e < 3 else float(i) for e, i in enumerate(x.split(':'))])
 
     env = Environment(WHITES[deg][wp], la, ba, surround, discounting)
     convert = xyz_d65_to_cam16
 
-    test = Achromatic(tuning, 0.06, env, spline)
+    test = Achromatic(spline=spline, env=env)
+    test.calc_achromatic_response(tune, env=env)
 
     color = Color('srgb', [0, 0, 0])
     points1 = {}
     points2 = {}
-    diff = 0
+    diff_over = 0
+    diff_under = 0
     max_m = 0
+    first = False
 
-    for i in range(res + 1):
+    for i in range(1, res + 1):
         div = res / 5
-        color.update('srgb', [i / div, i / div, i / div])
+        v = i / div
+        if v < 0.001:
+            continue
+        color.update('srgb', [v, v, v])
         xyz = color.convert('xyz-d65')
         coords = convert(xyz[:-1], env)[:]
-        j, m = coords[0], coords[5]
+        j, m, h = coords[0], coords[5], coords[2]
+        if not first:
+            print('Starting J: ', j)
+            print('Starting M: ', m)
+            first = True
 
         if m > max_m:
             max_m = m
@@ -109,46 +110,61 @@ def run(white_point, la, ba, surround, discounting, spline, low, mid, high, res)
             domain = test.scale(j)
             calc = test.spline(domain)
         else:
-            calc = (j, 1e-08)
+            calc = (j, 1e-08, 0.0)
 
-        delta = abs(calc[1] - m)
-        if delta > diff:
-            diff = delta
+        delta = calc[1] - m
+        if delta >= 0 and delta > diff_over:
+            diff_over = delta
+        if delta < 0 and abs(delta) > diff_under:
+            diff_under = abs(delta)
 
-        points1[j] = m
-        points2[calc[0]] = calc[1]
+        points1[j] = (m, h)
+        points2[calc[0]] = (calc[1], calc[2])
 
-    print('Delta: ', diff)
+    print('Delta Over: ', diff_over)
+    print('Delta Under: ', diff_under)
     print('Max M: ', max_m)
+    if not env.discounting:
+        print('Data Points: ', test.spline.length)
 
     j1 = []
     j2 = []
     m1 = []
     m2 = []
+    h1 = []
+    h2 = []
     for j in sorted(points1):
         j1.append(j)
-        m1.append(points1[j])
+        m1.append(points1[j][0])
+        h1.append(points1[j][1])
     for j in sorted(points2):
         j2.append(j)
-        m2.append(points2[j])
+        m2.append(points2[j][0])
+        h2.append(points2[j][1])
 
     figure = plt.figure()
 
-    # Create axes
     ax = plt.axes(
+        projection='3d',
         xlabel='M',
-        ylabel='J'
+        ylabel='H',
+        zlabel='J'
     )
-    ax.set_aspect('auto')
-    ax.set_title('JMh: Delta = {} - Max M = {}'.format(diff, max_m))
+
+    # ax.set_aspect('auto')
+    ax.set_title('JMh: Delta = {} - Max M = {}'.format(max(diff_over, diff_under), max_m))
     figure.add_axes(ax)
 
     # Print the calculated line against the real line
     plt.style.use('seaborn-v0_8-whitegrid')
-    plt.plot(m1, j1, '.', color='black')
-    plt.plot(m2, j2, '.', color='red', markersize=0.5)
+    plt.plot(m1, h1, j1, '.', color='black')
+    plt.plot(m2, h2, j2, '.', color='red', markersize=0.5)
     plt.show()
 
+    if dump:
+        print('===== Data =====')
+        print(test.dump())
+
     return 0
 
 
diff --git a/tools/calc_hct_min_c.py b/tools/calc_hct_min_c.py
index cd20bb7e7..c972ad6ce 100644
--- a/tools/calc_hct_min_c.py
+++ b/tools/calc_hct_min_c.py
@@ -26,92 +26,110 @@ def main():
         '--res', '-r', type=int, default=50000, help="Resolution to use when calculating range, default is 10000."
     )
     parser.add_argument(
-        '--spline', '-S', type=str, default='natural', help="Spline to use for approximation of achromatic line"
+        '--spline', '-S', type=str, default='catrom', help="Spline to use for approximation of achromatic line"
     )
     parser.add_argument(
-        '--low', '-L', type=str, default='0:51:1:200.0',
-        help="Tuning for low range: start:end:step:scale (int:int:int:float)"
+        '--tuning', '-t', type=str, action='append',
+        help="Spline tuning parameters: start:end:step:scale (int:int:int:float)"
     )
     parser.add_argument(
-        '--mid', '-M', type=str, default='50:101:5:100.0',
-        help="Tuning for mid range: start:end:step:scale (int:int:int:float)"
-    )
-    parser.add_argument(
-        '--high', '-H', type=str, default='101:502:25:75.0',
-        help="Tuning for high range: start:end:step:scale (int:int:int:float)"
+        '--dump', action='store_true', help="Dump calculated values."
     )
     args = parser.parse_args()
 
-    return run(args.spline, args.low, args.mid, args.high, args.res)
+    return run(args.spline, args.tuning, args.res, args.dump)
 
 
-def run(spline, low, mid, high, res):
+def run(spline, tuning, res, dump):
     """Run."""
 
-    tuning = {
-        "low": [int(i) if e < 3 else float(i) for e, i in enumerate(low.split(':'))],
-        "mid": [int(i) if e < 3 else float(i) for e, i in enumerate(mid.split(':'))],
-        "high": [int(i) if e < 3 else float(i) for e, i in enumerate(high.split(':'))]
-    }
+    tune = []
+    for x in tuning:
+        tune.append([int(i) if e < 3 else float(i) for e, i in enumerate(x.split(':'))])
     env = HCT.ENV
-    test = Achromatic(tuning, 0.06, env, spline)
+    test = Achromatic(spline=spline, env=env)
+    test.calc_achromatic_response(tune, env=env)
 
     color = Color('srgb', [0, 0, 0])
     points1 = {}
     points2 = {}
-    diff = 0
+    diff_over = 0
+    diff_under = 0
     max_m = 0
+    first = False
 
     for i in range(res + 1):
         div = res / 5
-        color.update('srgb', [i / div, i / div, i / div])
+        v = i / div
+        if v < 0.001:
+            continue
+        color.update('srgb', [v, v, v])
         xyz = color.convert('xyz-d65')
-        m, t = xyz_to_hct(xyz[:-1], HCT.ENV)[1:]
+        h, c, t = xyz_to_hct(xyz[:-1], HCT.ENV)
+        if not first:
+            print('Starting T: ', t)
+            print('Starting C: ', c)
+            first = True
 
-        if m > max_m:
-            max_m = m
+        if c > max_m:
+            max_m = c
 
         domain = test.scale(t)
         calc = test.spline(domain)
 
-        delta = abs(calc[1] - m)
-        if delta > diff:
-            diff = delta
+        delta = calc[1] - c
+        if delta >= 0 and delta > diff_over:
+            diff_over = delta
+        if delta < 0 and abs(delta) > diff_under:
+            diff_under = abs(delta)
 
-        points1[t] = m
-        points2[calc[0]] = calc[1]
+        points1[t] = (c, h)
+        points2[calc[0]] = (calc[1], calc[2])
 
-    print('Delta: ', diff)
-    print('Max Chroma: ', max_m)
+    print('Delta Over: ', diff_over)
+    print('Delta Under: ', diff_under)
+    print('Max M: ', max_m)
+    print('Data Points: ', test.spline.length)
 
     t1 = []
     t2 = []
     m1 = []
     m2 = []
+    h1 = []
+    h2 = []
     for t in sorted(points1):
         t1.append(t)
-        m1.append(points1[t])
+        m1.append(points1[t][0])
+        h1.append(points1[t][1])
     for t in sorted(points2):
         t2.append(t)
-        m2.append(points2[t])
+        m2.append(points2[t][0])
+        h2.append(points2[t][1])
 
     figure = plt.figure()
 
     # Create axes
     ax = plt.axes(
+        projection='3d',
         xlabel='C',
-        ylabel='T'
+        ylabel='H',
+        zlabel='T'
     )
+
     ax.set_aspect('auto')
-    ax.set_title('HCT: Delta = {} - Max C = {}'.format(diff, max_m))
+    ax.set_title('HCT: Delta = {} - Max C = {}'.format(max(diff_over, diff_under), max_m))
     figure.add_axes(ax)
 
     # Print the calculated line against the real line
     plt.style.use('seaborn-v0_8-whitegrid')
-    plt.plot(m1, t1, '.', color='black')
-    plt.plot(m2, t2, '.', color='red', markersize=0.5)
+    plt.plot(m1, h1, t1, '.', color='black')
+    plt.plot(m2, h2, t2, '.', color='red', markersize=0.5)
     plt.show()
 
+    if dump:
+        print('===== Data =====')
+        print(test.dump())
+
     return 0
 
 
diff --git a/tools/calc_lch_min_c.py b/tools/calc_lch_min_c.py
new file mode 100644
index 000000000..27bb98592
--- /dev/null
+++ b/tools/calc_lch_min_c.py
@@ -0,0 +1,174 @@
+"""
+Map our achromatic spline calculation against real achromatic response.
+
+Report the delta between our spline and the real world. Also note the highest chroma climbs.
+"""
+import sys
+import argparse
+import os
+import matplotlib.pyplot as plt
+
+sys.path.insert(0, os.getcwd())
+
+from coloraide.everything import ColorAll as Color  # noqa: E402
+from coloraide.spaces.achromatic import Achromatic  # noqa: E402
+from coloraide import algebra as alg  # noqa: E402
+
+
+def main():
+    """Main."""
+
+    parser = argparse.ArgumentParser(
+        prog='calc_cam16_ucs_jmh_min_m.py',
+        description='Calculate min C for achromatic colors in LCh spaces and map spline against real values.'
+    )
+    parser.add_argument(
+        '-space', '-s', type=str, help="Color space to use."
+    )
+    # Flag arguments
+    parser.add_argument(
+        '--res', '-r', type=int, default=50000, help="Resolution to use when calculating range, default is 50000."
+    )
+    parser.add_argument(
+        '--spline', '-S', type=str, default='catrom', help="Spline to use for approximation of achromatic line"
+    )
+    parser.add_argument(
+        '--tuning', '-t', type=str, action='append',
+        help="Spline tuning parameters: start:end:step:scale (int:int:int:float)"
+    )
+    parser.add_argument(
+        '--mirror', '-m', action='store_true', help="Mirror response across lightness axis"
+    )
+    parser.add_argument(
+        '--dump', action='store_true', help="Dump calculated values."
+    )
+    args = parser.parse_args()
+
+    return run(
+        args.space,
+        args.spline,
+        args.tuning,
+        args.mirror,
+        args.res,
+        args.dump
+    )
+
+
+def run(space, spline, tuning, mirror, res, dump):
+    """Run."""
+
+    tune = []
+    for x in tuning:
+        tune.append([int(i) if e < 3 else float(i) for e, i in enumerate(x.split(':'))])
+
+    class Achroma(Achromatic):
+        """Setup special dynamic achromatic class."""
+
+        L_IDX = 0
+        C_IDX = 1
+        H_IDX = 2
+
+        def convert(self, coords, **kwargs):
+            """Convert to the target color space."""
+
+            lab = Color('srgb', coords).convert(space)
+            l = lab[0]
+            c, h = alg.rect_to_polar(*lab[1:-1])
+            return l, c, h
+
+    test = Achroma(spline=spline, mirror=mirror)
+    test.calc_achromatic_response(tune)
+
+    color = Color('srgb', [0, 0, 0])
+    points1 = {}
+    points2 = {}
+    diff_over = 0
+    diff_under = 0
+    min_h = float('inf')
+    max_h = float('-inf')
+    max_c = 0
+    first = False
+
+    for i in range(res + 1):
+        div = res / 5
+        v = i / div
+        if v < 0.001:
+            continue
+        color.update('srgb', [v, v, v])
+        lab = color.convert(space, norm=False)
+        l = lab[0]
+        c, h = alg.rect_to_polar(*lab[1:-1])
+        if not first:
+            print('Starting L: ', l)
+            print('Starting C: ', c)
+            first = True
+
+        if c > max_c:
+            max_c = c
+
+        if h < min_h:
+            min_h = h
+        if h > max_h:
+            max_h = h
+
+        domain = test.scale(l)
+        calc = test.spline(domain)
+
+        delta = calc[1] - c
+        if delta >= 0 and delta > diff_over:
+            diff_over = delta
+        if delta < 0 and abs(delta) > diff_under:
+            diff_under = abs(delta)
+
+        points1[l] = (c, h)
+        points2[calc[0]] = (calc[1], calc[2])
+
+    print('Delta Over: ', diff_over)
+    print('Delta Under: ', diff_under)
+    print('Max C: ', max_c)
+    print('Hue (low/high) : {} / {}'.format(min_h, max_h))
+    print('Data Points: ', test.spline.length)
+
+    l1 = []
+    l2 = []
+    c1 = []
+    c2 = []
+    h1 = []
+    h2 = []
+    for l in sorted(points1):
+        l1.append(l)
+        c1.append(points1[l][0])
+        h1.append(points1[l][1])
+    for l in sorted(points2):
+        l2.append(l)
+        c2.append(points2[l][0])
+        h2.append(points2[l][1])
+
+    figure = plt.figure()
+
+    # Create axes
+    ax = plt.axes(
+        projection='3d',
+        xlabel='C',
+        ylabel='h',
+        zlabel='L'
+    )
+    # ax.set_aspect('auto')
+    ax.set_title('LCh: Delta (over/under) = {:.5g}/{:.5g} - Max C = {:.5g}'.format(diff_over, diff_under, max_c))
+    figure.add_axes(ax)
+
+    # Print the calculated line against the real line
+    plt.style.use('seaborn-v0_8-whitegrid')
+    plt.plot(c1, l1, '.', color='black')
+    plt.plot(c2, l2, '.', color='red', markersize=0.5)
+    plt.show()
+
+    if dump:
+        print('===== Data =====')
+        print(test.dump())
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/calc_lch_threshold.py b/tools/calc_lch_threshold.py
index 0f1b72030..0bfea3523 100644
--- a/tools/calc_lch_threshold.py
+++ b/tools/calc_lch_threshold.py
@@ -56,7 +56,7 @@ def run(lch, lab, verify):
             color = Color(space, [x / 1000] * 3)
             if lab:
                 labish = color.convert(lab)
-                a_name, b_name = labish._space.labish_names()[1:]
+                a_name, b_name = labish._space.names()[1:]
                 a = labish.get(a_name)
                 b = labish.get(b_name)
                 if abs(a) > max_a:
@@ -65,11 +65,12 @@ def run(lch, lab, verify):
                     max_b = abs(b)
 
             if lch:
-                lchish = color.convert(lch)
-                c_name = lchish._space.lchish_names()[1]
+                lchish = color.convert(lch, norm=False)
+                c_name = lchish._space.names()[1]
                 chroma = lchish.get(c_name)
                 if verify:
-                    assert lchish.is_nan('hue'), str(lchish) + " <-> " + str(color)
+                    lchish_adjusted = color.convert(lch)
+                    assert lchish_adjusted.is_nan('hue'), str(lchish_adjusted) + " <-> " + str(color)
                 if chroma > max_chroma:
                     max_chroma = chroma
 
diff --git a/tools/calc_oklab_matrices.py b/tools/calc_oklab_matrices.py
index e00caedbd..bde58fffc 100644
--- a/tools/calc_oklab_matrices.py
+++ b/tools/calc_oklab_matrices.py
@@ -28,9 +28,9 @@
 
 
 LMS3_TO_OKLAB = [
-    [0.2104542553, 0.793617785, -0.0040720468],
-    [1.9779984951, -2.428592205, 0.4505937099],
-    [0.0259040371, 0.7827717662, -0.808675766]
+    [0.21045426824930336, 0.7936177747759865, -0.00407204302528986],
+    [1.977998539071675, -2.4285922502018127, 0.4505937111301374],
+    [0.025904030547901084, 0.7827717270900503, -0.8086757576379514]
 ]
 
 OKLAB_TO_LMS3 = alg.inv(LMS3_TO_OKLAB)
diff --git a/tools/gamut_3d_diagrams.py b/tools/gamut_3d_diagrams.py
index 7502be29a..f1989a8de 100644
--- a/tools/gamut_3d_diagrams.py
+++ b/tools/gamut_3d_diagrams.py
@@ -21,20 +21,13 @@
     from coloraide_extras.everything import ColorAll as Color
 except ImportError:
     from coloraide.everything import ColorAll as Color
-from coloraide.spaces import Cylindrical, LChish, Labish  # noqa: E402
-from coloraide.algebra import is_nan  # noqa: E402
+from coloraide.spaces import Cylindrical, LChish, Labish, HSLish, HSVish  # noqa: E402
 
 # Special cases for certain color spaces
 color_options = {
-    'hsluv': {'factor': 100, 'chroma': 's', 'force_top': True, 'force_bottom': True, 'is_hsl': True},
-    'hpluv': {'factor': 100, 'chroma': 'p', 'force_top': True, 'force_bottom': True, 'is_hsl': True},
-    'hsl': {'chroma': 's', 'force_top': True, 'force_bottom': True},
-    'okhsl': {'chroma': 's', 'force_top': True, 'force_bottom': True, "is_hsl": True},
-    'hsv': {'chroma': 's', 'lightness': 'v', 'force_bottom': True, 'is_hsv': True},
-    'okhsv': {'chroma': 's', 'lightness': 'v', 'force_bottom': True, 'is_hsv': True},
-    'hsi': {'chroma': 's', 'lightness': 'i', 'force_bottom': True},
-    'hwb': {'chroma': 'w', 'lightness': 'b', 'force_max_radius': True, 'force_top': True, 'force_bottom': True},
-    'cam16-jmh': {'chroma': 'm'}
+    'hsluv': {'factor': 100},
+    'hpluv': {'factor': 100},
+    'hwb': {'force_max_radius': True}
 }
 
 
@@ -91,7 +84,7 @@ class ColorCyl(Color):
     return ColorCyl
 
 
-def add_rect_color(space, color, x, y, z, c):
+def add_rect_color(space, stype, color, x, y, z, c):
     """Add rectangular color to the provided arrays."""
 
     coords = color.convert(space)[:-1]
@@ -104,7 +97,7 @@ def add_rect_color(space, color, x, y, z, c):
     c.append(color.to_string(hex=True))
 
 
-def add_lab_color(space, color, x, y, z, c):
+def add_lab_color(space, stype, color, x, y, z, c):
     """Add Lab like color to the provided arrays."""
 
     coords = color.convert(space)[:-1]
@@ -117,20 +110,23 @@ def add_lab_color(space, color, x, y, z, c):
     c.append(color.to_string(hex=True))
 
 
-def add_cyl_color(space, color, x, y, z, c):
+def add_cyl_color(space, stype, color, x, y, z, c):
     """
     Add color to the provided arrays.
 
     Handles cylindrical spaces. Returns x (hue), y (chroma/saturation), z (value/lightness).
     """
 
-    cyl = color.convert(space)
-    lightness = cyl.get(color_options.get(space, {}).get('lightness', 'lightness'))
-    chroma = cyl.get(color_options.get(space, {}).get('chroma', 'chroma'))
-    hue = cyl.get(color_options.get(space, {}).get('hue', 'hue'))
+    cyl = color.convert(space, norm=False)
 
-    if is_nan(hue):
-        hue = cyl._space.achromatic_hue()
+    if stype in ('hslish', 'hsvish'):
+        names = cyl._space.names()
+        hue, chroma, lightness = cyl.get(names)
+    elif stype == 'lchish':
+        names = cyl._space.names()
+        lightness, chroma, hue = cyl.get(names)
+    else:
+        hue, chroma, lightness = cyl.coords()
 
     x.append(chroma * math.sin(math.radians(hue)))
     y.append(chroma * math.cos(math.radians(hue)))
@@ -162,10 +158,22 @@ def render_space(space, gamut, resolution, factor, x, y, z, c):
     spaces.
     """
 
-    is_hsv = color_options.get(space, {}).get('is_hsv', False)
-    is_hsl = color_options.get(space, {}).get('is_hsl', False)
-    if not is_hsv:
-        ColorCyl = create_custom_hsv(space, gamut, is_hsl, factor)
+    cs = Color.CS_MAP[space]
+
+    is_cyl = isinstance(cs, Cylindrical)
+    if isinstance(cs, Labish):
+        space_type = 'labish'
+    elif isinstance(cs, LChish):
+        space_type = 'lchish'
+    elif isinstance(cs, HSVish):
+        space_type = 'hsvish'
+    elif isinstance(cs, HSLish):
+        space_type = 'hslish'
+    else:
+        space_type = 'other'
+
+    if space_type != 'hsvish' or space == 'hsi':
+        ColorCyl = create_custom_hsv(space, gamut, space_type == 'hslish', factor)
         gamut_space = 'hsv-{}'.format(gamut)
     else:
         ColorCyl = Color
@@ -175,18 +183,17 @@ def render_space(space, gamut, resolution, factor, x, y, z, c):
     res2 = int(resolution * 1.5)
 
     color = ColorCyl('srgb', [])
-    is_cyl = isinstance(ColorCyl.CS_MAP[space], Cylindrical)
-    is_labish = isinstance(ColorCyl.CS_MAP[space], Labish)
-    is_lchish = isinstance(ColorCyl.CS_MAP[space], LChish)
-    if is_labish:
+
+    if space_type == 'labish':
         add = add_lab_color
     elif not is_cyl:
         add = add_rect_color
     else:
         add = add_cyl_color
-    force_max_radius = not is_lchish and is_cyl and color_options.get(space, {}).get('force_max_radius', False)
-    force_bottom = color_options.get(space, {}).get('force_bottom', False)
-    force_top = color_options.get(space, {}).get('force_top', False)
+
+    force_max_radius = space_type != 'lchish' and is_cyl and color_options.get(space, {}).get('force_max_radius', False)
+    force_bottom = space_type != 'lchish' and is_cyl
+    force_top = space_type == 'hslish'
 
     # We are rendering the spaces using sRGB, so just do a shell by picking
     # all the colors on the outside of the sRGB space. Render will be hollow.
@@ -203,17 +210,17 @@ def render_space(space, gamut, resolution, factor, x, y, z, c):
                 c1 += (360 / resolution) * 0.5
 
         if not force_max_radius:
-            add(space, color.update(gamut_space, [c1, c2, 1]), x, y, z, c)
-            add(space, color.update(gamut_space, [c1, 1, c2]), x, y, z, c)
+            add(space, space_type, color.update(gamut_space, [c1, c2, 1], norm=False), x, y, z, c)
+            add(space, space_type, color.update(gamut_space, [c1, 1, c2], norm=False), x, y, z, c)
         else:
             # Certain color spaces, like HWB, we must force max radius as the space
             # is constructed in a way that just doesn't translate to how we map other spaces.
             x.append(factor * math.sin(math.radians(c1)))
             y.append(factor * math.cos(math.radians(c1)))
             z.append(c2)
-            c.append(color.update(space, [c1, factor, c2]).to_string(hex=True))
+            c.append(color.update(space, [c1, factor, c2], norm=False).to_string(hex=True))
 
-        if not is_lchish and is_cyl:
+        if space_type != 'lchish' and is_cyl:
             # RGB cylinders often map max lightness to a single point instead of rendering a full cylinder
             # top an bottom. The alternative is to map the radius with a magnitude that lessens as we approach
             # achromatic, or just render the top and bottom of the cylinder with a disc. We've chosen the latter.
@@ -224,17 +231,17 @@ def render_space(space, gamut, resolution, factor, x, y, z, c):
                 x.append(c2 * factor * math.sin(math.radians(c1)))
                 y.append(c2 * factor * math.cos(math.radians(c1)))
                 z.append(factor)
-                c.append(color.update(space, [c1, c2, factor]).to_string(hex=True))
+                c.append(color.update(space, [c1, c2, factor], norm=False).to_string(hex=True))
 
             if force_bottom:
                 # Bottom disc
                 x.append(c2 * factor * math.sin(math.radians(c1)))
                 y.append(c2 * factor * math.cos(math.radians(c1)))
                 z.append(0)
-                c.append(color.update(space, [c1, c2 * factor, 0]).to_string(hex=True))
+                c.append(color.update(space, [c1, c2 * factor, 0], norm=False).to_string(hex=True))
         else:
             # Some spaces require a higher resolution in the black region to fill in any holes
-            add(space, color.update(gamut_space, [c1, 1, c2 * 0.005]), x, y, z, c)
+            add(space, space_type, color.update(gamut_space, [c1, 1, c2 * 0.005], norm=False), x, y, z, c)
 
 
 def plot_gamut_in_space(space, gamut, title="", dark=False, resolution=70, rotate_elev=30.0, rotate_azim=-60.0):
@@ -257,10 +264,10 @@ def plot_gamut_in_space(space, gamut, title="", dark=False, resolution=70, rotat
 
     # Some spaces need us to rearrange the order of the data
     if is_labish:
-        c1, c2, c3 = target.labish_indexes()
+        c1, c2, c3 = target.indexes()
         axm = [c2, c3, c1]
     elif is_lchish:
-        c1, c2, c3 = target.lchish_indexes()
+        c1, c2, c3 = target.indexes()
         axm = [c3, c2, c1]
     elif is_cyl:
         axm = [0, 1, 2]
diff --git a/tools/slice_diagram.py b/tools/slice_diagram.py
index 046459d64..2da4ee63f 100644
--- a/tools/slice_diagram.py
+++ b/tools/slice_diagram.py
@@ -27,23 +27,18 @@
     from coloraide.everything import ColorAll as Color
 from coloraide import NaN  # noqa: E402
 from coloraide.util import fmt_float  # noqa: E402
-from coloraide.spaces import Cylindrical  # noqa: E402
+from coloraide.spaces import Cylindrical, LChish  # noqa: E402
 
 
-def needs_workaround(color):
+def ignore_LCh_high_chroma_black(color):
     """
-    Check if LChuv has high chroma and no lightness.
+    Ignore LCh spaces that will render all lightness of zero with any chroma as the same color.
 
-    LChuv will have such values all in gamut and create weird graphs.
-    This is just due to how LChuv algorithm handles high chroma and zero lightness,
-    it all gets treated as black which is in gamut for almost any color.
+    Prevents a render of a single black line at lightness zero across all chroma.
     """
 
-    return (
-        color.space().startswith(('lchuv', 'cam16-jmh', 'hct')) and
-        color['lightness'] == 0 and
-        not color.normalize().is_nan('hue')
-    )
+    names = color._space.names()
+    return color[names[0]] == 0 and color[names[1]] != 0
 
 
 def plot_slice(
@@ -72,6 +67,7 @@ def plot_slice(
 
     # Create a color object based on the specified space.
     c = Color(space, [])
+    is_lchish = isinstance(c._space, LChish)
 
     # Parse the channel strings into actual values
     chan_constants = []
@@ -144,8 +140,8 @@ def plot_slice(
         coords[index2] = c2
         c.update(space, coords)
 
-        # Only process colors within the gamut of sRGB.
-        if c.in_gamut(gamut, tolerance=0) and not needs_workaround(c):
+        # Only process colors within the specified gamut.
+        if c.in_gamut(gamut, tolerance=0) and (not is_lchish or not ignore_LCh_high_chroma_black(c)):
             if hue_index != -1:
                 c1 = math.radians(c1)
 


Test patch:
diff --git a/tests/test_acescct.py b/tests/test_acescct.py
index 2ce5179f4..986c27c13 100644
--- a/tests/test_acescct.py
+++ b/tests/test_acescct.py
@@ -51,7 +51,7 @@ class TestACEScctSerialize(util.ColorAssertsPyTest):
         # TODO: In ACEScct, it seems that 0 may or may not be out of gamut? If it is out of gamut,
         # then we might need to rethink zero always being the default replacement for NaN
         # or come up with some other approach.
-        ('color(--acescct none 0.3 0.75)', {}, 'color(--acescct 0 0.3 0.75)'),
+        ('color(--acescct none 0.3 0.75)', {}, 'color(--acescct 0.07291 0.3 0.75)'),
         ('color(--acescct none 0.3 0.75)', {'none': True}, 'color(--acescct none 0.3 0.75)'),
         # Test Fit
         ('color(--acescct 1.5 0.2 0)', {}, 'color(--acescct 1.468 0.2 0.07291)'),
diff --git a/tests/test_api.py b/tests/test_api.py
index e70e089d8..b21a59174 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -275,6 +275,12 @@ def test_get(self):
         c1 = Color('orange')
         self.assertEqual(c1.get("red"), 1.0)
 
+    def test_get_no_nans(self):
+        """Test get with no NaN."""
+
+        self.assertEqual(Color('white').convert('lch').get('h', nans=False), 0.0)
+        self.assertEqual(Color('white').convert('lch').get(['c', 'h'], nans=False), [0.0, 0.0])
+
     def test_space_get(self):
         """Test get with another space."""
 
@@ -312,6 +318,52 @@ def test_set(self):
         c1.set("red", 0.5)
         self.assertEqual(c1.get("red"), 0.5)
 
+    def test_set_undefined(self):
+        """Test set."""
+
+        def set_test(value):
+            """Test that value is not NaN."""
+
+            assert not math.isnan(value)
+            return value
+
+        c1 = Color('gray').convert('hsl')
+        c1.set("hue", set_test, nans=False)
+
+    def test_set_undefined_space(self):
+        """Test set."""
+
+        def set_test(value):
+            """Test that value is not NaN."""
+
+            assert not math.isnan(value)
+            return value
+
+        Color('gray').set('hsl.hue', set_test, nans=False)
+
+    def test_multi_set_undefined(self):
+        """Test set."""
+
+        def set_test(value):
+            """Test that value is not NaN."""
+
+            assert not math.isnan(value)
+            return value
+
+        c1 = Color('gray').convert('hsl')
+        c1.set({'lightness': 0.5, "hue": set_test}, nans=False)
+
+    def test_multi_set_undefined_space(self):
+        """Test set."""
+
+        def set_test(value):
+            """Test that value is not NaN."""
+
+            assert not math.isnan(value)
+            return value
+
+        Color('gray').set({'hsl.saturation': 0.5, 'hsl.hue': set_test}, nans=False)
+
     def test_multi_set(self):
         """Test setting multiple channels via set."""
 
@@ -391,6 +443,31 @@ def test_set_bad_input(self):
         with self.assertRaises(ValueError):
             c1.set("red", "bad")
 
+    def test_is_achromatic(self):
+        """Test the default `is_achroamtic` (evaluation in XYZ-d65) when space reports it doesn't know."""
+
+        from coloraide.spaces.srgb.css import sRGB
+
+        class TempsRGB(sRGB):
+            """Construct an sRGB class that defaults to fallback achromatic test."""
+
+            def is_achromatic(self, coords) -> bool:
+                """
+                If no undefined, report we don't know if the color is achromatic.
+
+                This is the default and allows us to test the fallback achromatic test.
+                """
+
+                return None
+
+        class TempColor(Color):
+            """Temporary color class."""
+
+        TempColor.register(TempsRGB(), overwrite=True)
+
+        self.assertFalse(TempColor('red').is_achromatic())
+        self.assertTrue(TempColor('gray').is_achromatic())
+
     def test_is_nan_false(self):
         """Test when `is_nan` is false."""
 
@@ -474,8 +551,8 @@ def test_random_space(self):
         c = Color.random('srgb')
         self.assertEqual(c.space(), 'srgb')
 
-        c = Color.random('display-p3')
-        self.assertEqual(c.space(), 'display-p3')
+        c = Color.random('hsl')
+        self.assertEqual(c.space(), 'hsl')
 
     def test_random_range(self):
         """Test that random colors are generated within the space's range."""
diff --git a/tests/test_blend_modes.py b/tests/test_blend_modes.py
index 586c36741..f862e7486 100644
--- a/tests/test_blend_modes.py
+++ b/tests/test_blend_modes.py
@@ -154,12 +154,6 @@ def test_hue(self):
         self.assertColorEqual(Color(BLUISH).compose(REDISH, blend='hue'), Color('rgb(13.23 172.67 204.23)'))
         self.assertColorEqual(Color(BLUISH).compose(YELLOWISH, blend='hue'), Color('rgb(113.71 231.66 255)'))
 
-        # sRGB must be forced
-        self.assertColorEqual(
-            Color(BLUISH).compose(YELLOWISH, blend='hue', space="display-p3"),
-            Color(BLUISH).compose(YELLOWISH, blend='hue', space="srgb")
-        )
-
     def test_saturation(self):
         """Test hue."""
 
@@ -172,12 +166,6 @@ def test_saturation(self):
         self.assertColorEqual(Color(BLUISH).compose(REDISH, blend='saturation'), Color('rgb(255 59.357 153.59)'))
         self.assertColorEqual(Color(BLUISH).compose(YELLOWISH, blend='saturation'), Color('rgb(245.4 211.1 15.403)'))
 
-        # sRGB must be forced
-        self.assertColorEqual(
-            Color(BLUISH).compose(YELLOWISH, blend='saturation', space="display-p3"),
-            Color(BLUISH).compose(YELLOWISH, blend='saturation', space="srgb")
-        )
-
     def test_luminosity(self):
         """Test luminosity."""
 
@@ -187,12 +175,6 @@ def test_luminosity(self):
         self.assertColorEqual(Color(BLUISH).compose(REDISH, blend='luminosity'), Color('rgb(255 86.175 167.49)'))
         self.assertColorEqual(Color(BLUISH).compose(YELLOWISH, blend='luminosity'), Color('rgb(182.76 155.5 0)'))
 
-        # sRGB must be forced
-        self.assertColorEqual(
-            Color(BLUISH).compose(YELLOWISH, blend='luminosity', space="display-p3"),
-            Color(BLUISH).compose(YELLOWISH, blend='luminosity', space="srgb")
-        )
-
     def test_color(self):
         """Test color."""
 
@@ -202,12 +184,6 @@ def test_color(self):
         self.assertColorEqual(Color(BLUISH).compose(REDISH, blend='color'), Color('rgb(0 177.73 212.9)'))
         self.assertColorEqual(Color(BLUISH).compose(YELLOWISH, blend='color'), Color('rgb(113.71 231.66 255)'))
 
-        # sRGB must be forced
-        self.assertColorEqual(
-            Color(BLUISH).compose(YELLOWISH, blend='color', space="display-p3"),
-            Color(BLUISH).compose(YELLOWISH, blend='color', space="srgb")
-        )
-
 
 class TestBlend(util.ColorAsserts, unittest.TestCase):
     """Test general blend API."""
@@ -226,7 +202,7 @@ def test_blend_different_space(self):
         c2 = Color('yellow')
         self.assertColorEqual(
             c1.compose(c2, blend='normal', space="display-p3"),
-            Color('rgb(127.5 127.5 167.63)')
+            Color('color(display-p3 0.5 0.5 0.64524)')
         )
 
     def test_blend_different_space_and_output(self):
@@ -235,8 +211,8 @@ def test_blend_different_space_and_output(self):
         c1 = Color('blue').set('alpha', 0.5)
         c2 = Color('yellow')
         self.assertColorEqual(
-            c1.compose(c2, blend='normal', space="display-p3", out_space="display-p3"),
-            Color('color(display-p3 0.5 0.5 0.64524)')
+            c1.compose(c2, blend='normal', space="display-p3", out_space="srgb"),
+            Color('rgb(127.5 127.5 167.63)')
         )
 
     def test_blend_bad_mode(self):
diff --git a/tests/test_cam16.py b/tests/test_cam16.py
index 49316c189..b0849c0ee 100644
--- a/tests/test_cam16.py
+++ b/tests/test_cam16.py
@@ -1,13 +1,9 @@
 """Test CAM16 UCS."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
-from coloraide.spaces.cam16 import cam16_to_xyz_d65, CAM16
-from collections import namedtuple
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
-CAM16Coords = namedtuple("CAM16Coords", "J C h s Q M H")
-
 
 class TestCAM16CAM16(util.ColorAssertsPyTest):
     """Test CAM16."""
@@ -101,67 +97,17 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 0.5)
 
 
-class TestCAM16ApperanceModel(util.ColorAsserts, unittest.TestCase):
-    """Test CAM16 appearance model."""
-
-    COORDS = CAM16Coords(
-        45.33435136131785, 45.26195932727762, 258.92464993097565,
-        62.67686398624793, 83.29355481993107, 32.720950777696196, 310.5279473979526
-    )
-
-    def test_no_lightness(self):
-        """Test conversion failure when no equivalent lightness."""
-
-        with self.assertRaises(ValueError):
-            cam16_to_xyz_d65(C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV)
-
-    def test_no_chroma(self):
-        """Test conversion failure when no equivalent chroma."""
-
-        with self.assertRaises(ValueError):
-            cam16_to_xyz_d65(J=self.COORDS.J, h=self.COORDS.h, env=CAM16.ENV)
-
-    def test_no_hue(self):
-        """Test conversion failure when no equivalent hue."""
-
-        with self.assertRaises(ValueError):
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, env=CAM16.ENV)
-
-    def test_no_environment(self):
-        """Test no test no environment."""
-
-        with self.assertRaises(ValueError):
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h)
-
-    def test_lightness_convert(self):
-        """Test convert lightness."""
-
-        for a, b in zip(
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV),
-            cam16_to_xyz_d65(Q=self.COORDS.Q, C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV)
-        ):
-            self.assertCompare(a, b, 14)
-
-    def test_chroma_convert(self):
-        """Test convert chroma."""
-
-        for a, b in zip(
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV),
-            cam16_to_xyz_d65(Q=self.COORDS.Q, s=self.COORDS.s, h=self.COORDS.h, env=CAM16.ENV)
-        ):
-            self.assertCompare(a, b, 14)
-
-        for a, b in zip(
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV),
-            cam16_to_xyz_d65(Q=self.COORDS.Q, M=self.COORDS.M, h=self.COORDS.h, env=CAM16.ENV)
-        ):
-            self.assertCompare(a, b, 14)
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
 
-    def test_hue_convert(self):
-        """Test convert hue."""
+    def test_achromatic(self):
+        """Test when color is achromatic."""
 
-        for a, b in zip(
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16.ENV),
-            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=CAM16.ENV)
-        ):
-            self.assertCompare(a, b, 14)
+        self.assertEqual(Color('#222222').convert('cam16').is_achromatic(), True)
+        self.assertEqual(Color('srgb', [0.000000001] * 3).convert('cam16').set('j', NaN).is_achromatic(), True)
+        self.assertEqual(Color('cam16', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('cam16', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('cam16', [0, 3, -4]).is_achromatic(), True)
+        self.assertEqual(Color('cam16', [NaN, 0, -3]).is_achromatic(), True)
+        self.assertEqual(Color('cam16', [30, NaN, 0]).is_achromatic(), False)
+        self.assertEqual(Color('cam16', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_cam16_jmh.py b/tests/test_cam16_jmh.py
index d57044c65..d58e8325a 100644
--- a/tests/test_cam16_jmh.py
+++ b/tests/test_cam16_jmh.py
@@ -2,8 +2,12 @@
 import unittest
 from . import util
 from coloraide.everything import ColorAll as Color, NaN
+from coloraide.spaces.cam16_jmh import cam16_to_xyz_d65, CAM16JMh
+from collections import namedtuple
 import pytest
 
+CAM16Coords = namedtuple("CAM16Coords", "J C h s Q M H")
+
 
 class TestCAM16JMh(util.ColorAssertsPyTest):
     """Test CAM16 JMh."""
@@ -16,9 +20,9 @@ class TestCAM16JMh(util.ColorAssertsPyTest):
         ('blue', 'color(--cam16-jmh 25.066 62.442 282.75)'),
         ('indigo', 'color(--cam16-jmh 16.046 43.278 310.9)'),
         ('violet', 'color(--cam16-jmh 63.507 46.779 331.39)'),
-        ('white', 'color(--cam16-jmh 100 2.2369 0)'),
-        ('gray', 'color(--cam16-jmh 43.042 1.467 0)'),
-        ('black', 'color(--cam16-jmh 0 0 0)'),
+        ('white', 'color(--cam16-jmh 100 2.2369 209.53)'),
+        ('gray', 'color(--cam16-jmh 43.042 1.467 209.54)'),
+        ('black', 'color(--cam16-jmh 0 0 209.54)'),
         # Test color
         ('color(--cam16-jmh 50 30 270)', 'color(--cam16-jmh 50 30 270)'),
         ('color(--cam16-jmh 50 30 270 / 0.5)', 'color(--cam16-jmh 50 30 270 / 0.5)'),
@@ -37,31 +41,13 @@ def test_colors(self, color1, color2):
         self.assertColorEqual(Color(color1).convert('cam16-jmh'), Color(color2))
 
 
-class TestCAM16JMhRevere(util.ColorAssertsPyTest):
-    """Test CAM16 JMh conversions in reverse."""
-
-    COLORS = [
-        # CAM16 has a specific limit for a given lightness that determines
-        # when a color is achromatic. This is specific to when `discounting=False`.
-        # Anything below the limit will start giving you non-achromatic colors
-        # again. Internally, we catch this handle them as achromatic.
-        ('color(--cam16-jmh 0.5 0 0)', 'rgb(0.24033 0.23714 0.23654)')
-    ]
-
-    @pytest.mark.parametrize('color1,color2', COLORS)
-    def test_colors(self, color1, color2):
-        """Test colors."""
-
-        self.assertColorEqual(Color(color1).convert('srgb'), Color(color2))
-
-
 class TestCAM16JMhPoperties(util.ColorAsserts, unittest.TestCase):
     """Test CAM16 JMh properties."""
 
     def test_names(self):
         """Test LCh-ish names."""
 
-        self.assertEqual(Color('color(--cam16-jmh 97.139 75.504 111.05)')._space.lchish_names(), ('j', 'm', 'h'))
+        self.assertEqual(Color('color(--cam16-jmh 97.139 75.504 111.05)')._space.names(), ('j', 'm', 'h'))
 
     def test_h(self):
         """Test `h`."""
@@ -99,11 +85,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('cam16-jmh')._space.achromatic_hue(), 209.5333344635329)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -119,13 +100,13 @@ def test_none_input(self):
     def test_null_normalization_min_chroma(self):
         """Test minimum saturation."""
 
-        c = Color('color(--cam16-jmh 740 0.03 90 / 1)').normalize()
+        c = Color(Color('white').convert('cam16-jmh').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
-        c = Color('color(--cam16-jmh 30 0.03 90 / 1)').normalize()
+        c = Color(Color('gray').convert('cam16-jmh').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
-        c = Color('color(--cam16-jmh 1 0.03 30)').normalize()
+        c = Color(Color('darkgray').convert('cam16-jmh').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
     def test_achromatic_hue(self):
@@ -136,3 +117,88 @@ def test_achromatic_hue(self):
                 color = Color('color({space} {num:f} {num:f} {num:f})'.format(space=space, num=x / 255))
                 color2 = color.convert('cam16-jmh')
                 self.assertTrue(color2.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#222222').convert('cam16-jmh').is_achromatic(), True)
+        self.assertEqual(
+            Color('srgb', [0.000000001] * 3).convert('cam16-jmh').set('m', lambda x: x + 1e-8).is_achromatic(),
+            True
+        )
+        self.assertEqual(Color('cam16-jmh', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-jmh', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-jmh', [0, 50, 270]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-jmh', [NaN, 50, 270]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-jmh', [20, NaN, 270]).is_achromatic(), False)
+        self.assertEqual(Color('cam16-jmh', [NaN, NaN, 270]).is_achromatic(), True)
+
+
+class TestCAM16ApperanceModel(util.ColorAsserts, unittest.TestCase):
+    """Test CAM16 appearance model."""
+
+    COORDS = CAM16Coords(
+        45.33435136131785, 45.26195932727762, 258.92464993097565,
+        62.67686398624793, 83.29355481993107, 32.720950777696196, 310.5279473979526
+    )
+
+    def test_no_lightness(self):
+        """Test conversion failure when no equivalent lightness."""
+
+        with self.assertRaises(ValueError):
+            cam16_to_xyz_d65(C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV)
+
+    def test_no_chroma(self):
+        """Test conversion failure when no equivalent chroma."""
+
+        with self.assertRaises(ValueError):
+            cam16_to_xyz_d65(J=self.COORDS.J, h=self.COORDS.h, env=CAM16JMh.ENV)
+
+    def test_no_hue(self):
+        """Test conversion failure when no equivalent hue."""
+
+        with self.assertRaises(ValueError):
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, env=CAM16JMh.ENV)
+
+    def test_no_environment(self):
+        """Test no test no environment."""
+
+        with self.assertRaises(ValueError):
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h)
+
+    def test_lightness_convert(self):
+        """Test convert lightness."""
+
+        for a, b in zip(
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV),
+            cam16_to_xyz_d65(Q=self.COORDS.Q, C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+    def test_chroma_convert(self):
+        """Test convert chroma."""
+
+        for a, b in zip(
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV),
+            cam16_to_xyz_d65(Q=self.COORDS.Q, s=self.COORDS.s, h=self.COORDS.h, env=CAM16JMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+        for a, b in zip(
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV),
+            cam16_to_xyz_d65(Q=self.COORDS.Q, M=self.COORDS.M, h=self.COORDS.h, env=CAM16JMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
+
+    def test_hue_convert(self):
+        """Test convert hue."""
+
+        for a, b in zip(
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, h=self.COORDS.h, env=CAM16JMh.ENV),
+            cam16_to_xyz_d65(J=self.COORDS.J, C=self.COORDS.C, H=self.COORDS.H, env=CAM16JMh.ENV)
+        ):
+            self.assertCompare(a, b, 14)
diff --git a/tests/test_cam16_ucs.py b/tests/test_cam16_ucs.py
index 403ad2e76..4a21d1331 100644
--- a/tests/test_cam16_ucs.py
+++ b/tests/test_cam16_ucs.py
@@ -1,7 +1,7 @@
 """Test CAM16 UCS."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -279,3 +279,19 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#222222').convert('cam16').is_achromatic(), True)
+        self.assertEqual(Color('srgb', [0.000000001] * 3).convert('cam16-ucs').set('j', NaN).is_achromatic(), True)
+        self.assertEqual(Color('cam16-ucs', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-ucs', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-ucs', [0, 3, -4]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-ucs', [NaN, 0, -3]).is_achromatic(), True)
+        self.assertEqual(Color('cam16-ucs', [30, NaN, 0]).is_achromatic(), False)
+        self.assertEqual(Color('cam16-ucs', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_cmy.py b/tests/test_cmy.py
index daedbd8d9..6cd695b8e 100644
--- a/tests/test_cmy.py
+++ b/tests/test_cmy.py
@@ -1,7 +1,7 @@
 """Test CMY."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -95,3 +95,15 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('cmy', [0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('cmy', [0.3, 0.3, 0.30000001]).is_achromatic(), True)
+        self.assertEqual(Color('cmy', [0.3, 0.4, 0.3]).is_achromatic(), False)
+        self.assertEqual(Color('cmy', [0.3, 0.3, NaN]).is_achromatic(), False)
diff --git a/tests/test_cmyk.py b/tests/test_cmyk.py
index 831903423..59eb12405 100644
--- a/tests/test_cmyk.py
+++ b/tests/test_cmyk.py
@@ -1,7 +1,7 @@
 """Test CMYK."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -103,3 +103,18 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('cmyk', [0.3, 0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('cmyk', [0.3000001, 0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('cmyk', [0.3, 0.3, 0.3, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('cmyk', [0.4, 0.3, 0.3, 1.0]).is_achromatic(), True)
+        self.assertEqual(Color('cmyk', [NaN, 0.3, 0.0, 1.0]).is_achromatic(), True)
+        self.assertEqual(Color('cmyk', [0.4, 0.3, 0.3, 0.3]).is_achromatic(), False)
+        self.assertEqual(Color('cmyk', [NaN, 0.3, 0.3, 0.3]).is_achromatic(), False)
diff --git a/tests/test_compositing.py b/tests/test_compositing.py
index aac50eede..884b4ef8d 100644
--- a/tests/test_compositing.py
+++ b/tests/test_compositing.py
@@ -100,7 +100,7 @@ def test_compose_bad_space(self):
 
         c1 = Color('blue').set('alpha', 0.5)
         c2 = Color('yellow')
-        with self.assertRaises(ValueError):
+        with self.assertRaises(KeyError):
             c1.compose(c2, space="bad")
 
     def test_compose_no_alpha(self):
@@ -135,4 +135,5 @@ def test_compose_cyl(self):
 
         c1 = Color('blue').set('alpha', 0.5)
         c2 = Color('yellow')
-        self.assertEqual(c1.compose(c2, space="hsl"), Color('color(srgb 0 1 0.5)'))
+        with self.assertRaises(ValueError):
+            c1.compose(c2, space="hsl")
diff --git a/tests/test_cvd.py b/tests/test_cvd.py
index 97780de32..a08a4deac 100644
--- a/tests/test_cvd.py
+++ b/tests/test_cvd.py
@@ -116,12 +116,12 @@ class TestCVD(util.ColorAssertsPyTest):
             ('red', 'rgb(146.57 84.574 10.552)', 'protan', 'vienot', 0.8),
             ('red', 'rgb(123.88 89.533 11.788)', 'protan', 'vienot', 0.9),
 
-            ('red', Color('red').filter('tritan'), 'tritan', 'brettel', 1),
-            ('red', Color('red').filter('protan'), 'protan', 'vienot', 1),
-            ('red', Color('red').filter('deutan'), 'deutan', 'vienot', 1),
-            ('red', Color('red').filter('tritan', 0.5), 'tritan', 'brettel', 0.5),
-            ('red', Color('red').filter('protan', 0.5), 'protan', 'machado', 0.5),
-            ('red', Color('red').filter('deutan', 0.5), 'deutan', 'machado', 0.5)
+            ('red', Color('red').filter('tritan', out_space='srgb'), 'tritan', 'brettel', 1),
+            ('red', Color('red').filter('protan', out_space='srgb'), 'protan', 'vienot', 1),
+            ('red', Color('red').filter('deutan', out_space='srgb'), 'deutan', 'vienot', 1),
+            ('red', Color('red').filter('tritan', 0.5, out_space='srgb'), 'tritan', 'brettel', 0.5),
+            ('red', Color('red').filter('protan', 0.5, out_space='srgb'), 'protan', 'machado', 0.5),
+            ('red', Color('red').filter('deutan', 0.5, out_space='srgb'), 'deutan', 'machado', 0.5)
         ]
     )
     def test_cvd(self, color1, color2, deficiency, method, severity):
@@ -130,7 +130,7 @@ def test_cvd(self, color1, color2, deficiency, method, severity):
         print('color1: ', color1)
         print('color2: ', color2)
         self.assertColorEqual(
-            Color(color1).filter(deficiency, severity, method=method),
+            Color(color1).filter(deficiency, severity, out_space='srgb', method=method),
             Color(color2) if isinstance(color2, str) else color2
         )
 
diff --git a/tests/test_distance.py b/tests/test_distance.py
index fc1877625..a8b38218b 100644
--- a/tests/test_distance.py
+++ b/tests/test_distance.py
@@ -491,9 +491,9 @@ def test_euclidean_different_space(self, color1, color2, value):
             ('red', 'blue', 85.0841),
             ('red', 'indigo', 68.2811),
             ('red', 'violet', 49.9396),
-            ('red', 'white', 71.0854),
+            ('red', 'white', 75.1061),
             ('red', 'black', 77.2615),
-            ('red', 'gray', 54.351),
+            ('red', 'gray', 57.85),
             ('red', 'red', 0),
             ('orange', 'red', 44.506),
             ('yellow', 'red', 80.2846),
@@ -501,9 +501,9 @@ def test_euclidean_different_space(self, color1, color2, value):
             ('blue', 'red', 85.0841),
             ('indigo', 'red', 68.2811),
             ('violet', 'red', 49.9396),
-            ('white', 'red', 71.0854),
+            ('white', 'red', 75.1061),
             ('black', 'red', 77.2615),
-            ('gray', 'red', 54.351)
+            ('gray', 'red', 57.85)
         ]
     )
     def test_delta_e_hct(self, color1, color2, value):
diff --git a/tests/test_filters.py b/tests/test_filters.py
index e00f62f7c..cc510de70 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -68,7 +68,7 @@ def test_filter(self, color1, color2, name, amount):
         print('color1: ', color1)
         print('color2: ', color2)
         self.assertColorEqual(
-            Color(color1).filter(name, amount),
+            Color(color1).filter(name, amount, out_space='srgb'),
             Color(color2) if isinstance(color2, str) else color2
         )
 
@@ -86,7 +86,7 @@ def test_bad_space(self):
         """Test a bad input space."""
 
         with self.assertRaises(ValueError):
-            Color('red').filter('sepia', space='display-p3')
+            Color('red').filter('sepia', space='display-p3', out_space='srgb')
 
     def test_bad_filter(self):
         """Test bad filter."""
diff --git a/tests/test_harmonies.py b/tests/test_harmonies.py
index a931db0ea..78e2078d7 100644
--- a/tests/test_harmonies.py
+++ b/tests/test_harmonies.py
@@ -82,14 +82,14 @@ class TestHarmonies(util.ColorAssertsPyTest):
                 'analogous',
                 ['rgb(255 0 0)',
                  'rgb(239.47 70.306 -85.526)',
-                 'rgb(245.33 -9.3969 132.34)'],
+                 'rgb(245.33 -9.3968 132.34)'],
                 None
             ),
             (
                 'red',
                 'triad',
                 ['rgb(255 0 0)',
-                 'rgb(-90.834 173.97 -5.7495)',
+                 'rgb(-90.834 173.97 -5.7497)',
                  'rgb(78.547 111.49 288.59)'],
                 None
             ),
@@ -123,13 +123,13 @@ class TestHarmonies(util.ColorAssertsPyTest):
     def test_harmonies(self, color, name, colors, space):
         """Test color harmonies."""
 
-        results = Color(color).harmony(name, space=space)
+        results = Color(color).harmony(name, space=space, out_space='srgb')
         for c1, c2 in zipl(results, [Color(c) for c in colors]):
             self.assertColorEqual(c1, c2)
 
 
 class TestHarmonyError(util.ColorAsserts, unittest.TestCase):
-    """Test filter miscellaneous cases."""
+    """Test harmony errors."""
 
     def test_bad_space(self):
         """Test using a harmony with sRGB."""
diff --git a/tests/test_hct.py b/tests/test_hct.py
index 914a8014e..e45ab8c5e 100644
--- a/tests/test_hct.py
+++ b/tests/test_hct.py
@@ -16,9 +16,9 @@ class TestHCT(util.ColorAssertsPyTest):
         ('blue', 'color(--hct 282.76 87.228 32.301)'),
         ('indigo', 'color(--hct 310.96 60.765 20.47)'),
         ('violet', 'color(--hct 331.49 65.001 69.695)'),
-        ('white', 'color(--hct 0 2.8716 100)'),
-        ('gray', 'color(--hct 0 1.8977 53.585)'),
-        ('black', 'color(--hct 0 0 0)'),
+        ('white', 'color(--hct 209.54 2.8716 100)'),
+        ('gray', 'color(--hct 209.54 1.8977 53.585)'),
+        ('black', 'color(--hct 209.55 0 0)'),
         # Test color
         ('color(--hct 270 30 100)', 'color(--hct 270 30 100)'),
         ('color(--hct 270 30 100 / 0.5)', 'color(--hct 270 30 100 / 0.5)'),
@@ -67,7 +67,7 @@ class TestHCTPoperties(util.ColorAsserts, unittest.TestCase):
     def test_names(self):
         """Test LCh-ish names."""
 
-        self.assertEqual(Color('color(--hct 111.05 75.504 97.139)')._space.lchish_names(), ('t', 'c', 'h'))
+        self.assertEqual(Color('color(--hct 111.05 75.504 97.139)')._space.names(), ('t', 'c', 'h'))
 
     def test_h(self):
         """Test `h`."""
@@ -105,11 +105,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hct')._space.achromatic_hue(), 209.5429359788321)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -125,13 +120,13 @@ def test_none_input(self):
     def test_null_normalization_min_chroma(self):
         """Test minimum saturation."""
 
-        c = Color('color(--hct 90 0.05 30 / 1)').normalize()
+        c = Color(Color('white').convert('hct').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
-        c = Color('color(--hct 30 0.05 7)').normalize()
+        c = Color(Color('gray').convert('hct').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
-        c = Color('color(--hct 30 0.05 1)').normalize()
+        c = Color(Color('darkgray').convert('hct').to_string()).normalize()
         self.assertTrue(c.is_nan('hue'))
 
     def test_achromatic_hue(self):
@@ -142,3 +137,22 @@ def test_achromatic_hue(self):
                 color = Color('color({space} {num:f} {num:f} {num:f})'.format(space=space, num=x / 255))
                 color2 = color.convert('hct')
                 self.assertTrue(color2.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#222222').convert('hct').is_achromatic(), True)
+        self.assertEqual(
+            Color('srgb', [0.000000001] * 3).convert('hct').set('c', lambda x: x + 1e-8).is_achromatic(),
+            True
+        )
+        self.assertEqual(Color('hct', [270, 0.00001, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hct', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hct', [270, 50, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hct', [270, 50, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hct', [270, NaN, 20]).is_achromatic(), False)
+        self.assertEqual(Color('hct', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_hsi.py b/tests/test_hsi.py
index c45e2e8ff..069bda0b2 100644
--- a/tests/test_hsi.py
+++ b/tests/test_hsi.py
@@ -65,6 +65,11 @@ def test_colors(self, color1, options, color2):
 class TestHSIPoperties(util.ColorAsserts, unittest.TestCase):
     """Test HSI."""
 
+    def test_names(self):
+        """Test HSV-ish names."""
+
+        self.assertEqual(Color('color(--hsi 60 1 0.66667)')._space.names(), ('h', 's', 'i'))
+
     def test_h(self):
         """Test `h`."""
 
@@ -101,11 +106,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hsi')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
diff --git a/tests/test_hsl.py b/tests/test_hsl.py
index 5d05ed497..929df8fee 100644
--- a/tests/test_hsl.py
+++ b/tests/test_hsl.py
@@ -123,6 +123,11 @@ def test_colors(self, color1, options, color2):
 class TestHSLProperties(util.ColorAsserts, unittest.TestCase):
     """Test HSL."""
 
+    def test_names(self):
+        """Test HSL-ish names."""
+
+        self.assertEqual(Color('color(--hsl 120 50% 90% / 1)')._space.names(), ('h', 's', 'l'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -159,11 +164,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hsl')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -199,3 +199,22 @@ def test_corner_case_null(self):
 
         c = Color('color(srgb -2 0 2)').convert('hsl')
         self.assertTrue(c.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('hsl', [270, 0.5, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0.5, 1]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0.000001, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0.5, 0.9999999]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, NaN, 1]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0.0, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, 0.5, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, NaN, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hsl', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_hsluv/test_hpluv.py b/tests/test_hsluv/test_hpluv.py
index b891bf6e8..b4aacaed4 100644
--- a/tests/test_hsluv/test_hpluv.py
+++ b/tests/test_hsluv/test_hpluv.py
@@ -55,6 +55,11 @@ def test_colors(self, color1, options, color2):
 class TestHPluvProperties(util.ColorAsserts, unittest.TestCase):
     """Test HPLuv."""
 
+    def test_names(self):
+        """Test HSL-ish names."""
+
+        self.assertEqual(Color('color(--hpluv 270 0% 0.75 / 1)')._space.names(), ('h', 'p', 'l'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -91,11 +96,6 @@ def test_alpha(self):
 class TestHPLuvNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hpluv')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -125,3 +125,22 @@ def test_null_normalization_min_light(self):
 
         c = Color('color(--hpluv 270 20% 0% / 1)').normalize()
         self.assertTrue(c.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('hpluv', [270, 50, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 50, 100]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 0, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 0.000001, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 50, 99.99999999]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, NaN, 100]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 0.0, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, 50, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, NaN, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hpluv', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_hsluv/test_hsluv.py b/tests/test_hsluv/test_hsluv.py
index b8914dd15..49ae6319a 100644
--- a/tests/test_hsluv/test_hsluv.py
+++ b/tests/test_hsluv/test_hsluv.py
@@ -65,6 +65,11 @@ def test_colors(self, color1, options, color2):
 class TestHSluvProperties(util.ColorAsserts, unittest.TestCase):
     """Test HSLuv."""
 
+    def test_names(self):
+        """Test HSL-ish names."""
+
+        self.assertEqual(Color('color(--hsluv none 0% 75% / 1)')._space.names(), ('h', 's', 'l'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -101,11 +106,6 @@ def test_alpha(self):
 class TestHSLuvNulls(util.ColorAsserts, unittest.TestCase):
     """Test HSLuv Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hsluv')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -135,3 +135,22 @@ def test_null_normalization_min_light(self):
 
         c = Color('color(--hsluv 270 20% 0% / 1)').normalize()
         self.assertTrue(c.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('hsluv', [270, 50, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 50, 100]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 0, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 0.000001, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 50, 99.99999999]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, NaN, 100]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 0.0, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, 50, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, NaN, 50]).is_achromatic(), True)
+        self.assertEqual(Color('hsluv', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_hsv.py b/tests/test_hsv.py
index e3fa3d418..2f2c14d7a 100644
--- a/tests/test_hsv.py
+++ b/tests/test_hsv.py
@@ -64,6 +64,11 @@ def test_colors(self, color1, options, color2):
 class TestHSVProperties(util.ColorAsserts, unittest.TestCase):
     """Test HSV."""
 
+    def test_names(self):
+        """Test HSV-ish names."""
+
+        self.assertEqual(Color('color(--hsv 120 50% 50% / 1)')._space.names(), ('h', 's', 'v'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -100,11 +105,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hsv')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -138,3 +138,20 @@ def test_from_hsl(self):
         c2 = c1.convert('hsv')
         self.assertColorEqual(c2, Color('color(--hsv 0 0% 50%)'))
         self.assertTrue(c2.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('hsv', [270, 0.5, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, 0, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, 0.000001, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, 0.0, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, 0.5, 1]).is_achromatic(), False)
+        self.assertEqual(Color('hsv', [270, NaN, 1]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, 0.5, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hsv', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_hwb.py b/tests/test_hwb.py
index a34e16a37..2a99c7603 100644
--- a/tests/test_hwb.py
+++ b/tests/test_hwb.py
@@ -121,11 +121,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('hwb')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -159,3 +154,22 @@ def test_from_hsv(self):
         c2 = c1.convert('hsv')
         self.assertColorEqual(c2, Color('color(--hsv 0 0% 50%)'))
         self.assertTrue(c2.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('hwb', [270, 1, 0]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, 0, 1]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, 0.5, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, 1, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, NaN, 1]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, 1, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, NaN, 2]).is_achromatic(), True)
+        self.assertEqual(Color('hwb', [270, 0, 0]).is_achromatic(), False)
+        self.assertEqual(Color('hwb', [270, NaN, 0]).is_achromatic(), False)
+        self.assertEqual(Color('hwb', [270, 0, NaN]).is_achromatic(), False)
+        self.assertEqual(Color('hwb', [270, NaN, NaN]).is_achromatic(), False)
diff --git a/tests/test_ictcp.py b/tests/test_ictcp.py
index 5056a22f5..fa7a134a5 100644
--- a/tests/test_ictcp.py
+++ b/tests/test_ictcp.py
@@ -100,4 +100,4 @@ def test_labish_names(self):
         """Test `labish_names`."""
 
         c = Color('color(--ictcp 1 0.2 -0.3 / 1)')
-        self.assertEqual(c._space.labish_names(), ('i', 'cp', 'ct'))
+        self.assertEqual(c._space.names(), ('i', 'cp', 'ct'))
diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py
index 1c073e198..94631241c 100644
--- a/tests/test_interpolation.py
+++ b/tests/test_interpolation.py
@@ -3,6 +3,85 @@
 from coloraide.everything import ColorAll as Color
 from coloraide import NaN, stop, hint, ease_in
 from . import util
+import pytest
+
+
+@pytest.mark.skipif(True, reason='Experimental')
+class TestCarryFoward(util.ColorAssertsPyTest):
+    """Test carry forward."""
+
+    CASES = [
+        [
+            'oklab',
+            4,
+            ['lch(none 30 270)', 'lab(none -2 12)', 'hsl(30 80 none)', 'oklab(0.5 0.3 -0.1)'],
+            ('l', 0.5)
+        ],
+        [
+            'oklch',
+            5,
+            [
+                'lch(40 none 270)', 'hsl(30 none 75)', 'color(--hsv 300 none 0.5)',
+                'color(--hsi 90 none 0.2)', 'color(--oklch 0.5 0.2 30)'],
+            ('c', 0.2)
+        ],
+        [
+            'hsl',
+            5,
+            [
+                'lch(40 none 270)', 'color(--hsv 300 none 0.5)', 'color(--hsi 90 none 0.2)',
+                'color(--oklch 0.5 none 30)', 'hsl(30 30 75)'],
+            ('s', 0.3)
+        ],
+        [
+            'hsv',
+            5,
+            [
+                'lch(40 none 270)', 'hsl(30 none 75)', 'color(--hsi 90 none 0.2)',
+                'color(--oklch 0.5 none 30)', 'color(--hsv 300 0.3 0.5)'],
+            ('s', 0.3)
+        ],
+        [
+            'hsl',
+            6,
+            [
+                'lch(40 10 none)', 'color(--hsv none 0.1 0.5)', 'hwb(none 30 75)',
+                'color(--hsi none 0.1 0.2)', 'color(--oklch 0.5 0.1 none)', 'hsl(30 30 75)'],
+            ('h', 30)
+        ],
+        [
+            'hwb',
+            6,
+            [
+                'lch(40 10 none)', 'color(--hsv none 0.1 0.5)', 'hsl(none 30 75)',
+                'color(--hsi none 0.1 0.2)', 'color(--oklch 0.5 0.1 none)', 'hwb(30 30 75)'],
+            ('h', 30)
+        ],
+        [
+            'oklab',
+            3,
+            [
+                'lch(40 10 270 / none)', 'rgb(220 0 47 / none)', 'hsl(30 30 75 / 0.5)',
+            ],
+            ('alpha', 0.5)
+        ],
+        [
+            'srgb',
+            3,
+            [
+                'color(xyz-d65 0.24 0.34 none)', 'color(display-p3 0 1 none)', 'rgb(30 30 75)'
+            ],
+            ('blue', 75 / 255)
+        ]
+    ]
+
+    @pytest.mark.parametrize('space, steps, colors, cmp', CASES)
+    def test_round_trip(self, space, steps, colors, cmp):
+        """Test round trip."""
+
+        results = Color.steps(colors, steps=steps, space=space, method='monotone', _carryforward=True)
+        if not all([abs(r[cmp[0]] - cmp[1]) < 1e-6 for r in results]):
+            assert False, "{} != {} : {}".format(cmp[0], cmp[1], results)
 
 
 class TestInterpolation(util.ColorAsserts, unittest.TestCase):
@@ -13,21 +92,21 @@ def test_domain(self):
 
         i = Color.interpolate(
             ['blue', 'green', 'yellow', 'orange', 'red'],
-            domain=[-32, 32, 60, 85, 95]
+            domain=[-32, 32, 60, 85, 95],
         )
 
-        self.assertColorEqual(i(-32), Color('blue'))
-        self.assertColorEqual(i(35), Color('rgb(52.558 141.25 0.01039)'))
-        self.assertColorEqual(i(60), Color('yellow'))
-        self.assertColorEqual(i(79), Color('rgb(256.86 187 0.02703)'))
-        self.assertColorEqual(i(95), Color('red'))
+        self.assertColorEqual(i(-32), Color('oklab(0.45201 -0.03246 -0.31153)'))
+        self.assertColorEqual(i(35), Color('oklab(0.56778 -0.13292 0.11741)'))
+        self.assertColorEqual(i(60), Color('oklab(0.96798 -0.07137 0.19857)'))
+        self.assertColorEqual(i(79), Color('oklab(0.83476 0.0259 0.17031)'))
+        self.assertColorEqual(i(95), Color('oklab(0.62796 0.22486 0.12585)'))
 
     def test_domain_extrapolation(self):
         """Test extrapolation with custom domain."""
 
         i = Color.interpolate(['red', 'blue'], extrapolate=True, domain=[-25, 25])
-        self.assertColorEqual(i(-30), Color('rgb(277.88 -56.723 -61.634)'))
-        self.assertColorEqual(i(30), Color('rgb(-22.838 -53.038 273.02)'))
+        self.assertColorEqual(i(-30), Color('oklab(0.64555 0.2506 0.16958)'))
+        self.assertColorEqual(i(30), Color('oklab(0.43442 -0.05819 -0.35527)'))
 
     def test_domain_of_one(self):
         """
@@ -41,10 +120,10 @@ def test_domain_of_one(self):
             domain=[1]
         )
 
-        self.assertColorEqual(i(-1), Color('blue'))
-        self.assertColorEqual(i(0), Color('blue'))
-        self.assertColorEqual(i(1), Color('blue'))
-        self.assertColorEqual(i(2), Color('red'))
+        self.assertColorEqual(i(-1), Color('oklab(0.45201 -0.03246 -0.31153)'))
+        self.assertColorEqual(i(0), Color('oklab(0.45201 -0.03246 -0.31153)'))
+        self.assertColorEqual(i(1), Color('oklab(0.45201 -0.03246 -0.31153)'))
+        self.assertColorEqual(i(2), Color('oklab(0.62796 0.22486 0.12585)'))
 
     def test_domain_in_step(self):
         """Test domains work in steps."""
@@ -55,11 +134,11 @@ def test_domain_in_step(self):
             domain=[-32, 32, 60, 85, 95]
         )
 
-        self.assertColorEqual(steps[0], Color('blue'))
-        self.assertColorEqual(steps[3], Color('rgb(-53.051 112.52 135.23)'))
-        self.assertColorEqual(steps[5], Color('rgb(-3.6056 127.82 10.348)'))
-        self.assertColorEqual(steps[7], Color('rgb(230.77 240.55 0.01746)'))
-        self.assertColorEqual(steps[10], Color('red'))
+        self.assertColorEqual(steps[0], Color('oklab(0.45201 -0.03246 -0.31153)'))
+        self.assertColorEqual(steps[3], Color('oklab(0.49234 -0.09666 -0.06197)'))
+        self.assertColorEqual(steps[5], Color('oklab(0.51922 -0.13946 0.1044)'))
+        self.assertColorEqual(steps[7], Color('oklab(0.91836 -0.079 0.18851)'))
+        self.assertColorEqual(steps[10], Color('oklab(0.62796 0.22486 0.12585)'))
 
     def test_domain_mix(self):
         """Test domains in mix."""
@@ -72,11 +151,11 @@ def test_domain_mix(self):
     def test_mix(self):
         """Test interpolation via mixing."""
 
-        self.assertColorEqual(Color('red').mix('blue', 1), Color("rgb(0 0 255)"))
-        self.assertColorEqual(Color('red').mix('blue', 0.75), Color("rgb(80.686 71.104 209.56)"))
-        self.assertColorEqual(Color('red').mix('blue'), Color("rgb(140.36 83.033 162.31)"))
-        self.assertColorEqual(Color('red').mix('blue', 0.25), Color("rgb(197.88 73.02 108.95)"))
-        self.assertColorEqual(Color('red').mix('blue', 0.0), Color("rgb(255 0 0)"))
+        self.assertColorEqual(Color('red').mix('blue', 1), Color("color(--oklab 0.45201 -0.03246 -0.31153 / 1)"))
+        self.assertColorEqual(Color('red').mix('blue', 0.75), Color("color(--oklab 0.496 0.03187 -0.20218 / 1)"))
+        self.assertColorEqual(Color('red').mix('blue'), Color("color(--oklab 0.53998 0.0962 -0.09284 / 1)"))
+        self.assertColorEqual(Color('red').mix('blue', 0.25), Color("color(--oklab 0.58397 0.16053 0.0165 / 1)"))
+        self.assertColorEqual(Color('red').mix('blue', 0.0), Color("color(--oklab 0.62796 0.22486 0.12585 / 1)"))
 
     def test_mix_dict(self):
         """Test mixing with a mapping."""
@@ -251,23 +330,23 @@ def test_mix_hue_adjust(self):
         c2 = Color('lch(85% 100 805)')
         self.assertColorEqual(
             c1.mix(c2.mask("hue", invert=True), 0.25, hue="shorter", space="lch"),
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             c1.mix(c2.mask("hue", invert=True), 0.25, hue="longer", space="lch"),
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             c1.mix(c2.mask("hue", invert=True), 0.25, hue="increasing", space="lch"),
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             c1.mix(c2.mask("hue", invert=True), 0.25, hue="decreasing", space="lch"),
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             c1.mix(c2.mask("hue", invert=True), 0.25, hue="specified", space="lch"),
-            Color("rgb(112.83 63.969 -28.821)")
+            Color("lch(32.393 61.244 432.89)")
         )
 
     def test_hue_shorter_cases(self):
@@ -387,14 +466,6 @@ def test_mix_progress(self):
             results[0]
         )
 
-    def test_interpolate_fit_required(self):
-        """Test interpolation case that requires fitting."""
-
-        self.assertColorEqual(
-            Color.interpolate(['color(display-p3 0 1 1)', 'color(display-p3 0 0 1)'], space='hsl')(0.5),
-            Color('color(display-p3 0.21779 0.49774 0.96566)')
-        )
-
     def test_interpolate(self):
         """Test interpolation."""
 
@@ -428,7 +499,7 @@ def test_interpolate_channel(self):
 
         self.assertColorEqual(
             Color.interpolate(['red', Color('blue').set('alpha', 0)], progress={'alpha': lambda t: t ** 3})(0.5),
-            Color('rgb(119.63 0 0 / 0.875)')
+            Color('oklab(0.35883 0.12849 0.07191 / 0.875)')
         )
 
     def test_interpolate_channel_bspline(self):
@@ -438,7 +509,7 @@ def test_interpolate_channel_bspline(self):
             Color.interpolate(
                 ['red', Color('blue').set('alpha', 0)], progress={'alpha': lambda t: t ** 3}, method='bspline'
             )(0.5),
-            Color('rgb(119.63 0 0 / 0.875)')
+            Color('oklab(0.35883 0.12849 0.07191 / 0.875)')
         )
 
     def test_interpolate_easing_inline(self):
@@ -446,7 +517,7 @@ def test_interpolate_easing_inline(self):
 
         self.assertColorEqual(
             Color.interpolate(['red', lambda t: t ** 3, 'blue'])(0.5),
-            Color('rgb(226.44 55.886 74.779)')
+            Color('oklab(0.60596 0.1927 0.07117)')
         )
 
     def test_interpolate_color_hint(self):
@@ -454,7 +525,7 @@ def test_interpolate_color_hint(self):
 
         self.assertColorEqual(
             Color.interpolate(['red', hint(0.75), 'blue'])(0.5),
-            Color('rgb(212 66.119 93.278)')
+            Color('oklab(0.59484 0.17643 0.04352)')
         )
 
     def test_interpolate_channel_all(self):
@@ -521,15 +592,15 @@ def test_interpolate_stop_bspline(self):
     def test_interpolate_space(self):
         """Test color mix in different space."""
 
-        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(1), Color("rgb(0 0 255)"))
+        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(1), Color("lab(29.568 68.287 -112.03)"))
         self.assertColorEqual(
-            Color.interpolate(['red', 'blue'], space='lab')(0.75), Color("rgb(144.85 -24.864 194.36)")
+            Color.interpolate(['red', 'blue'], space='lab')(0.75), Color("lab(35.749 71.417 -66.55)")
         )
-        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(0.5), Color("rgb(192.99 -29.503 136.17)"))
+        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(0.5), Color("lab(41.929 74.546 -21.069)"))
         self.assertColorEqual(
-            Color.interpolate(['red', 'blue'], space='lab')(0.25), Color("rgb(226.89 -24.304 79.188)")
+            Color.interpolate(['red', 'blue'], space='lab')(0.25), Color("lab(48.11 77.676 24.411)")
         )
-        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(0), Color("rgb(255 0 0)"))
+        self.assertColorEqual(Color.interpolate(['red', 'blue'], space='lab')(0), Color("lab(54.291 80.805 69.891)"))
 
     def test_interpolate_empty_list(self):
         """Test interpolate with empty list."""
@@ -541,85 +612,85 @@ def test_interpolate_piecewise(self):
         """Test multiple inputs for interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black'])
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(266.95 161.01 145.22)'))
-        self.assertColorEqual(func(0.5), Color('red'))
-        self.assertColorEqual(func(0.75), Color('rgb(99.086 0 0)'))
-        self.assertColorEqual(func(1), Color('black'))
-        self.assertColorEqual(func(-0.1), Color('rgb(255 255 255)'))
-        self.assertColorEqual(func(1.1), Color('rgb(0 0 0)'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.81398 0.11243 0.06292 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.62796 0.22486 0.12585 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.31398 0.11243 0.06292 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0 0 0 / 1)'))
 
     def test_interpolate_multi_bspline(self):
         """Test multiple inputs for B-spline interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black'], method='bspline')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(259.69 162.64 147.89)'))
-        self.assertColorEqual(func(0.5), Color('rgb(205.87 72.188 58.186)'))
-        self.assertColorEqual(func(0.75), Color('rgb(93.946 7.74 4.4259)'))
-        self.assertColorEqual(func(1), Color('black'))
-        self.assertColorEqual(func(-0.1), Color('rgb(255 255 255)'))
-        self.assertColorEqual(func(1.1), Color('rgb(0 0 0)'))
+        self.assertColorEqual(func(0), Color('oklab(1 0 0)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.80865 0.10306 0.05768 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.5853 0.14991 0.0839 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.30865 0.10306 0.05768 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0 0 0 / 1)'))
 
     def test_interpolate_multi_natural(self):
         """Test multiple inputs for Natural B-spline interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black'], method='natural')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(299 152.05 132.27)'))
-        self.assertColorEqual(func(0.5), Color('rgb(255 0 0)'))
-        self.assertColorEqual(func(0.75), Color('rgb(122.19 -30.589 -18.259)'))
-        self.assertColorEqual(func(1), Color('black'))
-        self.assertColorEqual(func(-0.1), Color('rgb(255 255 255)'))
-        self.assertColorEqual(func(1.1), Color('rgb(0 0 0)'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.83797 0.15459 0.08652 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.62796 0.22486 0.12585 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.33797 0.15459 0.08652 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0 0 0 / 1)'))
 
     def test_interpolate_multi_natural_more(self):
         """Test more inputs for Natural B-spline interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black', 'purple'], method='natural')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(303.04 93.841 78.947)'))
-        self.assertColorEqual(func(0.5), Color('rgb(83.613 -20.05 -14.914)'))
-        self.assertColorEqual(func(0.75), Color('rgb(0.08759 -0.13753 0.7319)'))
-        self.assertColorEqual(func(1), Color('purple'))
-        self.assertColorEqual(func(-0.1), Color('white'))
-        self.assertColorEqual(func(1.1), Color('purple'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.7663 0.21652 0.11694 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.25451 0.11694 0.07997 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.00785 -0.00275 -0.03301 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0.42091 0.1647 -0.10147 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0.42091 0.1647 -0.10147 / 1)'))
 
     def test_interpolate_multi_natural_even_more(self):
         """Test even more inputs for Natural B-spline interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black', 'purple', 'green'], method='natural')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('red'))
-        self.assertColorEqual(func(0.5), Color('black'))
-        self.assertColorEqual(func(0.75), Color('purple'))
-        self.assertColorEqual(func(1), Color('green'))
-        self.assertColorEqual(func(-0.1), Color('white'))
-        self.assertColorEqual(func(1.1), Color('green'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.62796 0.22486 0.12585 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.42091 0.1647 -0.10147 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0.51975 -0.1403 0.10768 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0.51975 -0.1403 0.10768 / 1)'))
 
     def test_interpolate_multi_catmull_rom(self):
         """Test multiple inputs for Catmull-Rom spline interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black'], method='catrom')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(288.41 155.36 136.77)'))
-        self.assertColorEqual(func(0.5), Color('rgb(255 0 0)'))
-        self.assertColorEqual(func(0.75), Color('rgb(114.48 -22.696 -13.176)'))
-        self.assertColorEqual(func(1), Color('black'))
-        self.assertColorEqual(func(-0.1), Color('rgb(255 255 255)'))
-        self.assertColorEqual(func(1.1), Color('rgb(0 0 0)'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.82997 0.14054 0.07865 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.62796 0.22486 0.12585 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.32997 0.14054 0.07865 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0 0 0 / 1)'))
 
     def test_interpolate_multi_monotone(self):
         """Test multiple inputs for monotone interpolation."""
 
         func = Color.interpolate(['white', 'red', 'black'], method='monotone')
-        self.assertColorEqual(func(0), Color('white'))
-        self.assertColorEqual(func(0.25), Color('rgb(288.41 155.36 136.77)'))
-        self.assertColorEqual(func(0.5), Color('rgb(255 0 0)'))
-        self.assertColorEqual(func(0.75), Color('rgb(114.48 -22.696 -13.176)'))
-        self.assertColorEqual(func(1), Color('black'))
-        self.assertColorEqual(func(-0.1), Color('rgb(255 255 255)'))
-        self.assertColorEqual(func(1.1), Color('rgb(0 0 0)'))
+        self.assertColorEqual(func(0), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(0.25), Color('color(--oklab 0.82997 0.14054 0.07865 / 1)'))
+        self.assertColorEqual(func(0.5), Color('color(--oklab 0.62796 0.22486 0.12585 / 1)'))
+        self.assertColorEqual(func(0.75), Color('color(--oklab 0.32997 0.14054 0.07865 / 1)'))
+        self.assertColorEqual(func(1), Color('color(--oklab 0 0 0 / 1)'))
+        self.assertColorEqual(func(-0.1), Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(func(1.1), Color('color(--oklab 0 0 0 / 1)'))
 
     def test_interpolate_out_space(self):
         """Test interpolation."""
@@ -711,23 +782,23 @@ def test_interpolate_hue_adjust(self):
         c2 = Color('lch(85% 100 805)')
         self.assertColorEqual(
             Color.interpolate([c1, c2.mask("hue", invert=True)], hue="shorter", space="lch")(0.25),
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             Color.interpolate([c1, c2.mask("hue", invert=True)], hue="longer", space="lch")(0.25),
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             Color.interpolate([c1, c2.mask("hue", invert=True)], hue="increasing", space="lch")(0.25),
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             Color.interpolate([c1, c2.mask("hue", invert=True)], hue="decreasing", space="lch")(0.25),
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             Color.interpolate([c1, c2.mask("hue", invert=True)], hue="specified", space="lch")(0.25),
-            Color("rgb(112.83 63.969 -28.821)")
+            Color("lch(32.393 61.244 432.89)")
         )
 
     def test_interpolate_progress(self):
@@ -790,9 +861,9 @@ def test_steps_multi(self):
         """Test steps with multiple color ranges."""
 
         colors = Color('white').steps(['white', 'red', 'black'], steps=3)
-        self.assertColorEqual(colors[0], Color('white'))
-        self.assertColorEqual(colors[1], Color('red'))
-        self.assertColorEqual(colors[2], Color('black'))
+        self.assertColorEqual(colors[0], Color('color(--oklab 1 0 0 / 1)'))
+        self.assertColorEqual(colors[1], Color('oklab(0.62796 0.22486 0.12585)'))
+        self.assertColorEqual(colors[2], Color('color(--oklab 0 0 0 / 1)'))
 
     def test_steps_multi_max_delta_e(self):
         """Test steps with multiple color ranges and max_delta_e."""
@@ -842,11 +913,11 @@ def test_steps_space(self):
         """Test steps different space."""
 
         colors = Color.steps(['red', 'blue'], space="lab", steps=5)
-        self.assertColorEqual(colors[4], Color("rgb(0 0 255)"))
-        self.assertColorEqual(colors[3], Color("rgb(144.85 -24.864 194.36)"))
-        self.assertColorEqual(colors[2], Color("rgb(192.99 -29.503 136.17)"))
-        self.assertColorEqual(colors[1], Color("rgb(226.89 -24.304 79.188)"))
-        self.assertColorEqual(colors[0], Color("rgb(255 0 0)"))
+        self.assertColorEqual(colors[4], Color("lab(29.568 68.287 -112.03)"))
+        self.assertColorEqual(colors[3], Color("lab(35.749 71.417 -66.55)"))
+        self.assertColorEqual(colors[2], Color("lab(41.929 74.546 -21.069)"))
+        self.assertColorEqual(colors[1], Color("lab(48.11 77.676 24.411)"))
+        self.assertColorEqual(colors[0], Color("lab(54.291 80.805 69.891)"))
 
     def test_steps_out_space(self):
         """Test steps with output in different space."""
@@ -932,7 +1003,7 @@ def test_steps_hue_adjust(self):
                 steps=5,
                 hue="shorter"
             )[1],
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             Color.steps(
@@ -941,7 +1012,7 @@ def test_steps_hue_adjust(self):
                 steps=5,
                 hue="longer"
             )[1],
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             Color.steps(
@@ -950,7 +1021,7 @@ def test_steps_hue_adjust(self):
                 steps=5,
                 hue="increasing"
             )[1],
-            Color("rgb(146.72 -3.9233 106.41)")
+            Color("lch(32.393 61.244 342.89)")
         )
         self.assertColorEqual(
             Color.steps(
@@ -959,7 +1030,7 @@ def test_steps_hue_adjust(self):
                 steps=5,
                 hue="decreasing"
             )[1],
-            Color("rgb(-86.817 87.629 170)")
+            Color("lch(32.393 61.244 252.89)")
         )
         self.assertColorEqual(
             Color.steps(
@@ -968,7 +1039,7 @@ def test_steps_hue_adjust(self):
                 steps=5,
                 hue="specified"
             )[1],
-            Color("rgb(112.83 63.969 -28.821)")
+            Color("lch(32.393 61.244 432.89)")
         )
 
     def test_steps_progress(self):
@@ -1110,18 +1181,6 @@ def test_bspline_cylindrical(self):
             Color('hsl(152.5 40.104% 44.271%)')
         )
 
-    def test_bspline_cylindrical_gamut(self):
-        """Test B-spline with a cylindrical space with at least one color out of gamut."""
-
-        self.assertColorEqual(
-            Color.interpolate(
-                ['hsl(250 50% 30%)', 'hsl(none 0% 110%)'],
-                space='hsl',
-                method='bspline'
-            )(0.75),
-            Color('hsl(250 12.5% 82.5%)')
-        )
-
     def test_bspline_undefined_alpha(self):
         """Test some cases related to undefined alpha and B-spline."""
 
diff --git a/tests/test_ipt.py b/tests/test_ipt.py
index 32c4897e7..6ab70cdc4 100644
--- a/tests/test_ipt.py
+++ b/tests/test_ipt.py
@@ -1,7 +1,7 @@
 """Test IPT."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -95,3 +95,19 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#464646').convert('ipt').is_achromatic(), True)
+        self.assertEqual(Color('ipt', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('ipt', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('ipt', [0, 0.3, -0.4]).is_achromatic(), False)
+        self.assertEqual(Color('ipt', [NaN, 0, -0.3]).is_achromatic(), False)
+        self.assertEqual(Color('ipt', [0.3, NaN, 0]).is_achromatic(), False)
+        self.assertEqual(Color('ipt', [NaN, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('ipt', [-0.5, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_jzCzhz.py b/tests/test_jzCzhz.py
index 4cb4883c4..adcd07d31 100644
--- a/tests/test_jzCzhz.py
+++ b/tests/test_jzCzhz.py
@@ -26,7 +26,7 @@ class TestJzCzhz(util.ColorAssertsPyTest):
         ('color(--jzczhz 50% 50% 50% / 50%)', 'color(--jzczhz 0.5 0.25 180 / 0.5)'),
         ('color(--jzczhz none none none / none)', 'color(--jzczhz none none none / none)'),
         # Test percent ranges
-        ('color(--jzczhz 0% 0% 0%)', 'color(--jzczhz 0 0 none)'),
+        ('color(--jzczhz 0% 0% 0%)', 'color(--jzczhz 0 0 0)'),
         ('color(--jzczhz 100% 100% 100%)', 'color(--jzczhz 1 0.5 360 / 1)'),
         ('color(--jzczhz -100% -100% -100%)', 'color(--jzczhz 0 0 -360 / 1)')
     ]
@@ -107,11 +107,6 @@ def test_hue_name(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('jzczhz')._space.achromatic_hue(), 216.0777045520467)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -124,30 +119,17 @@ def test_none_input(self):
         c = Color('color(--jzczhz 90% 0 none / 1)')
         self.assertTrue(c.is_nan('hue'))
 
-    def test_near_zero_null(self):
-        """
-        Test very near zero null.
-
-        This is a fix up to help give more sane hues
-        when chroma is very close to zero.
-        """
-
-        c = Color('color(--jzczhz 90% 0.00009 120 / 1)').convert('jzazbz').convert('jzczhz')
-        self.assertTrue(c.is_nan('hue'))
-
     def test_null_normalization_min_chroma(self):
         """Test minimum saturation."""
 
-        c = Color('color(--jzczhz 90% 0 120 / 1)').normalize()
+        c = Color(Color('white').convert('jzczhz').to_string(precision=6)).normalize()
         self.assertTrue(c.is_nan('hue'))
 
-    def test_from_jzazbz(self):
-        """Test null from Lab conversion."""
+        c = Color(Color('gray').convert('jzczhz').to_string(precision=6)).normalize()
+        self.assertTrue(c.is_nan('hue'))
 
-        c1 = Color('color(--jzazbz 90% 0 0)')
-        c2 = c1.convert('jzczhz')
-        self.assertColorEqual(c2, Color('color(--jzczhz 90% 0 0)'))
-        self.assertTrue(c2.is_nan('hue'))
+        c = Color(Color('darkgray').convert('jzczhz').to_string(precision=6)).normalize()
+        self.assertTrue(c.is_nan('hue'))
 
     def test_achromatic_hue(self):
         """Test that all RGB-ish colors convert to OkLCh with a null hue."""
@@ -168,3 +150,21 @@ def test_chorma_less_than_zero_convert(self):
         self.assertColorEqual(
             Color('color(--jzczhz 90% -10 120 / 1)').convert('jzazbz'), Color('color(--jzazbz 0.9 0 0)')
         )
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#222222').convert('jzczhz').is_achromatic(), True)
+        self.assertEqual(Color('#222222').convert('jzczhz').set('cz', lambda x: x + 1e-8).is_achromatic(), True)
+        self.assertEqual(Color('srgb', [5.2, 5.2, 5.2]).convert('jzczhz').is_achromatic(), True)
+        self.assertEqual(Color('jzczhz', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('jzczhz', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('jzczhz', [0, 0.5, 270]).is_achromatic(), True)
+        self.assertEqual(Color('pink').convert('jzczhz').is_achromatic(), False)
+        self.assertEqual(Color('jzczhz', [NaN, 0.5, 270]).is_achromatic(), True)
+        self.assertEqual(Color('jzczhz', [0.2, NaN, 270]).is_achromatic(), False)
+        self.assertEqual(Color('jzczhz', [NaN, NaN, 270]).is_achromatic(), True)
diff --git a/tests/test_jzazbz.py b/tests/test_jzazbz.py
index 2a438fdd2..774599cda 100644
--- a/tests/test_jzazbz.py
+++ b/tests/test_jzazbz.py
@@ -1,7 +1,7 @@
 """Test Jzazbz library."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -95,3 +95,19 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('#222222').convert('jzazbz').is_achromatic(), True)
+        self.assertEqual(Color('#222222').convert('jzazbz').set('jz', NaN).is_achromatic(), True)
+        self.assertEqual(Color('jzazbz', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('jzazbz', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('jzazbz', [0, 0.3, -0.4]).is_achromatic(), True)
+        self.assertEqual(Color('jzazbz', [NaN, 0, -0.3]).is_achromatic(), True)
+        self.assertEqual(Color('jzazbz', [0.3, NaN, 0]).is_achromatic(), False)
+        self.assertEqual(Color('jzazbz', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_lab.py b/tests/test_lab.py
index bcb1420b6..1dd9d1d90 100644
--- a/tests/test_lab.py
+++ b/tests/test_lab.py
@@ -1,7 +1,7 @@
 """Test Lab library."""
 import unittest
 from . import util
-from coloraide import Color
+from coloraide import Color, NaN
 import pytest
 
 
@@ -87,6 +87,16 @@ def test_colors(self, color1, options, color2):
 class TestLabProperties(util.ColorAsserts, unittest.TestCase):
     """Test Lab."""
 
+    def test_names(self):
+        """Test LCh-ish names."""
+
+        self.assertEqual(Color('color(--lab 90% 50 -20 / 1)')._space.names(), ('l', 'a', 'b'))
+
+    def test_indexes(self):
+        """Test LCh-ish names."""
+
+        self.assertEqual(Color('color(--lab 90% 50 -20 / 1)')._space.indexes(), [0, 1, 2])
+
     def test_lightness(self):
         """Test `lightness`."""
 
@@ -118,3 +128,20 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('lab', [30, 0, 0]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [30, 0.000001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [NaN, 0.00001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [0, 30, -40]).is_achromatic(), False)
+        self.assertEqual(Color('lab', [NaN, 0, -30]).is_achromatic(), False)
+        self.assertEqual(Color('lab', [30, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('lab', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_lch.py b/tests/test_lch.py
index 034d5a2b7..ef089cee2 100644
--- a/tests/test_lch.py
+++ b/tests/test_lch.py
@@ -129,11 +129,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('lch')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -188,3 +183,19 @@ def test_chorma_less_than_zero_convert(self):
         """Test handling of negative chroma when converting to lab."""
 
         self.assertColorEqual(Color('lch(90% -10 120 / 1)').convert('lab'), Color('lab(90% 0 0)'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('lch', [30, 0, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lch', [30, 0.000001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lch', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lch', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lch', [0, 100, 270]).is_achromatic(), False)
+        self.assertEqual(Color('lch', [NaN, 20, 270]).is_achromatic(), False)
+        self.assertEqual(Color('lch', [30, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lch', [NaN, NaN, 270]).is_achromatic(), True)
diff --git a/tests/test_lch99o.py b/tests/test_lch99o.py
index 2e00f4cef..94488b51c 100644
--- a/tests/test_lch99o.py
+++ b/tests/test_lch99o.py
@@ -101,11 +101,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('lch99o')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
diff --git a/tests/test_lchuv.py b/tests/test_lchuv.py
index 599512f08..ed6f860c6 100644
--- a/tests/test_lchuv.py
+++ b/tests/test_lchuv.py
@@ -101,11 +101,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('lchuv')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -160,3 +155,19 @@ def test_chorma_less_than_zero_convert(self):
         """Test handling of negative chroma when converting to lab."""
 
         self.assertColorEqual(Color('color(--lchuv 90% -10 120 / 1)').convert('luv'), Color('color(--luv 90% 0 0)'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('lchuv', [30, 0, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [30, 0.000001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [0, 100, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [NaN, 20, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [30, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('lchuv', [NaN, NaN, 270]).is_achromatic(), True)
diff --git a/tests/test_luv.py b/tests/test_luv.py
index 963f459af..3c035a55f 100644
--- a/tests/test_luv.py
+++ b/tests/test_luv.py
@@ -1,7 +1,7 @@
 """Test Luv library."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -95,3 +95,19 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('luv', [30, 0, 0]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [30, 0.000001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [NaN, 0.00001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [0, 30, -40]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [NaN, 0, -30]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [30, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('luv', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_okhsl.py b/tests/test_okhsl.py
index ef0a01070..773f5ea6c 100644
--- a/tests/test_okhsl.py
+++ b/tests/test_okhsl.py
@@ -15,9 +15,9 @@ class TestsOkhsl(util.ColorAssertsPyTest):
         ('yellow', 'color(--okhsl 109.77 1 0.9627)'),
         ('green', 'color(--okhsl 142.5 1 0.44371)'),
         ('blue', 'color(--okhsl 264.05 1 0.36657)'),
-        ('indigo', 'color(--okhsl 301.68 1.0018 0.24043)'),
-        ('violet', 'color(--okhsl 327.21 0.90624 0.7231)'),
-        ('white', 'color(--okhsl none 0 1)'),
+        ('indigo', 'color(--okhsl 301.68 1 0.24043)'),
+        ('violet', 'color(--okhsl 327.21 0.90625 0.7231)'),
+        ('white', 'color(--okhsl 0 0 1)'),
         ('gray', 'color(--okhsl none 0 0.53571)'),
         ('black', 'color(--okhsl none 0 0)'),
         # Test color
@@ -67,6 +67,11 @@ def test_colors(self, color1, options, color2):
 class TestOkhslProperties(util.ColorAsserts, unittest.TestCase):
     """Test Okhsl."""
 
+    def test_names(self):
+        """Test HSL-ish names."""
+
+        self.assertEqual(Color('color(--okhsl 120 50% 90% / 1)')._space.names(), ('h', 's', 'l'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -103,11 +108,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('okhsl')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
diff --git a/tests/test_okhsv.py b/tests/test_okhsv.py
index 582384094..8c143859c 100644
--- a/tests/test_okhsv.py
+++ b/tests/test_okhsv.py
@@ -14,9 +14,9 @@ class TestsOkhsv(util.ColorAssertsPyTest):
         ('orange', 'color(--okhsv 70.67 1 1)'),
         ('yellow', 'color(--okhsv 109.77 1 1)'),
         ('green', 'color(--okhsv 142.5 1 0.52704)'),
-        ('blue', 'color(--okhsv 264.05 0.99999 1)'),
-        ('indigo', 'color(--okhsv 301.68 1.0007 0.50334)'),
-        ('violet', 'color(--okhsv 327.21 0.60492 0.93895)'),
+        ('blue', 'color(--okhsv 264.05 1 1)'),
+        ('indigo', 'color(--okhsv 301.68 1 0.50334)'),
+        ('violet', 'color(--okhsv 327.21 0.60487 0.93895)'),
         ('white', 'color(--okhsv none 0 1)'),
         ('gray', 'color(--okhsv none 0 0.53571)'),
         ('black', 'color(--okhsv none 0 0)'),
@@ -65,6 +65,11 @@ def test_colors(self, color1, options, color2):
 class TestOkhsvProperties(util.ColorAsserts, unittest.TestCase):
     """Test Okhsv."""
 
+    def test_names(self):
+        """Test HSL-ish names."""
+
+        self.assertEqual(Color('color(--okhsv 120 50% 50% / 1)')._space.names(), ('h', 's', 'v'))
+
     def test_hue(self):
         """Test `hue`."""
 
@@ -101,11 +106,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('okhsv')._space.achromatic_hue(), 0.0)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -129,3 +129,20 @@ def test_null_normalization_min_sat(self):
 
         c = Color('color(--okhsv 270 0% 0.75 / 1)').normalize()
         self.assertTrue(c.is_nan('hue'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('okhsv', [270, 0.5, 0]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, 0, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, 0.000001, 0.5]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, 0.0, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, 0.5, 1]).is_achromatic(), False)
+        self.assertEqual(Color('okhsv', [270, NaN, 1]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, 0.5, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('okhsv', [270, NaN, NaN]).is_achromatic(), True)
diff --git a/tests/test_oklab.py b/tests/test_oklab.py
index e4d9d7263..ab3f6efff 100644
--- a/tests/test_oklab.py
+++ b/tests/test_oklab.py
@@ -1,7 +1,7 @@
 """Test Oklab library."""
 import unittest
 from . import util
-from coloraide import Color as Color
+from coloraide import Color as Color, NaN
 import pytest
 
 
@@ -116,3 +116,21 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('oklab', [0.3, 0, 0]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [-0.3, 0, 0]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [0.3, 0.000001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [NaN, 0.00001, 0]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [0, NaN, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [0, 0.3, -0.4]).is_achromatic(), False)
+        self.assertEqual(Color('oklab', [NaN, 0, -0.3]).is_achromatic(), False)
+        self.assertEqual(Color('oklab', [0.3, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('oklab', [NaN, NaN, 0]).is_achromatic(), True)
diff --git a/tests/test_oklch.py b/tests/test_oklch.py
index 84cb76701..ccbc6b64b 100644
--- a/tests/test_oklch.py
+++ b/tests/test_oklch.py
@@ -40,7 +40,7 @@ class TestsOkLCh(util.ColorAssertsPyTest):
         ('color(--oklch 50% 50% 50% / 50%)', 'color(--oklch 0.5 0.2 180 / 0.5)'),
         ('color(--oklch none none none / none)', 'color(--oklch none none none / none)'),
         # Test percent ranges
-        ('color(--oklch 0% 0% 0%)', 'color(--oklch 0 0 none)'),
+        ('color(--oklch 0% 0% 0%)', 'color(--oklch 0 0 0)'),
         ('color(--oklch 100% 100% 100%)', 'color(--oklch 1 0.4 360 / 1)'),
         ('color(--oklch -100% -100% -100%)', 'color(--oklch -1 0 -360 / 1)')
     ]
@@ -128,11 +128,6 @@ def test_alpha(self):
 class TestNull(util.ColorAsserts, unittest.TestCase):
     """Test Null cases."""
 
-    def test_real_achromatic_hue(self):
-        """Test that we get the expected achromatic hue."""
-
-        self.assertEqual(Color('white').convert('oklch')._space.achromatic_hue(), 90.00000025580869)
-
     def test_null_input(self):
         """Test null input."""
 
@@ -153,7 +148,7 @@ def test_near_zero_null(self):
         when chroma is very close to zero.
         """
 
-        c = Color('color(--oklch 90% 0.0000009 120 / 1)').convert('srgb').convert('oklch')
+        c = Color('color(--oklch 90% 0.00000009 120 / 1)').convert('srgb').convert('oklch')
         self.assertTrue(c.is_nan('hue'))
 
     def test_null_normalization_min_chroma(self):
@@ -167,7 +162,7 @@ def test_from_oklab(self):
 
         c1 = Color('color(--oklab 90% 0 0)')
         c2 = c1.convert('oklch')
-        self.assertColorEqual(c2, Color('color(--oklch 90% 0 0)'))
+        self.assertColorEqual(c2, Color('color(--oklch 90% 0 none)'))
         self.assertTrue(c2.is_nan('hue'))
 
     def test_achromatic_hue(self):
@@ -187,3 +182,19 @@ def test_chorma_less_than_zero_convert(self):
         """Test handling of negative chroma when converting to Oklab."""
 
         self.assertColorEqual(Color('color(--oklch 90% -10 120 / 1)').convert('oklab'), Color('color(--oklab 0.9 0 0)'))
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('oklch', [0.3, 0, 270]).is_achromatic(), True)
+        self.assertEqual(Color('oklch', [0.3, 0.000001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('oklch', [NaN, 0.00001, 270]).is_achromatic(), True)
+        self.assertEqual(Color('oklch', [0, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('oklch', [0, 1, 270]).is_achromatic(), False)
+        self.assertEqual(Color('oklch', [NaN, 0.2, 270]).is_achromatic(), False)
+        self.assertEqual(Color('oklch', [0.3, NaN, 270]).is_achromatic(), True)
+        self.assertEqual(Color('oklch', [NaN, NaN, 270]).is_achromatic(), True)
diff --git a/tests/test_orgb.py b/tests/test_orgb.py
index 375c6a93d..a7067010c 100644
--- a/tests/test_orgb.py
+++ b/tests/test_orgb.py
@@ -100,4 +100,4 @@ def test_labish_names(self):
         """Test `labish_names`."""
 
         c = Color('color(--orgb 1 0.5 0.5)')
-        self.assertEqual(c._space.labish_names(), ('l', 'crg', 'cyb'))
+        self.assertEqual(c._space.names(), ('l', 'crg', 'cyb'))
diff --git a/tests/test_prismatic.py b/tests/test_prismatic.py
index d7a8ff7e1..1bc59f10a 100644
--- a/tests/test_prismatic.py
+++ b/tests/test_prismatic.py
@@ -1,7 +1,7 @@
 """Test Prismatic."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -103,3 +103,18 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('prismatic', [0.3, 0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('prismatic', [0.3, 0.3000001, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('prismatic', [NaN, 0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('prismatic', [0.0, 0.4, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('prismatic', [0.0, NaN, 0.3, 0.0]).is_achromatic(), True)
+        self.assertEqual(Color('prismatic', [0.3, 0.4, 0.3, 0.3]).is_achromatic(), False)
+        self.assertEqual(Color('prismatic', [0.3, NaN, 0.3, 0.3]).is_achromatic(), False)
diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py
index 1aa4bffb6..d6ffb5189 100644
--- a/tests/test_roundtrip.py
+++ b/tests/test_roundtrip.py
@@ -1,40 +1,29 @@
 """Sanity check that ensures all colors round trip back."""
-from coloraide.everything import ColorAll as Color
-from coloraide.spaces import Cylindrical
+from coloraide.everything import ColorAll as Base
+from coloraide.spaces import Cylindrical, HSLish, HSVish
 from coloraide import algebra as alg
 import pytest
 
-SPACES = {k: 5 for k in Color.CS_MAP.keys()}
-
-# This color's gamut is less than the sRGB gamut we use to round trip, so we cannot test it.
-del SPACES['hpluv']
-SPACES['hct'] = 3
-
 
 class TestRoundTrip:
     """
-    Test round trip conversions of all color spaces.
+    Test round trip conversions for non-achromatic colors.
 
     A color that does not round trip back likely has a broken conversion.
-    It may be possible that some color spaces degrade through conversion,
-    but the currently supported spaces have enough integrity when performing
-    a round trip to meet the required value at the default precision.
-
-    There are certain values they may not round trip exactly. Hues may become
-    null, etc. But we are picking general colors that should round trip well
-    enough.
+    It may be possible that some color spaces degrade through conversion.
+    Only lower precision for spaces that do not have a high degree of accuracy,
+    but they should round trip for their lower precision.
     """
 
-    # Except for black, it can be very difficult to get perfect round tripping
-    # on achromatic colors unless we do absolutely no hue adjustments near achromatic.
-    # Many algorithms do not exactly land on what is expected for achromatic. For
-    # instance CIELab rarely resolves to `a = b = 0` except in the case of black.
-    # For cylindrical spaces that pass through a Lab-like spaces during conversion,
-    # you can often get wild hues near achromatic values because the saturation or
-    # chroma never quite gets to zero. In these case, we often set hue as undefined when
-    # chroma or saturation is very close to zero. But, when we pass through multiple
-    # spaces that do this, we can get a compounding error near achromatic colors making
-    # it difficult to get perfect round tripping with achromatic values.
+    class Color(Base):
+        """Local color object."""
+
+    Color.deregister('space:hpluv')
+
+    SPACES = {k: 5 for k in Color.CS_MAP.keys()}
+    # Not as accurate due to bisect back to CAM16
+    SPACES['hct'] = 3
+
     COLORS = [
         Color('red'),
         Color('orange'),
@@ -42,36 +31,18 @@ class TestRoundTrip:
         Color('green'),
         Color('blue'),
         Color('indigo'),
-        Color('violet'),
-        Color('darkgrey'),
-        Color('lightgrey'),
-        Color('gray'),
-        Color('black'),
-        Color('white')
+        Color('violet')
     ]
 
-    # Ignore non-black achromatic cases for spaces who's algorithm isn't precise enough
-    IGNORE_ACHROMA = ['okhsl', 'okhsv']
-
-    # Ignore gray cases. Spaces that don't offer enough precision near grey.
-    IGNORE_GREY = ['jzczhz', 'cam16-jmh', 'hct']
-
-    ACHROMA = {'grey', 'white', 'darkgrey', 'lightgrey'}
-    GREY = {'grey', 'darkgrey', 'lightgrey'}
-
     def assert_round_trip(self, color, space):
         """Cycle through all the other colors and convert to them and back and check the results."""
 
-        c1 = Color(color)
+        c1 = self.Color(color)
         name = c1.to_string(names=True)
         c1.convert(space, in_place=True)
-        p1 = SPACES[space]
-        for space, p2 in SPACES.items():
+        p1 = self.SPACES[space]
+        for space, p2 in self.SPACES.items():
             p = min(p1, p2)
-            if ((c1.space() in self.IGNORE_ACHROMA or space in self.IGNORE_ACHROMA) and name in self.ACHROMA):
-                continue
-            if ((c1.space() in self.IGNORE_GREY or space in self.IGNORE_GREY) and name in self.GREY):
-                continue
             # Print the color space to easily identify which color space broke.
             c2 = c1.convert(space)
             c2.convert(c1.space(), in_place=True)
@@ -79,9 +50,15 @@ def assert_round_trip(self, color, space):
             if isinstance(c2._space, Cylindrical):
                 if alg.round_half_up(alg.no_nan(c2['hue']), p) == 360:
                     c2.set('hue', 0)
+            # In HSL and HSV spaces particularly, we can get nonsense saturation if lightness
+            # is not exactly within 0 - 1 range. Ignore saturation in achromatic cases.
+            if isinstance(c2._space, (HSLish, HSVish)) and alg.is_nan(c2['hue']):
+                c2[c2._space.indexes()[1]] = 0.0
+            if isinstance(c1._space, (HSLish, HSVish)) and alg.is_nan(c1['hue']):
+                c1[c1._space.indexes()[1]] = 0.0
             # Run rounded string back through parsing in case we hit something like a hue that needs normalization.
-            str1 = Color(c1.to_string(color=True, fit=False)).to_string(color=True, fit=False, precision=p)
-            str2 = Color(c2.to_string(color=True, fit=False)).to_string(color=True, fit=False, precision=p)
+            str1 = self.Color(c1.to_string(color=True, fit=False)).to_string(color=True, fit=False, precision=p)
+            str2 = self.Color(c2.to_string(color=True, fit=False)).to_string(color=True, fit=False, precision=p)
             # Print failing results for debug purposes
             if str1 != str2:
                 print('----- Convert: {} <=> {} -----'.format(c1.space(), space))
@@ -97,3 +74,36 @@ def test_round_trip(self, space):
 
         for c in self.COLORS:
             self.assert_round_trip(c, space)
+
+
+class TestAchromaticRoundTrip(TestRoundTrip):
+    """
+    Test achromatic round trip.
+
+    For convenience, we determine achromatic colors and set hues to `NaN`.
+    This can cause a slight drop in precision.
+    """
+
+    class Color(Base):
+        """Local color object."""
+
+    Color.deregister('space:hpluv')
+
+    SPACES = {k: 5 for k in Color.CS_MAP.keys()}
+    # Precision just isn't as high for these in achromatic region
+    # but it is good enough for practical purposes.
+
+    COLORS = [
+        Color('darkgrey'),
+        Color('lightgrey'),
+        Color('gray'),
+        Color('black'),
+        Color('white')
+    ]
+
+    @pytest.mark.parametrize('space', SPACES)
+    def test_round_trip(self, space):
+        """Test round trip."""
+
+        for c in self.COLORS:
+            self.assert_round_trip(c, space)
diff --git a/tests/test_srgb.py b/tests/test_srgb.py
index 89cf6bcc3..49ec8eb1a 100644
--- a/tests/test_srgb.py
+++ b/tests/test_srgb.py
@@ -1,7 +1,7 @@
 """Test sRGB library."""
 import unittest
 from . import util
-from coloraide import Color
+from coloraide import Color, NaN
 import pytest
 
 
@@ -170,3 +170,15 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('srgb', [0.3, 0.3, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('srgb', [0.3, 0.3, 0.30000001]).is_achromatic(), True)
+        self.assertEqual(Color('srgb', [0.3, 0.4, 0.3]).is_achromatic(), False)
+        self.assertEqual(Color('srgb', [0.3, 0.3, NaN]).is_achromatic(), False)
diff --git a/tests/test_xyy.py b/tests/test_xyy.py
index bb46e988e..4ea937c39 100644
--- a/tests/test_xyy.py
+++ b/tests/test_xyy.py
@@ -1,7 +1,7 @@
 """Test xyY."""
 import unittest
 from . import util
-from coloraide.everything import ColorAll as Color
+from coloraide.everything import ColorAll as Color, NaN
 import pytest
 
 
@@ -95,3 +95,18 @@ def test_alpha(self):
         self.assertEqual(c['alpha'], 1)
         c['alpha'] = 0.5
         self.assertEqual(c['alpha'], 0.5)
+
+
+class TestsAchromatic(util.ColorAsserts, unittest.TestCase):
+    """Test achromatic."""
+
+    def test_achromatic(self):
+        """Test when color is achromatic."""
+
+        self.assertEqual(Color('xyy', [0.3127, 0.3290, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('xyy', [0.3127, 0.32900001, 0.3]).is_achromatic(), True)
+        self.assertEqual(Color('xyy', [0.3127, 0.3290, NaN]).is_achromatic(), True)
+        self.assertEqual(Color('xyy', [0.3127, 0.2290, 0.0]).is_achromatic(), True)
+        self.assertEqual(Color('xyy', [0.3127, NaN, 0]).is_achromatic(), True)
+        self.assertEqual(Color('xyy', [0.3127, 0.2290, 0.3]).is_achromatic(), False)
+        self.assertEqual(Color('xyy', [0.3127, NaN, 0.3]).is_achromatic(), False)
diff --git a/tests/test_xyz.py b/tests/test_xyz.py
index 43b65772a..e71fe45b5 100644
--- a/tests/test_xyz.py
+++ b/tests/test_xyz.py
@@ -66,6 +66,16 @@ def test_colors(self, color1, options, color2):
 class TestXYZD65Properties(util.ColorAsserts, unittest.TestCase):
     """Test XYZ D65."""
 
+    def test_names(self):
+        """Test RGB-ish names."""
+
+        self.assertEqual(Color('color(xyz 0.1 0.2 0.3 / 1)')._space.names(), ('x', 'y', 'z'))
+
+    def test_indexes(self):
+        """Test RGB-ish names."""
+
+        self.assertEqual(Color('color(xyz 0.1 0.2 0.3 / 1)')._space.indexes(), [0, 1, 2])
+
     def test_x(self):
         """Test `x`."""