Repository: facelessuser/coloraide (language: python)

Issue:
CAM16 UCS
EDIT: We will target CAM16 UCS instead. CAM16 is an appearance model used to create color spaces like CAM16 UCS.

So this would need some careful consideration of how to approach due to its need for environmental factors and the fact that it produces numerous channels which can be redundant.

We have a playground here: https://facelessuser.github.io/coloraide/playground/?source=https%3A%2F%2Fgist.githubusercontent.com%2Ffacelessuser%2F29feb9bc18242bb5760fbdbe2c024b99%2Fraw%2F5f8a9a9dfc3956b280f253e2b60bd1465be6048a%2Fcam16.py

## How to Handle Environement?

How do we handle the viewing conditions? Currently, in the playground above, we rely on an Environment object that we attach to the space. It calculates the environment once, and then all conversions use it. If the user wanted a different environment, they'd have to subclass and do so accordingly. This may be the way we go, but should we allow environments to be passed on the fly?

```py
Color('xyz', [0.23446234045762349, 0.23897966766938541, 0.6049634765734734]).convert('cam16', env={...})
```

I guess you'd also have to specify them when defining the CAM16 color directly as well:

```py
Color('color(--cam16 45.542 44.428 259.99 133.02 38.852 54.045 / 1)', env={...})
```

And with other operations as well, which is why I don't like this approach:

```py
# Asssumed for all CAM16 colors in the operation?
Color.interpolate(['red', 'green', 'color(--cam16 45.542 44.428 259.99 133.02 38.852 54.045 / 1)'], env={...})
```

I guess we could say if a string is passed, it is assumed to be the default, only if you wrap the color in a `Color()` object would it allow you to set the environment :shrug:. We'd need to cache custom configs as it is expensive for CAM16.

Dictionary input/output I guess could export an optional config in the dict.

## Coordinates

EDIT: Coordinates shouldn't be a problem as in the UCS model they seem to use `[J, a, b]`.

~~So the next issue is that CAM16 just dumps a ton of coordinates at you. They express different properties, but if you adjust one, you'd have to then adjust the others or they'd get out of sync. Then, if a user wanted to convert, the conversion algorithm often can actually convert based on just one, for instance, it can use `J` or `Q`. Or it can use `C`, `M`, or `s`. Not all of the coordinates are needed as the same info can be extracted from multiple coordinates. Then it becomes of question of which one to look at, and which one is actually right. After conversion, they are all right, but after a user mucks with it, it is anyone's guess.~~

~~So, do we provide just a few of the coordinates?  Like CAM16 just spits out `[J, C, h]` and CAM UCS spits out `[J, a, b]`?  Do we have multiple variants: `[J, C, h]`, `[Q, C, h]`, `[J, M, h]`, `[Q, M, h]`, `[J, s, h]`, `[Q, s, h]`, etc.~~

~~I think we could provide just one and allow a user to actually subclass to get whatever coordinates they want.~~

Stuff to think about.




Discussion / hints:
Should note we referred to [this](https://observablehq.com/@jrus/cam16) (Observable page on CAM16 as the spec is paywalled) and [this](https://arxiv.org/pdf/1802.06067.pdf) (Nico Schlömer paper on algorithm improvements, which the Observable includes).
Reducing it to just JCh and plotting it:

![cam16](https://user-images.githubusercontent.com/1055125/179370366-3dce582e-495b-46c2-97fc-2c8b35e5fefc.png)


Ended up taking it all the way to CAM16 UCS. It seems that is more in line with its goals as far as color spaces are concerned.

![cam-ucs](https://user-images.githubusercontent.com/1055125/179373764-4ec9c2f8-e5b6-48cf-bdf2-29cb27f898b3.png)


This give coefficents to augment the color space for large (LCD) and small (SCD) color difference. This is of course based on CAM02, but it also can apply to CAM16, a matter of fact the coefficients listed for CAM02 UCD are also used in CAM16.

https://www.researchgate.net/publication/317811721_Perceptually_uniform_color_space_for_image_signals_including_high_dynamic_range_and_wide_gamut
I've currently released an experimental version in ColorAide Extras: https://github.com/facelessuser/coloraide-extras
I'm thinking, that moving forward, we will have to document that `Space` objects if they fundamentally change how coordinates are calculated (changing CAM16 viewing conditions for instance) that the space should declare a new `Color.NAME`.

ColorAide will happily mutate a Color object that uses a derived Space object to another Color object that uses the original Space object without questions. It assumes the coordinates are compatible. If they are not compatible, the space names should be different. This goes for color spaces that are identical except for the white point, viewing conditions, etc. This does not apply to superficial changes such as changing the default string input/output, such changes affect serialization, but do not affect the actual, fundamental things like computed color values.

I don't think we'll go down the road of accepting viewing condition objects for color translation and such. Often a color with viewing conditions will not embed that info in exported strings. If we get a dictionary with viewing conditions, we'd have to normalize it with whatever we have registered. We'd have to create a subclass with the new view conditions, or maybe create a new instance, and perform the calculations on the fly that we'd normally do ahead of time. Convert to some common color space, then convert back to the color space with the new viewing conditions. And what if a color space has no direct viewing conditions but uses one that does as a base? It is much easier just to say "configure the color space configurations you require with unique `NAME`s and then manage conversion accordingly.

One weird thing to note about CAM16-UCS, the Lab-ish form doesn't really align to `a` and `b` equaling zero for achromatic values. Some current spaces don't either, for instance, IgPgTg and even Jzazbz to an even lesser extent, but they are at least reasonably close.  The difference is that CAM16-UCS is greatly off. IgPgTg, the previous worse in this regard, is still much, much closer to zero than CAM16-UCS. Black is about the only color that is right on target.

We can see when we compare IgPgTg to CAM16-UCS

```python
>>> Color('white').convert('igpgtg')
color(--igpgtg 0.97415 0.00139 -0.00404 / 1)
>>> Color('white').convert('cam16-ucs')
color(--cam16-ucs 100 -1.8983 -1.0754 / 1)
```

IIRC we compared against Colour Science and our results were pretty spot on assuming conditions were configured alike, so I feel pretty confident we implemented it correctly.  This is just how CAM16-UCS works.

If we ever implemented a cylindrical variant, you could not, in any way, set hue to undefined with achromatic values unless they were very close to black. Values approaching almost 2.0 is just an unreliable range to clamp hue to undefined.

With the work done in HCT, I could see LCh-ish CAM16 UCS. I imagine though it could only be tuned for a given environment. I suspect the achromatic response would be different for different environments.
We now have a CAM16 UCS JMh over on ColorAide Extras. We have a method that can properly detect and follow the achromatic response of the space in polar form.

The black line is the measured chromatic response, and the red is our detection of it.

![Figure_1](https://user-images.githubusercontent.com/1055125/216071496-1b362c3f-5d86-4310-9959-079f28ba9324.png)

![cam16-ucs-jmh](https://user-images.githubusercontent.com/1055125/216071680-825e9606-0d3c-4c00-959c-8d113dee75a9.png)

I imagine we can bring in both the Jab and JMh form officially after a little more testing. Depending on the lighting environment, we recalculate the achromatic path. That just needs some more testing to make sure we can reasonably handle shifts in the environment. As far as the default viewing conditions, we seem to have them nailed down.


Fix patch:
diff --git a/coloraide/contrast/lstar.py b/coloraide/contrast/lstar.py
new file mode 100644
index 000000000..8437b7f19
--- /dev/null
+++ b/coloraide/contrast/lstar.py
@@ -0,0 +1,31 @@
+"""
+L* color contrast.
+
+Used for color contrast in Google's HCT.
+
+https://material.io/blog/science-of-color-design
+"""
+from __future__ import annotations
+from ..contrast import ColorContrast
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+
+class LstarContrast(ColorContrast):
+    """L* contrast."""
+
+    NAME = "lstar"
+
+    @classmethod
+    def contrast(cls, color1: Color, color2: Color, **kwargs: Any) -> float:
+        """Contrast."""
+
+        l1 = color1.get('lch-d65.lightness')
+        l2 = color2.get('lch-d65.lightness')
+
+        if l1 > l2:
+            l2, l1 = l1, l2
+
+        return l2 - l1
diff --git a/coloraide/distance/delta_e_cam16.py b/coloraide/distance/delta_e_cam16.py
new file mode 100644
index 000000000..bac3b6cc3
--- /dev/null
+++ b/coloraide/distance/delta_e_cam16.py
@@ -0,0 +1,34 @@
+"""
+Delta E CAM16.
+
+https://www.ncbi.nlm.nih.gov/pmc/articles/PMC9698626/pdf/sensors-22-08869.pdf
+"""
+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
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+
+class DECAM16(DeltaE):
+    """Delta E CAM16 class."""
+
+    NAME = "cam16"
+
+    def distance(self, color: Color, sample: Color, model: str = 'ucs', **kwargs: Any) -> float:
+        """Delta E z color distance formula."""
+
+        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])
+
+        dj = j1 - j2
+        da = a1 - a2
+        db = b1 - b2
+
+        return math.sqrt((dj / kl) ** 2 + da ** 2 + db ** 2)
diff --git a/coloraide/distance/delta_e_hct.py b/coloraide/distance/delta_e_hct.py
new file mode 100644
index 000000000..94bdba544
--- /dev/null
+++ b/coloraide/distance/delta_e_hct.py
@@ -0,0 +1,42 @@
+"""Delta E CAM16."""
+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
+
+if TYPE_CHECKING:  # pragma: no cover
+    from ..color import Color
+
+COEFF2 = COEFFICENTS['ucs'][2]
+
+
+def convert_ucs_ab(c: float, h: float) -> Vector:
+    """Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) to UCS a and b."""
+
+    hrad = math.radians(h)
+    M = math.log(1 + COEFF2 * c) / COEFF2
+    a = M * math.cos(hrad)
+    b = M * math.sin(hrad)
+
+    return [a, b]
+
+
+class DEHCT(DeltaE):
+    """Delta E HCT class."""
+
+    NAME = "hct"
+
+    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])
+
+        a1, b1 = convert_ucs_ab(c1, h1)
+        a2, b2 = convert_ucs_ab(c2, h2)
+
+        # Use simple euclidean distance
+        return math.sqrt((t1 - t2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2)
diff --git a/coloraide/everything.py b/coloraide/everything.py
index 9dcd3f302..9bc1f54cb 100644
--- a/coloraide/everything.py
+++ b/coloraide/everything.py
@@ -28,10 +28,18 @@
 from .spaces.acescg import ACEScg
 from .spaces.acescc import ACEScc
 from .spaces.acescct import ACEScct
+from .spaces.cam16 import CAM16
+from .spaces.cam16_jmh import CAM16JMh
+from .spaces.cam16_ucs import CAM16UCS, CAM16LCD, CAM16SCD
+from .spaces.hct import HCT
 from .distance.delta_e_itp import DEITP
 from .distance.delta_e_99o import DE99o
 from .distance.delta_e_z import DEZ
+from .distance.delta_e_cam16 import DECAM16
+from .distance.delta_e_hct import DEHCT
+from .gamut.fit_hct_chroma import HCTChroma
 from .interpolate.catmull_rom import CatmullRom
+from .contrast.lstar import LstarContrast
 from .cat import VonKries, XYZScaling, CAT02, CMCCAT97, Sharp, CMCCAT2000, CAT16
 from .color import ColorMatch
 from .interpolate import stop, hint
@@ -74,11 +82,22 @@ class ColorAll(Base):
         ACEScg(),
         ACEScc(),
         ACEScct(),
+        CAM16(),
+        CAM16JMh(),
+        CAM16UCS(),
+        CAM16SCD(),
+        CAM16LCD(),
+        HCT(),
 
         # Delta E
         DEITP(),
         DE99o(),
         DEZ(),
+        DECAM16(),
+        DEHCT(),
+
+        # Gamut Mapping
+        HCTChroma(),
 
         # CAT
         VonKries(),
@@ -90,6 +109,9 @@ class ColorAll(Base):
         CAT16(),
 
         # Interpolation
-        CatmullRom()
+        CatmullRom(),
+
+        # Contrast
+        LstarContrast()
     ]
 )
diff --git a/coloraide/gamut/fit_hct_chroma.py b/coloraide/gamut/fit_hct_chroma.py
new file mode 100644
index 000000000..48a0245e6
--- /dev/null
+++ b/coloraide/gamut/fit_hct_chroma.py
@@ -0,0 +1,17 @@
+"""HCT gamut mapping."""
+from __future__ import annotations
+from ..gamut.fit_lch_chroma import LChChroma
+
+
+class HCTChroma(LChChroma):
+    """HCT chroma gamut mapping class."""
+
+    NAME = "hct-chroma"
+
+    EPSILON = 0.001
+    LIMIT = 0.02
+    DE = "hct"
+    SPACE = "hct"
+    MIN_LIGHTNESS = 0
+    MAX_LIGHTNESS = 100
+    MIN_CONVERGENCE = 0.0001
diff --git a/coloraide/spaces/__init__.py b/coloraide/spaces/__init__.py
index d5859eb66..ca6d982e3 100644
--- a/coloraide/spaces/__init__.py
+++ b/coloraide/spaces/__init__.py
@@ -24,6 +24,21 @@ def hue_index(self) -> int:  # pragma: no cover
 
         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.
+        """
+
+        return 0.0
+
 
 class Labish:
     """Lab-ish color spaces."""
diff --git a/coloraide/spaces/cam16.py b/coloraide/spaces/cam16.py
new file mode 100644
index 000000000..7d1e6d521
--- /dev/null
+++ b/coloraide/spaces/cam16.py
@@ -0,0 +1,403 @@
+"""
+CAM16 class.
+
+https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf
+https://observablehq.com/@jrus/cam16
+https://arxiv.org/abs/1802.06067
+https://doi.org/10.1002/col.22131
+"""
+from __future__ import annotations
+import math
+import bisect
+from ..spaces import Space, Labish
+from ..cat import WHITES
+from ..channels import Channel
+from .. import util
+from .. import algebra as alg
+from ..types import Vector, VectorLike
+from typing import Optional, cast
+
+# 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 = math.pow(fl * abs(c) * 0.01, ADAPTED_COEF)
+        adapted.append(math.copysign(400 * x / (x + 27.13), c))
+    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 * math.pow(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: Optional[float] = None,
+    C: Optional[float] = None,
+    h: Optional[float] = None,
+    s: Optional[float] = None,
+    Q: Optional[float] = None,
+    M: Optional[float] = None,
+    H: Optional[float] = None,
+    env: Optional[Environment] = None
+) -> Vector:
+    """From CAM16 to XYZ."""
+
+    # Reverse calculation can actually be obtained from a small subset of the components
+    # Really, only one should be given as we won't know which one is correct otherwise,
+    # but we don't currently enforce it as we expect the `Space` object to do that.
+    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 or Q == 0:
+        return [0, 0, 0]
+
+    # Break hue into Cartesian components
+    h_rad = math.radians(h if h is not None else inv_hue_quadrature(cast(float, H)))
+    cos_h = math.cos(h_rad)
+    sin_h = math.sin(h_rad)
+
+    # Calculate `J_root` from one of the lightness derived coordinates.
+    if J is not None:
+        J_root = alg.nth_root(J, 2) * 0.1
+    else:
+        J_root = 0.25 * env.c * cast(float, Q) / ((env.a_w + 4) * env.fl_root)
+
+    # Calculate the `t` value from one of the chroma derived coordinates
+    if C is not None:
+        alpha = C / J_root
+    elif M is not None:
+        alpha = (M / env.fl_root) / J_root
+    else:
+        alpha = 0.0004 * (cast(float, 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
+
+    if alg.is_nan(h):  # pragma: no cover
+        h = 0
+
+    return cam16_to_xyz_d65(J=J, M=M, h=h, env=env)
+
+
+def cam16_jmh_to_cam16_jab(jmh: Vector) -> Vector:
+    """Translate a CAM16 JMh to Jab of the same viewing conditions."""
+
+    J, M, h = jmh
+
+    return [
+        J,
+        M * math.cos(math.radians(h)),
+        M * math.sin(math.radians(h))
+    ]
+
+
+def cam16_jab_to_cam16_jmh(jab: Vector) -> Vector:
+    """Translate a CAM16 Jab to JMh of the same viewing conditions."""
+
+    J, a, b = jab
+    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"
+    NAME = "cam16"
+    SERIALIZE = ("--cam16",)
+    CHANNELS = (
+        Channel("j", 0.0, 100.0),
+        Channel("a", -90.0, 90.0),
+        Channel("b", -90.0, 90.0)
+    )
+    CHANNEL_ALIASES = {
+        "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)
+
+    def to_base(self, coords: Vector) -> Vector:
+        """To XYZ from CAM16."""
+
+        return cam16_jab_to_xyz_d65(coords, env=self.ENV)
+
+    def from_base(self, coords: Vector) -> Vector:
+        """From XYZ to CAM16."""
+
+        return xyz_d65_to_cam16_jab(coords, env=self.ENV)
diff --git a/coloraide/spaces/cam16_jmh.py b/coloraide/spaces/cam16_jmh.py
new file mode 100644
index 000000000..9a145d117
--- /dev/null
+++ b/coloraide/spaces/cam16_jmh.py
@@ -0,0 +1,187 @@
+"""
+CAM16 class (JMh).
+
+https://www.imaging.org/site/PDFS/Papers/2000/PICS-0-81/1611.pdf
+https://observablehq.com/@jrus/cam16
+https://arxiv.org/abs/1802.06067
+https://doi.org/10.1002/col.22131
+"""
+from __future__ import annotations
+import bisect
+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:
+    """
+    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,
+        env: Environment,
+        spline: str
+    ) -> 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(
+        self,
+        env: Environment,
+        points: list[list[float]],
+        start: int,
+        end: int,
+        step: int,
+        scale: float
+    ) -> None:
+        """
+        Iterate the achromatic response of the space.
+
+        Save points of lightness vs colorfulness. Also, track the domain.
+        """
+
+        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 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
+
+
+class CAM16JMh(LChish, Space):
+    """CAM16 class (JMh)."""
+
+    BASE = "cam16"
+    NAME = "cam16-jmh"
+    SERIALIZE = ("--cam16-jmh",)
+    CHANNELS = (
+        Channel("j", 0.0, 100.0),
+        Channel("m", 0, 55.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
+    ACHROMATIC = Achromatic(
+        {
+            'low': (0, 25, 1, 100.0),
+            'mid': (25, 101, 9, 80.0),
+            'high': (101, 302, 5, 60.0)
+        },
+        0.032,
+        ENV,
+        'monotone'
+    )
+
+    def achromatic_hue(self) -> float:
+        """Ideal achromatic hue."""
+
+        return self.ACHROMATIC.hue
+
+    def normalize(self, coords: Vector) -> Vector:
+        """Normalize the color ensuring no unexpected NaN and achromatic hues are NaN."""
+
+        coords = alg.no_nans(coords)
+        j, m = coords[:2]
+        if self.ACHROMATIC.test(j, m):
+            coords[2] = alg.NaN
+        return coords
+
+    def to_base(self, coords: Vector) -> Vector:
+        """To XYZ from CAM16."""
+
+        j, m = coords[:2]
+        if self.ACHROMATIC.test(j, m):
+            coords[2] = self.ACHROMATIC.hue
+
+        return cam16_jmh_to_cam16_jab(coords)
+
+    def from_base(self, coords: Vector) -> Vector:
+        """From XYZ to CAM16."""
+
+        jmh = cam16_jab_to_cam16_jmh(coords)
+        return self.normalize(jmh)
diff --git a/coloraide/spaces/cam16_ucs.py b/coloraide/spaces/cam16_ucs.py
new file mode 100644
index 000000000..f07a34320
--- /dev/null
+++ b/coloraide/spaces/cam16_ucs.py
@@ -0,0 +1,124 @@
+"""
+CAM 16 UCS.
+
+https://observablehq.com/@jrus/cam16
+https://arxiv.org/abs/1802.06067
+https://doi.org/10.1002/col.22131
+"""
+from __future__ import annotations
+import math
+from . cam16 import CAM16
+from ..types import Vector
+from ..channels import Channel
+
+COEFFICENTS = {
+    'lcd': (0.77, 0.007, 0.0053),
+    'scd': (1.24, 0.007, 0.0363),
+    'ucs': (1.00, 0.007, 0.0228)
+}
+
+
+def cam16_to_cam16_ucs(jab: Vector, model: str) -> Vector:
+    """
+    CAM16 (Jab) to CAM16 UCS (Jab).
+
+    We can actually go between simply by removing the old colorfulness multiplier
+    and then adding the new adjusted multiplier. Then we can just adjust lightness.
+    """
+
+    J, a, b = jab
+    M = math.sqrt(a ** 2 + b ** 2)
+
+    c1, c2 = COEFFICENTS[model][1:]
+
+    if M != 0:
+        a /= M
+        b /= M
+        M = math.log(1 + c2 * M) / c2
+        a *= M
+        b *= M
+
+    return [
+        (1 + 100 * c1) * J / (1 + c1 * J),
+        a,
+        b
+    ]
+
+
+def cam16_ucs_to_cam16(ucs: Vector, model: str) -> Vector:
+    """
+    CAM16 UCS (Jab) to CAM16 (Jab).
+
+    We can actually go between simply by removing the old colorfulness multiplier
+    and then adding the new adjusted multiplier. Then we can just adjust lightness.
+    """
+
+    J, a, b = ucs
+    M = math.sqrt(a ** 2 + b ** 2)
+
+    c1, c2 = COEFFICENTS[model][1:]
+
+    if M != 0:
+        a /= M
+        b /= M
+        M = (math.exp(M * c2) - 1) / c2
+        a *= M
+        b *= M
+
+    return [
+        J / (1 - c1 * (J - 100)),
+        a,
+        b
+    ]
+
+
+class CAM16UCS(CAM16):
+    """CAM16 UCS (Jab) class."""
+
+    BASE = "cam16"
+    NAME = "cam16-ucs"
+    SERIALIZE = ("--cam16-ucs",)
+    MODEL = 'ucs'
+    CHANNELS = (
+        Channel("j", 0.0, 100.0),
+        Channel("a", -50.0, 50.0),
+        Channel("b", -50.0, 50.0)
+    )
+
+    def to_base(self, coords: Vector) -> Vector:
+        """To XYZ from CAM16."""
+
+        return cam16_ucs_to_cam16(coords, self.MODEL)
+
+    def from_base(self, coords: Vector) -> Vector:
+        """From XYZ to CAM16."""
+
+        return cam16_to_cam16_ucs(coords, self.MODEL)
+
+
+class CAM16LCD(CAM16UCS):
+    """CAM16 LCD (Jab) class."""
+
+    NAME = "cam16-lcd"
+    SERIALIZE = ("--cam16-lcd",)
+    ENV = ENV = CAM16.ENV
+    MODEL = 'lcd'
+    CHANNELS = (
+        Channel("j", 0.0, 100.0),
+        Channel("a", -70.0, 70.0),
+        Channel("b", -70.0, 70.0)
+    )
+
+
+class CAM16SCD(CAM16UCS):
+    """CAM16 SCD (Jab) class."""
+
+    NAME = "cam16-scd"
+    SERIALIZE = ("--cam16-scd",)
+    ENV = ENV = CAM16.ENV
+    MODEL = 'scd'
+    CHANNELS = (
+        Channel("j", 0.0, 100.0),
+        Channel("a", -40.0, 40.0),
+        Channel("b", -40.0, 40.0)
+    )
diff --git a/coloraide/spaces/hct.py b/coloraide/spaces/hct.py
new file mode 100644
index 000000000..4bccd30ad
--- /dev/null
+++ b/coloraide/spaces/hct.py
@@ -0,0 +1,231 @@
+"""
+HCT color space.
+
+This implements the HCT color space as described. This is not a port of the Material library.
+We simply, as described, create a color space with CIELAB L* and CAM16's C and h components.
+Environment settings are calculated with the assumption of L* 50.
+
+Our approach getting back to sRGB is likely different as we are using a simple binary search
+to help calculate the missing piece (J) needed to convert CAM16 C and h back to XYZ such that
+Y matches the L* conversion back to Y. I am certain the Material library is most likely
+employing further tricks to resolve faster. We also try to get as good round trip as possible,
+which forces us to probably make more iterations than Material does (none of this has been confirmed).
+
+As ColorAide usually cares about setting powerless hues as NaN, especially for good interpolation,
+we've also calculated the cut off for chromatic colors and will properly enforce achromatic,powerless
+hues. This is because CAM16 actually resolves colors as achromatic before chroma reaches zero as
+lightness increases. In the SDR range, a Tone of 100 will have a cut off as high as ~2.87 chroma.
+
+Generally, the HCT color space is restricted to SDR range in the Material library, but we do not have
+such restrictions.
+
+Though we did not port HCT from Material Color Utilities, we did test against it, and are pretty
+much on point. The only differences are due to matrix precision and white point precision. Material
+uses an RGB <-> XYZ matrix that rounds values off significantly more than we do. Also, while we
+calculate the XYZ points from the `xy` points without rounding, they have rounded XYZ points. Lastly,
+the gamut mapping algorithm we use is likely different even though it arrives at pretty much the same
+result, so slightly different values can occur.
+
+Material:
+
+```
+> hct.Hct.fromInt(0xff305077)
+Hct {
+  argb: 4281356407,
+  internalHue: 256.8040416857594,
+  internalChroma: 31.761442797741243,
+  internalTone: 33.34501410942328
+}
+```
+
+ColorAide:
+
+```
+>>> from coloraide_extras.everything import ColorAll as Color
+>>> Color('#305077').convert('hct')
+color(--hct 256.79 31.766 33.344 / 1)
+```
+
+Differences are inconsequential.
+"""
+from __future__ import annotations
+from .. import algebra as alg
+from .. import util
+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 Achromatic as _Achromatic
+from .lab import EPSILON, KAPPA, KE
+from ..types import Vector, VectorLike
+from typing import cast
+import math
+
+
+def y_to_lstar(y: float, white: VectorLike) -> float:
+    """Convert XYZ Y to Lab L*."""
+
+    y = y / white[1]
+    fy = alg.cbrt(y) if y > EPSILON else (KAPPA * y + 16) / 116
+    return (116.0 * fy) - 16.0
+
+
+def lstar_to_y(lstar: float, white: VectorLike) -> float:
+    """Convert Lab L* to XYZ Y."""
+
+    fy = (lstar + 16) / 116
+    y = fy ** 3 if lstar > KE else lstar / KAPPA
+    return y * white[1]
+
+
+def hct_to_xyz(coords: Vector, env: Environment) -> Vector:
+    """
+    Convert HCT to XYZ.
+
+    Utilize bisect to find the best J that fulfills the current XYZ Y while
+    keeping hue and chroma the same (assuming color is not achromatic).
+    Not the most efficient, especially if you want to provide the best round
+    trip possible. The official Material library that implements HCT, most
+    likely has a number of shortcuts to help resolve the color faster, some
+    may come at the cost of precision. Worth looking into in the future.
+    """
+
+    # Threshold of how close is close enough
+    # Precision requires more iterations...maybe too many :)
+    threshold = 0.000000002
+
+    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:
+        return env.ref_white[:]
+
+    # Initialize J with our T, set our bisect bounds,
+    # and get our target XYZ Y from T
+    j = t
+    low = 0.0
+    # SDR or HDR, give a little room for SDR colors a little over the limit
+    high = 105.0 if t < 100.05 else 1000.0
+    y = lstar_to_y(t, env.ref_white)
+
+    # Try to find a J such that the returned y matches the returned y of the L*
+    while (high - low) > threshold:
+        xyz = cam16_to_xyz_d65(J=j, C=c, h=h, env=env)
+
+        delta = xyz[1] - y
+
+        # We are within range, so return XYZ
+        if abs(delta) <= threshold:
+            return xyz
+
+        if delta < 0:
+            low = j
+        else:
+            high = j
+
+        j = (high + low) * 0.5
+
+    # Return the best that we have
+    return cam16_to_xyz_d65(J=j, C=coords[1], h=coords[0], env=env)  # pragma: no cover
+
+
+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]
+
+    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
+
+
+class HCT(LChish, Space):
+    """HCT class."""
+
+    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,
+        200 / math.pi * lstar_to_y(50.0, util.xy_to_xyz(WHITE)),
+        lstar_to_y(50.0, util.xy_to_xyz(WHITE)) * 100,
+        'average',
+        False
+    )
+
+    # Achromatic detection
+    ACHROMATIC = Achromatic(
+        {
+            'low': (0, 51, 1, 200.0),
+            'mid': (50, 101, 5, 100.0),
+            'high': (101, 502, 25, 75.0)
+        },
+        0.085,
+        ENV,
+        'natural'
+    )
+
+    def achromatic_hue(self) -> float:
+        """Ideal achromatic hue."""
+
+        return self.ACHROMATIC.hue
+
+    def normalize(self, coords: Vector) -> Vector:
+        """Normalize the color ensuring no unexpected NaN and achromatic hues are NaN."""
+
+        coords = alg.no_nans(coords)
+        m, j = coords[1:3]
+        if self.ACHROMATIC.test(j, m):
+            coords[0] = alg.NaN
+        return coords
+
+    def lchish_names(self) -> tuple[str, ...]:
+        """Return LCh-ish names in the order L C h."""
+
+        channels = cast(Space, 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[0] = self.ACHROMATIC.hue
+
+        return hct_to_xyz(coords, self.ENV)
+
+    def from_base(self, coords: Vector) -> Vector:
+        """From XYZ to HCT."""
+
+        hct = xyz_to_hct(coords, self.ENV)
+        return self.normalize(hct)
diff --git a/coloraide/spaces/jzczhz.py b/coloraide/spaces/jzczhz.py
index fa5da183e..3766ef33d 100644
--- a/coloraide/spaces/jzczhz.py
+++ b/coloraide/spaces/jzczhz.py
@@ -13,6 +13,8 @@
 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
 
 
@@ -78,6 +80,11 @@ class JzCzhz(LChish, Space):
     WHITE = WHITES['2deg']['D65']
     DYNAMIC_RANGE = 'hdr'
 
+    def achromatic_hue(self) -> float:
+        """Ideal achromatic hue."""
+
+        return ACHROMATIC_HUE
+
     def normalize(self, coords: Vector) -> Vector:
         """On color update."""
 
diff --git a/coloraide/spaces/lch/__init__.py b/coloraide/spaces/lch/__init__.py
index 48fbdf01b..d9e121c96 100644
--- a/coloraide/spaces/lch/__init__.py
+++ b/coloraide/spaces/lch/__init__.py
@@ -69,6 +69,11 @@ 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."""
 
diff --git a/coloraide/spaces/lch99o.py b/coloraide/spaces/lch99o.py
index 0dfc41777..6536910e0 100644
--- a/coloraide/spaces/lch99o.py
+++ b/coloraide/spaces/lch99o.py
@@ -1,16 +1,13 @@
 """DIN99o LCh class."""
 from __future__ import annotations
 from ..cat import WHITES
-from .lch import LCh
+from .lch import LCh, ACHROMATIC_THRESHOLD, ACHROMATIC_HUE
 from .. import util
 import math
 from .. import algebra as alg
 from ..types import Vector
 from ..channels import Channel, FLG_ANGLE
 
-ACHROMATIC_THRESHOLD = 0.0000000002
-ACHROMATIC_HUE = 0.0
-
 
 def lch_to_lab(lch: Vector) -> Vector:
     """DIN99o LCh to lab."""
@@ -63,6 +60,11 @@ class LCh99o(LCh):
         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."""
 
diff --git a/coloraide/spaces/lchuv.py b/coloraide/spaces/lchuv.py
index 5e60d012d..e5ebfffdb 100644
--- a/coloraide/spaces/lchuv.py
+++ b/coloraide/spaces/lchuv.py
@@ -3,14 +3,12 @@
 from ..spaces import Space
 from ..cat import WHITES
 from ..channels import Channel, FLG_ANGLE
-from .lch import LCh, ACHROMATIC_THRESHOLD
+from .lch import LCh, ACHROMATIC_THRESHOLD, ACHROMATIC_HUE
 from .. import util
 import math
 from .. import algebra as alg
 from ..types import Vector
 
-ACHROMATIC_HUE = 180.0
-
 
 def luv_to_lchuv(luv: Vector) -> Vector:
     """Luv to LChuv."""
@@ -64,6 +62,11 @@ 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."""
 
diff --git a/coloraide/spaces/okhsl.py b/coloraide/spaces/okhsl.py
index a3b6e6d0b..6474e7d24 100644
--- a/coloraide/spaces/okhsl.py
+++ b/coloraide/spaces/okhsl.py
@@ -382,7 +382,7 @@ def oklab_to_okhsl(lab: Vector) -> Vector:
 
     c = math.sqrt(lab[1] ** 2 + lab[2] ** 2)
     if c < ACHROMATIC_THRESHOLD:
-        c = 0
+        c = 0.0
 
     if abs(l) >= 1e-08 and abs(1 - l) >= 1e-08 and c != 0:
         a_ = lab[1] / c
diff --git a/coloraide/spaces/okhsv.py b/coloraide/spaces/okhsv.py
index 7d27e449b..69d6bacb3 100644
--- a/coloraide/spaces/okhsv.py
+++ b/coloraide/spaces/okhsv.py
@@ -98,7 +98,7 @@ def oklab_to_okhsv(lab: Vector) -> Vector:
 
     c = math.sqrt(lab[1] ** 2 + lab[2] ** 2)
     if c < ACHROMATIC_THRESHOLD:
-        c = 0
+        c = 0.0
 
     if abs(l) >= 1e-08 and abs(1 - l) >= 1e-08 and c != 0:
         a_ = lab[1] / c
diff --git a/coloraide/spaces/oklch/__init__.py b/coloraide/spaces/oklch/__init__.py
index d5f3b9e94..6a854f1c1 100644
--- a/coloraide/spaces/oklch/__init__.py
+++ b/coloraide/spaces/oklch/__init__.py
@@ -33,6 +33,10 @@
 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
 
 
@@ -93,6 +97,11 @@ class OkLCh(LChish, Space):
     }
     WHITE = WHITES['2deg']['D65']
 
+    def achromatic_hue(self) -> float:
+        """Ideal achromatic hue."""
+
+        return ACHROMATIC_HUE
+
     def normalize(self, coords: Vector) -> Vector:
         """On color update."""
 
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 921d33f3f..83f936297 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -16,6 +16,7 @@ Brettel
 Bz
 CATs
 CIE
+CIECAM
 CIEDE
 CIELCh
 CIELCh's
@@ -62,6 +63,7 @@ FNL
 Fairchild
 Florian
 Formatter
+HCT
 HDR
 HLG
 HPE
@@ -84,8 +86,11 @@ IgPgTg
 Interpolator
 Iz
 Izazbz
+JCh
+JMh
 JND
 JSON
+Jsh
 Jz
 JzCzhz
 Jzazbz
@@ -99,6 +104,8 @@ Lab
 Lab's
 Labish
 Lilley
+Lstar
+Lstar's
 Luv
 MATLAB
 MERCHANTABILITY
@@ -109,6 +116,7 @@ Matcher
 MkDocs
 Mollon
 Monochromacy
+Moroney
 NONINFRINGEMENT
 NaN
 NaNs
@@ -122,6 +130,7 @@ Oklab
 Oklab's
 Ottosson
 PQ
+Perceptibility
 Piecewise
 Planckian
 Pokorny
@@ -132,11 +141,16 @@ Protan
 Protanopia
 PyPI
 Pyodide
+QCh
+QMh
+Qsh
 RGB
 RLAB
 ROMM
 RYB
+SCD
 SDR
+SL
 SMPTE
 SRGB
 Sharma
@@ -149,6 +163,7 @@ Tritanopia
 Twemoji
 UCS
 UHDTV
+UIs
 Uncalibrated
 Verou
 Viénot
@@ -174,6 +189,7 @@ broadcasted
 bz
 cd
 chroma
+chroma's
 chromaticity
 colorimetrically
 colorimetry
@@ -188,12 +204,14 @@ deregistration
 deuteranomaly
 deuteranopia
 dichromacy
+diffuser
 dyad
 easings
 fixup
 formatter
 grayscale
 hz
+illuminance
 illuminant
 illuminants
 indexable
@@ -234,6 +252,7 @@ protanomaly
 protanopia
 quantizer
 rc
+reflectance
 repurpose
 rgb
 sRGB
@@ -255,6 +274,7 @@ tritanomaly
 tritanopia
 un
 uncomment
+uniformities
 unintuitive
 unsaturate
 uv
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 5770f920d..be78e329d 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,16 +1,21 @@
 # Changelog
 
-## 1.7.1
+## 1.7
 
+- **NEW**: Add support for CAM16 Jab and JMh: `cam16` and `cam16-jmh` respectively.
+- **NEW**: Add support for CAM16 UCS (Jab forms): `cam16-ucs`, `cam16-scd`, and `cam16-lcd`.
+- **NEW**: Add support for the HCT color space (`hct`) which combines the colorfulness and hue from CAM16 JMh and the
+  lightness from CIELab.
+- **NEW**: Gamut mapping classes derived from `fit_lch_chroma` can set `DE_OPTIONS` to pass ∆E parameters.
+- **NEW**: While rare, some cylindrical color spaces have an algorithm such that achromatic colors convert best with a
+  very specific hue. Internally, this is handled during conversions, but there can be reasons where knowing the hue can
+  be useful such as plotting. Cylindrical spaces now expose a method called `achromatic_hue()` which will
+  return this specific hue if needed.
 - **FIX**: Fix `rec2100-hlg` transform.
 - **FIX**: Hues should be constrained between as \[0, 360), not \[0, 360].
 - **FIX**: Some color transformation improvements.
-- **FIX**: Relax some achromatic detection logic for sRGB cylindrical models. Improves results when converting to from
-  various non-sRGB color spaces.
-
-## 1.7
-
-- **NEW**: Gamut mapping classes derived from `fit_lch_chroma` can set `DE_OPTIONS` to pass ∆E parameters.
+- **FIX**: Relax some achromatic detection logic for sRGB cylindrical models. Improves achromatic hue detection results
+  when converting to from various non-sRGB color spaces.
 
 ## 1.6
 
diff --git a/docs/src/markdown/colors/aces2065_1.md b/docs/src/markdown/colors/aces2065_1.md
index 9316b86a8..f509a0739 100644
--- a/docs/src/markdown/colors/aces2065_1.md
+++ b/docs/src/markdown/colors/aces2065_1.md
@@ -1,6 +1,6 @@
 # ACES 2065-1
 
-!!! fail "The ACES 2065-1 color space is not registered in `Color` by default"
+!!! failure "The ACES 2065-1 color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/acescc.md b/docs/src/markdown/colors/acescc.md
index d62188859..b9669a343 100644
--- a/docs/src/markdown/colors/acescc.md
+++ b/docs/src/markdown/colors/acescc.md
@@ -1,6 +1,6 @@
 # ACEScc
 
-!!! fail "The ACEScc color space is not registered in `Color` by default"
+!!! failure "The ACEScc color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/acescct.md b/docs/src/markdown/colors/acescct.md
index 62ebc7626..e93e14be5 100644
--- a/docs/src/markdown/colors/acescct.md
+++ b/docs/src/markdown/colors/acescct.md
@@ -1,6 +1,6 @@
 # ACEScct
 
-!!! fail "The ACEScc color space is not registered in `Color` by default"
+!!! failure "The ACEScc color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/acescg.md b/docs/src/markdown/colors/acescg.md
index 101e26d58..64e50eb41 100644
--- a/docs/src/markdown/colors/acescg.md
+++ b/docs/src/markdown/colors/acescg.md
@@ -1,6 +1,6 @@
 # ACEScg
 
-!!! fail "The ACEScg color space is not registered in `Color` by default"
+!!! failure "The ACEScg color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/cam16.md b/docs/src/markdown/colors/cam16.md
new file mode 100644
index 000000000..915cae055
--- /dev/null
+++ b/docs/src/markdown/colors/cam16.md
@@ -0,0 +1,106 @@
+# CAM16
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `cam16`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `j`  | [0, 100]
+    `a`  | [-90, 90]
+    `b`  | [-90, 90]
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![CAM16](../images/cam16.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the CAM16 color space.
+</figcaption>
+</figure>
+
+A color appearance model (CAM) is a mathematical model that seeks to describe the perceptual aspects of human color
+vision, i.e. viewing conditions under which the appearance of a color does not tally with the corresponding physical
+measurement of the stimulus source.
+
+CAM16 is a successor of CIECAM02 with various fixes and improvements. The model actually defines numerous different
+attributes:
+
+Name | Description
+---- | -----------
+J    | Lightness
+C    | Chroma
+h    | hue
+s    | saturation
+Q    | Brightness
+M    | Colorfulness
+H    | Hue Quadrature
+
+A color space can be constructed of using a subset of these attributes: JCh, JMh, Jsh, QCh, QMh, Qsh, etc. You can also
+construct Lab like spaces taking using the hue and either C, M, or s. The `cam16` color space in ColorAide represents
+a Jab configuration based off M (Colorfulness).
+
+[Learn more](https://doi.org/10.1002/col.22131).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`j`      | `lightness`
+`a`      |
+`b`      |
+
+## Input/Output
+
+The CAM16 UCS space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --cam16`:
+
+```css-color
+color(--cam16 j a b / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--cam16 j a b / a)` form.
+
+```playground
+Color("cam16", [46.026, 72.143, 37.385], 1)
+Color("cam16", [68.056, 13.955, 41.212], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.cam16 import CAM16
+
+class Color(Base): ...
+
+Color.register(CAM16())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Subclassing
+
+CAM16 is a color model that can vary due to viewing conditions. Factors such as surround (average/dim/dark), adapting
+luminance, background luminance, white point, and whether the eye is assumed to be fully adapted to the illuminant can
+all play into how the color model responds.
+
+If it is desired to create a CAM16 variant that uses different viewing conditions, the `CAM16` class can be subclassed.
+A new `Environment` object should be set to the class describing the viewing conditions.
+
+When subclassing, always use a new, unique name, like `cam16-custom` as other features or color spaces may depend on the
+`cam16` name converting a certain way.
+
+You can check out the source to learn more.
diff --git a/docs/src/markdown/colors/cam16_jmh.md b/docs/src/markdown/colors/cam16_jmh.md
new file mode 100644
index 000000000..44248f19f
--- /dev/null
+++ b/docs/src/markdown/colors/cam16_jmh.md
@@ -0,0 +1,114 @@
+# CAM16 JMh
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `cam16-jmh`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `j`  | [0, 100]
+    `m`  | [0, 105]
+    `h`  | [0, 360)
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![CAM16 JMh](../images/cam16-jmh.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the CAM16 JMh color space.
+</figcaption>
+</figure>
+
+A color appearance model (CAM) is a mathematical model that seeks to describe the perceptual aspects of human color
+vision, i.e. viewing conditions under which the appearance of a color does not tally with the corresponding physical
+measurement of the stimulus source.
+
+[CAM16](./cam16.md) is a successor of CIECAM02 with various fixes and improvements. The model actually defines numerous
+different attributes:
+
+Name | Description
+---- | -----------
+J    | Lightness
+C    | Chroma
+h    | hue
+s    | saturation
+Q    | Brightness
+M    | Colorfulness
+H    | Hue Quadrature
+
+A color space can be constructed of using a subset of these attributes: JCh, JMh, Jsh, QCh, QMh, Qsh, etc. You can also
+construct Lab like spaces taking using the hue and either C, M, or s. The `cam16-jmh` color space in ColorAide
+represents the JMh configuration.
+
+[Learn more](https://doi.org/10.1002/col.22131).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`j`      | `lightness`
+`m`      | `colorfulness`
+`h`      | `hue`
+
+## Input/Output
+
+The CAM16 JMh space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --cam16-jmh`:
+
+```css-color
+color(--cam16-jmh j m h / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--cam16-jmh j m h / a)` form.
+
+```playground
+Color("cam16-jmh", [59.178, 40.82, 21.153], 1)
+Color("cam16-jmh", [78.364, 9.6945, 28.629], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.cam16_jmh import CAM16JMh
+
+class Color(Base): ...
+
+Color.register(CAM16JMh())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Subclassing
+
+CAM16 JMh is a color model that can vary due to viewing conditions. Factors such as surround (average/dim/dark),
+adapting luminance, background luminance, white point, and whether the eye is assumed to be fully adapted to the
+illuminant can all play into how the color model responds.
+
+If it is desired to create a CAM16 JMh that uses different viewing conditions, the `CAM16JMh` class can be subclassed.
+A new `Environment` object should be set to the class describing the viewing conditions. As CAM16 JMh directly uses
+the CAM16 (Jab) color space as its base for conversion, that base would also need to be subclassed with the correct
+environment, or the the CAM16 JMh class would need to make the transform directly from XYZ. All the helper functions are
+available to pull this off if needed.
+
+Additionally, CAM16 JMh requires an `Achromatic` object that is responsible for determining at what point a color with
+a given lightness becomes achromatic. Knowing when a color becomes achromatic is important when interpolating in order
+to provide the best results. If the viewing conditions are tweaked via `Environment` the `Achromatic` object may require
+tuning.
+
+When subclassing, always use a new, unique name, like `cam16-custom` as other features or color spaces may depend on the
+`cam16-jmh` name converting a certain way.
+
+You can check out the source to learn more.
diff --git a/docs/src/markdown/colors/cam16_lcd.md b/docs/src/markdown/colors/cam16_lcd.md
new file mode 100644
index 000000000..36ec67a78
--- /dev/null
+++ b/docs/src/markdown/colors/cam16_lcd.md
@@ -0,0 +1,91 @@
+# CAM16 LCD
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `cam16-lcd`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `j`  | [0, 100]
+    `a`  | [-75, 75]
+    `b`  | [-75, 75]
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![CAM16 LCD](../images/cam16-lcd.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the CAM16 LCD color space.
+</figcaption>
+</figure>
+
+This is the LCD variant of the CAM16 UCS color space and is optimized for "large" color distancing. See
+[CAM16 UCS](./cam16_ucs.md) for more info.
+
+[Learn more](https://doi.org/10.1002/col.22131).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`j`      | `lightness`
+`a`      |
+`b`      |
+
+## Input/Output
+
+The CAM16 LCD space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --cam16-lcd`:
+
+```css-color
+color(--cam16-lcd j a b / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--cam16-lcd j a b / a)` form.
+
+```playground
+Color("cam16-lcd", [46.026, 81.254, 27.393], 1)
+Color("cam16-lcd", [68.056, 43.51, 71.293], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.cam16_ucs import CAM16LCD
+
+class Color(Base): ...
+
+Color.register(CAM16LCD())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Subclassing
+
+CAM16 LCD is a color model that can vary due to viewing conditions. Factors such as coefficients used (UCS/LCD/LCD),
+surround (average/dim/dark), adapting luminance, background luminance, white point, and whether the eye is assumed to be
+fully adapted to the illuminant can all play into how the color model responds.
+
+If it is desired to create a CAM16 LCD that uses different viewing conditions, the `CAM16LCD` class can be subclassed.
+A new `Environment` object should be set to the class describing the viewing conditions. As CAM16 LCD is directly uses
+the CAM16 (Jab) color space as its base for conversion, that base would also need to be subclassed with the correct
+environment, or the the CAM16 LCD class would need to make the transform directly from XYZ. All the helper functions are
+available to pull this off if needed.
+
+When subclassing, always use a new, unique name, like `cam16-custom` as other features or color spaces may depend on the
+`cam16-lcd` name converting a certain way.
+
+You can check out the source to learn more.
diff --git a/docs/src/markdown/colors/cam16_scd.md b/docs/src/markdown/colors/cam16_scd.md
new file mode 100644
index 000000000..a17998693
--- /dev/null
+++ b/docs/src/markdown/colors/cam16_scd.md
@@ -0,0 +1,91 @@
+# CAM16 SCD
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `cam16-scd`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `j`  | [0, 100]
+    `a`  | [-40, 40]
+    `b`  | [-40, 40]
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![CAM16 SCD](../images/cam16-scd.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the CAM16 SCD color space.
+</figcaption>
+</figure>
+
+This is the SCD variant of the CAM16 UCS color space and is optimized for "small" color distancing. See
+[CAM16 UCS](./cam16_ucs.md) for more info.
+
+[Learn more](https://doi.org/10.1002/col.22131).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`j`      | `lightness`
+`a`      |
+`b`      |
+
+## Input/Output
+
+The CAM16 SCD space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --cam16-scd`:
+
+```css-color
+color(--cam16-scd j a b / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--cam16-scd j a b / a)` form.
+
+```playground
+Color("cam16-scd", [59.178, 33.597, 17.41], 1)
+Color("cam16-scd", [78.364, 8.3723, 24.725], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.cam16_ucs import CAM16SCD
+
+class Color(Base): ...
+
+Color.register(CAM16SCD())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Subclassing
+
+CAM16 SCD is a color model that can vary due to viewing conditions. Factors such as coefficients used (UCS/SCD/LCD),
+surround (average/dim/dark), adapting luminance, background luminance, white point, and whether the eye is assumed to be
+fully adapted to the illuminant can all play into how the color model responds.
+
+If it is desired to create a CAM16 SCD that uses different viewing conditions, the `CAM16SCD` class can be subclassed.
+A new `Environment` object should be set to the class describing the viewing conditions. As CAM16 SCD is directly uses
+the CAM16 (Jab) color space as its base for conversion, that base would also need to be subclassed with the correct
+environment, or the the CAM16 SCD class would need to make the transform directly from XYZ. All the helper functions are
+available to pull this off if needed.
+
+When subclassing, always use a new, unique name, like `cam16-custom` as other features or color spaces may depend on the
+`cam16-scd` name converting a certain way.
+
+You can check out the source to learn more.
diff --git a/docs/src/markdown/colors/cam16_ucs.md b/docs/src/markdown/colors/cam16_ucs.md
new file mode 100644
index 000000000..cd5f7e0b9
--- /dev/null
+++ b/docs/src/markdown/colors/cam16_ucs.md
@@ -0,0 +1,98 @@
+# CAM16 UCS
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `cam16-ucs`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `j`  | [0, 100]
+    `a`  | [-50, 50]
+    `b`  | [-50, 50]
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![CAM16 UCS](../images/cam16-ucs.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the CAM16 UCS color space.
+</figcaption>
+</figure>
+
+A color appearance model (CAM) is a mathematical model that seeks to describe the perceptual aspects of human color
+vision, i.e. viewing conditions under which the appearance of a color does not tally with the corresponding physical
+measurement of the stimulus source.
+
+The CAM16 model is a successor of CIECAM02 with various fixes and improvements. The CAM16 UCS space takes the CAM16
+model and applies an additional nonlinear transformation to lightness and colorfulness so that a color difference metric
+ΔE can be based more closely on Euclidean distance. The `cam16-ucd` color space in ColorAide is based off CAM16 (Jab)
+which uses M (colorfulness) to derive the a and b values. There are also [SCD](./cam16_scd.md) and [LCD](./cam16_lcd.md)
+variants which optimize the spaces for "small" and "large" color distancing respectively.
+
+[Learn more](https://doi.org/10.1002/col.22131).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`j`      | `lightness`
+`a`      |
+`b`      |
+
+## Input/Output
+
+The CAM16 UCS space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --cam16-ucs`:
+
+```css-color
+color(--cam16-ucs j a b / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--cam16-ucs j a b / a)` form.
+
+```playground
+Color("cam16-ucs", [59.178, 40.82, 21.153], 1)
+Color("cam16-ucs", [78.364, 9.6945, 28.629], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide_extras.spaces.cam16_ucs import CAM16UCS
+
+class Color(Base): ...
+
+Color.register(CAM16UCS())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Subclassing
+
+CAM16 UCS is a color model that can vary due to viewing conditions. Factors such as coefficients used (UCS/SCD/LCD),
+surround (average/dim/dark), adapting luminance, background luminance, white point, and whether the eye is assumed to be
+fully adapted to the illuminant can all play into how the color model responds.
+
+If it is desired to create a CAM16 UCS that uses different viewing conditions, the `CAM16UCS` class can be subclassed.
+A new `Environment` object should be set to the class describing the viewing conditions. As CAM16 UCS is directly uses
+the CAM16 (Jab) color space as its base for conversion, that base would also need to be subclassed with the correct
+environment, or the the CAM16 UCS class would need to make the transform directly from XYZ. All the helper functions are
+available to pull this off if needed.
+
+When subclassing, always use a new, unique name, like `cam16-custom` as other features or color spaces may depend on the
+`cam16-ucs` name converting a certain way.
+
+You can check out the source to learn more.
diff --git a/docs/src/markdown/colors/cmy.md b/docs/src/markdown/colors/cmy.md
index ef3560634..5b58884db 100644
--- a/docs/src/markdown/colors/cmy.md
+++ b/docs/src/markdown/colors/cmy.md
@@ -1,6 +1,6 @@
 # CMY
 
-!!! fail "The CMY color space is not registered in `Color` by default"
+!!! failure "The CMY color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/cmyk.md b/docs/src/markdown/colors/cmyk.md
index e8f3c6570..155e7d226 100644
--- a/docs/src/markdown/colors/cmyk.md
+++ b/docs/src/markdown/colors/cmyk.md
@@ -1,6 +1,6 @@
 # CMYK
 
-!!! fail "The CMYK color space is not registered in `Color` by default"
+!!! failure "The CMYK color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/din99o.md b/docs/src/markdown/colors/din99o.md
index a23d8d8fe..206a1e084 100644
--- a/docs/src/markdown/colors/din99o.md
+++ b/docs/src/markdown/colors/din99o.md
@@ -1,6 +1,6 @@
 # DIN99o
 
-!!! fail "The DIN99o color space is not registered in `Color` by default"
+!!! failure "The DIN99o color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/hct.md b/docs/src/markdown/colors/hct.md
new file mode 100644
index 000000000..04ead42c6
--- /dev/null
+++ b/docs/src/markdown/colors/hct.md
@@ -0,0 +1,125 @@
+# HCT
+
+<div class="info-container" markdown="1">
+!!! info inline end "Properties"
+
+    **Name:** `hct`
+
+    **White Point:** D65
+
+    **Coordinates:**
+
+    Name | Range^\*^
+    ---- | -----
+    `h`  | [0, 360)
+    `c`  | [0, 145]
+    `t`  | [0, 100]
+
+    ^\*^ Space is not bound to the range and is only used as a reference to define percentage inputs/outputs in
+    relation to the Display P3 color space.
+
+<figure markdown>
+
+![HCT](../images/hct.png)
+
+<figcaption markdown>
+The sRGB gamut represented within the HCT color space.
+</figcaption>
+</figure>
+
+The HCT color space is Google's attempt at a perceptually accurate color system. Essentially, it is two color spaces
+glued together. 'H' (hue) and 'C' (chroma) come from the CAM16 color appearance model and 'T' (tone) is the lightness
+from the CIELAB (D65) color space. The idea was to take the more consistent perceptual hues from CAM16 and use the
+better lightness prediction found in CIELAB, so a new space was created by literally sticking the components together.
+
+In general, CAM16 is not a cheap color space to calculate, and all the glue to hold the components together makes it a
+bit more expensive, but considering the use case that it was designed for, creating better color schemes with decent
+contrast, it has attractive benefits.
+
+[Learn more](https://material.io/blog/science-of-color-design).
+</div>
+
+## Channel Aliases
+
+Channels | Aliases
+-------- | -------
+`h`      | `hue`
+`c`      | `chroma`
+`t`      | `tone`, `lightness`
+
+## Input/Output
+
+The HCT space is not currently supported in the CSS spec, the parsed input and string output formats use
+the `#!css-color color()` function format using the custom name `#!css-color --hct`:
+
+```css-color
+color(--hct h c t / a)  // Color function
+```
+
+The string representation of the color object and the default string output use the
+`#!css-color color(--hct h c t / a)` form.
+
+```playground
+Color("hct", [27.41, 113.36, 53.237], 1)
+Color("hct", [71.257, 60.528, 74.934], 1).to_string()
+```
+
+## Registering
+
+```py
+from coloraide import Color as Base
+from coloraide.spaces.hct import HCT
+
+class Color(Base): ...
+
+Color.register(HCT())
+```
+
+<style>
+.info-container {display: inline-block;}
+</style>
+
+## Tonal Palettes
+
+One of the applications of HCT is generating tonal palettes. When coupled with ColorAide's [∆E~hct~](../distance.md#delta-e-hct)
+distancing algorithm and the [`hct-chroma` gamut mapping algorithm](../gamut.md#hct-chroma), we can produce tonal
+palettes just like in Material's color utilities.
+
+```playground
+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])
+```
+
+Results in our library may be slightly different in some cases compared to Material's color utilities. This is because
+we have implemented the library as _described_, we did not port their implementation and so we do not share the exact
+same quirks of their implementation.
+
+Material uses different precision for their transformation matrices between sRGB and XYZ. The exact chroma reduction
+algorithms are likely different, though the end result is very similar.
+
+Consider the example below. We've taken the results from Material's tests. We generate the same tonal palettes and
+output both as HCT. We can compare which hues stay overall more constant, which chroma gets reduced more than others,
+and which hue and tone are less affected by the gamut mapping. Can you tell which is doing the job the _best_?
+
+```playground
+def tonal_palette(c):
+    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
+    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma') for tone in tones]
+
+material1 = ['#000000', '#00006e', '#0001ac',
+             '#0000ef', '#343dff', '#5a64ff',
+             '#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])
+
+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])
+```
diff --git a/docs/src/markdown/colors/hpluv.md b/docs/src/markdown/colors/hpluv.md
index e4a71c8e0..d76344006 100644
--- a/docs/src/markdown/colors/hpluv.md
+++ b/docs/src/markdown/colors/hpluv.md
@@ -1,6 +1,6 @@
 # HPLuv
 
-!!! fail "The HPLuv color space is not registered in `Color` by default"
+!!! failure "The HPLuv color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/hsi.md b/docs/src/markdown/colors/hsi.md
index 59bbfc99b..7f4fd9a16 100644
--- a/docs/src/markdown/colors/hsi.md
+++ b/docs/src/markdown/colors/hsi.md
@@ -1,6 +1,6 @@
 # HSI
 
-!!! fail "The HSI color space is not registered in `Color` by default"
+!!! failure "The HSI color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/hsluv.md b/docs/src/markdown/colors/hsluv.md
index 8babee202..09cb8483a 100644
--- a/docs/src/markdown/colors/hsluv.md
+++ b/docs/src/markdown/colors/hsluv.md
@@ -1,6 +1,6 @@
 # HSLuv
 
-!!! fail "The HSLuv color space is not registered in `Color` by default"
+!!! failure "The HSLuv color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/hsv.md b/docs/src/markdown/colors/hsv.md
index f30be02d1..ecf582558 100644
--- a/docs/src/markdown/colors/hsv.md
+++ b/docs/src/markdown/colors/hsv.md
@@ -13,9 +13,9 @@
 
     Name | Range
     ---- | -----
-    `h`    | [0, 360)
-    `s`    | [0, 1]
-    `v`    | [0, 1]
+    `h`  | [0, 360)
+    `s`  | [0, 1]
+    `v`  | [0, 1]
 
 <figure markdown>
 
diff --git a/docs/src/markdown/colors/hunter_lab.md b/docs/src/markdown/colors/hunter_lab.md
index 894c974c0..a54764d84 100644
--- a/docs/src/markdown/colors/hunter_lab.md
+++ b/docs/src/markdown/colors/hunter_lab.md
@@ -1,6 +1,6 @@
 # Hunter Lab
 
-!!! fail "The Hunter Lab color space is not registered in `Color` by default"
+!!! failure "The Hunter Lab color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/ictcp.md b/docs/src/markdown/colors/ictcp.md
index 784361964..1fe4b1549 100644
--- a/docs/src/markdown/colors/ictcp.md
+++ b/docs/src/markdown/colors/ictcp.md
@@ -1,6 +1,6 @@
 # ICtCp
 
-!!! fail "The ICtCp color space is not registered in `Color` by default"
+!!! failure "The ICtCp color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/igpgtg.md b/docs/src/markdown/colors/igpgtg.md
index 2703185c2..32b218bbc 100644
--- a/docs/src/markdown/colors/igpgtg.md
+++ b/docs/src/markdown/colors/igpgtg.md
@@ -1,6 +1,6 @@
 # IgPgTg
 
-!!! fail "The IgPgTg color space is not registered in `Color` by default"
+!!! failure "The IgPgTg color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/index.md b/docs/src/markdown/colors/index.md
index ecde4adb2..928e12382 100644
--- a/docs/src/markdown/colors/index.md
+++ b/docs/src/markdown/colors/index.md
@@ -66,6 +66,13 @@ flowchart TB
 
     xyz-d65 --- lab-d65 --- lch-d65
 
+    xyz-d65 --- cam16 --- cam16-jmh
+        cam16 --- cam16-ucs
+        cam16 --- cam16-scd
+        cam16 --- cam16-lcd
+
+    xyz-d65 --- hct
+
     xyz-d65 --- jzazbz --- jzczhz
 
     xyz-d65 --- ipt
@@ -134,6 +141,12 @@ flowchart TB
     acescg(ACEScg)
     acescc(ACEScc)
     acescct(ACEScct)
+    cam16(CAM16)
+    cam16-jmh(CAM16 JMh)
+    cam16-ucs(CAM16 UCS)
+    cam16-scd(CAM16 SCD)
+    cam16-lcd(CAM16 LCD)
+    hct(HCT)
 
     click xyz-d65 "./xyz_d65/" _self
     click xyz-d50 "./xyz_d50/" _self
@@ -183,4 +196,10 @@ flowchart TB
     click acescg "./acescg/" _self
     click acescc "./acescc/" _self
     click acescct "./acescct/" _self
+    click cam16 "./cam16/" _self
+    click cam16-jmh "./cam16_jmh/" _self
+    click cam16-ucs "./cam16_ucs/" _self
+    click cam16-scd "./cam16_scd/" _self
+    click cam16-lcd "./cam16_lcd/" _self
+    click hct "./hct/" _self
 ```
diff --git a/docs/src/markdown/colors/ipt.md b/docs/src/markdown/colors/ipt.md
index 2197056fc..b13163c05 100644
--- a/docs/src/markdown/colors/ipt.md
+++ b/docs/src/markdown/colors/ipt.md
@@ -1,6 +1,6 @@
 # IPT
 
-!!! fail "The IPT color space is not registered in `Color` by default"
+!!! failure "The IPT color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/jzazbz.md b/docs/src/markdown/colors/jzazbz.md
index 5c19670cc..7406ee278 100644
--- a/docs/src/markdown/colors/jzazbz.md
+++ b/docs/src/markdown/colors/jzazbz.md
@@ -1,6 +1,6 @@
 # Jzazbz
 
-!!! fail "The Jzazbz color space is not registered in `Color` by default"
+!!! failure "The Jzazbz color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/jzczhz.md b/docs/src/markdown/colors/jzczhz.md
index 987a9031e..6981fb91b 100644
--- a/docs/src/markdown/colors/jzczhz.md
+++ b/docs/src/markdown/colors/jzczhz.md
@@ -1,6 +1,6 @@
 # JzCzhz
 
-!!! fail "The JzCzhz color space is not registered in `Color` by default"
+!!! failure "The JzCzhz color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/lch99o.md b/docs/src/markdown/colors/lch99o.md
index 9d95fe904..50b9aa55a 100644
--- a/docs/src/markdown/colors/lch99o.md
+++ b/docs/src/markdown/colors/lch99o.md
@@ -1,6 +1,6 @@
 # DIN99o LCh
 
-!!! fail "The DIN99o LCh color space is not registered in `Color` by default"
+!!! failure "The DIN99o LCh color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/lchuv.md b/docs/src/markdown/colors/lchuv.md
index e0d946278..62a240409 100644
--- a/docs/src/markdown/colors/lchuv.md
+++ b/docs/src/markdown/colors/lchuv.md
@@ -1,6 +1,6 @@
 # LCh~uv~
 
-!!! fail "The LCH~uv~ color space is not registered in `Color` by default"
+!!! failure "The LCH~uv~ color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/luv.md b/docs/src/markdown/colors/luv.md
index 9ac70f74f..80726a975 100644
--- a/docs/src/markdown/colors/luv.md
+++ b/docs/src/markdown/colors/luv.md
@@ -1,6 +1,6 @@
 # Luv
 
-!!! fail "The Luv color space is not registered in `Color` by default"
+!!! failure "The Luv color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/okhsl.md b/docs/src/markdown/colors/okhsl.md
index f63d86478..129831c7b 100644
--- a/docs/src/markdown/colors/okhsl.md
+++ b/docs/src/markdown/colors/okhsl.md
@@ -1,6 +1,6 @@
 # Okhsl
 
-!!! fail "The Okhsl color space is not registered in `Color` by default"
+!!! failure "The Okhsl color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/okhsv.md b/docs/src/markdown/colors/okhsv.md
index 22789a364..9388b966a 100644
--- a/docs/src/markdown/colors/okhsv.md
+++ b/docs/src/markdown/colors/okhsv.md
@@ -1,6 +1,6 @@
 # Okhsv
 
-!!! fail "The Okhsv color space is not registered in `Color` by default"
+!!! failure "The Okhsv color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/orgb.md b/docs/src/markdown/colors/orgb.md
index b8e24879e..7a2de24c7 100644
--- a/docs/src/markdown/colors/orgb.md
+++ b/docs/src/markdown/colors/orgb.md
@@ -1,6 +1,6 @@
 # oRGB
 
-!!! fail "The oRGB color space is not registered in `Color` by default"
+!!! failure "The oRGB color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/prismatic.md b/docs/src/markdown/colors/prismatic.md
index 7e470b774..5e5fe03ca 100644
--- a/docs/src/markdown/colors/prismatic.md
+++ b/docs/src/markdown/colors/prismatic.md
@@ -1,6 +1,6 @@
 # Prismatic
 
-!!! fail "The oRGB color space is not registered in `Color` by default"
+!!! failure "The oRGB color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/rec2100_hlg.md b/docs/src/markdown/colors/rec2100_hlg.md
index b52edada1..16b48b20c 100644
--- a/docs/src/markdown/colors/rec2100_hlg.md
+++ b/docs/src/markdown/colors/rec2100_hlg.md
@@ -1,6 +1,6 @@
 # REC. 2100 HLG
 
-!!! fail "The Rec. 2100 HLG is not registered in `Color` by default"
+!!! failure "The Rec. 2100 HLG is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/rec2100_pq.md b/docs/src/markdown/colors/rec2100_pq.md
index 42a161b70..a846c23dc 100644
--- a/docs/src/markdown/colors/rec2100_pq.md
+++ b/docs/src/markdown/colors/rec2100_pq.md
@@ -1,6 +1,6 @@
 # REC. 2100 PQ
 
-!!! fail "The Rec. 2100 PQ is not registered in `Color` by default"
+!!! failure "The Rec. 2100 PQ is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/rlab.md b/docs/src/markdown/colors/rlab.md
index 1b98a5857..26229754a 100644
--- a/docs/src/markdown/colors/rlab.md
+++ b/docs/src/markdown/colors/rlab.md
@@ -1,6 +1,6 @@
 # RLAB
 
-!!! fail "The RLAB color space is not registered in `Color` by default"
+!!! failure "The RLAB color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/colors/xyy.md b/docs/src/markdown/colors/xyy.md
index 35611d82a..042e4cc76 100644
--- a/docs/src/markdown/colors/xyy.md
+++ b/docs/src/markdown/colors/xyy.md
@@ -1,6 +1,6 @@
 # xyY
 
-!!! fail "The xyY color space is not registered in `Color` by default"
+!!! failure "The xyY color space is not registered in `Color` by default"
 
 <div class="info-container" markdown>
 !!! info inline end "Properties"
diff --git a/docs/src/markdown/contrast.md b/docs/src/markdown/contrast.md
index 611fbc947..d54c85cbb 100644
--- a/docs/src/markdown/contrast.md
+++ b/docs/src/markdown/contrast.md
@@ -33,42 +33,82 @@ Color("blue").luminance()
 
 ## Contrast
 
-There have actually been numerous approaches to determining reliable contrast. ColorAide currently only implements the
-color contrast ratio as outlined in the [WCAG 2.1 spec](https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio), but has done
-so as a plugin to allow for expanding implementations in the future to allow for more reliable approaches as the WCAG
-2.1 approach is not without flaws.
+Chromatic contrast refers to the ability to see the difference of a colored object against a colored background. This
+can be very important when it comes to visual design and other fields. Determining contrast has been explored in many
+different ways and, depending on the application, there may be approaches to contrast that are more favorable. In the
+context of the web, contrast often refers to the ability to see text on a colored background and is of particular
+importance to those that suffer from visual impairments or disabilities.
 
 It should be noted that as we talk about contrast, we will refer to the colors as the **text** and **background** as
-this is generally the context in which such a function is used. The **text** is always the calling color and the
-**background** is the input parameter. Not all contrast algorithms care about such details, but it is important to note
-as some future algorithms assuredly will.
+this is often the context in which such a function is used. As far as ColorAide is concerned, the **text** is always the
+calling color and the **background** is the input parameter. It is important to note this as not all contrast algorithms
+are symmetrical, and can differ depending which color is referenced as **text**, and which is referenced as the
+**background**.
 
-```py
-text.contrast(background)
+At this time, ColorAide only offers a handful of contrast approaches, and they can be by using the `contrast()` method.
+
+```playground
+Color("blue").contrast("red")
+```
+
+To select different contrast methods, simply use the `method` parameter.
+
+```playground
+Color("blue").contrast("red", method='wcag21')
 ```
 
-While in all normal circumstances a negative luminance should not occur, if one does occur, the luminance will be
-clamped to zero.
+Methods  | Symmetrical         | Description
+-------- | ------------------  | -----------
+`wcag21` | :octicons-check-16: | WCAG 2.1 contrast ratio.
+`lstar`  | :octicons-check-16: | Color difference between two tones in the HCT color space.
+
+
+### WCAG 2.1 Contrast Ratio
+
+!!! success "The WCAG 2.1 contrast ratio is registered in `Color` by default"
+
+ColorAide implements the color contrast ratio as outlined in the [WCAG 2.1 spec](https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio).
+This is currently, the default contrast method. It is not without fault, but is currently the standard outlined for the
+web.
+
+The contrast ratio as outlined in the WCAG 2.1 specification is simply the ratio of color luminance from a foreground
+and background color and is very easy to determine if you are able to acquire the luminance of the two colors.
 
 ```py
 # Where `l1` is the lighter luminance and `l2` the darker
 contrast_ratio = (l1 + 0.05) / (l2 + 0.05)
 ```
-To get the this contrast ratio between two colors, simply pass in the second color:
+
+This method can be used by specifying `wcag21` as the contrast method.
 
 ```playground
-Color("blue").contrast("red")
+Color("blue").contrast("red", method='wcag21')
 ```
 
-!!! warning "Distancing and Symmetry"
-    It should be noted that not all contrast algorithms are symmetrical. Some are order dependent.
+### Lstar Lightness Difference
 
-Methods  | Symmetrical         | Description
--------- | ------------------  | -----------
-`wcag21` | :octicons-check-16: | WCAG 2.1 contrast ratio.
+!!! failure "The Lstar contrast method is **not** registered in `Color` by default"
+
+Google's Material Design uses a new color space called [HCT](./colors/hct.md). It uses the hue and chroma from
+[CAM16](./colors/cam16.md) and the tone/lightness from CIELab. For contrast, they determined using tones that are
+"far enough apart" in the HCT color space was a good indication of sufficient contrast. Since HCT tone is exactly the
+same as CIELab's lightness (also known as L\*), we've referred to this approach as Lstar.
 
-To use different methods, simply specify the method via the `method` parameter:
+Lstar's color difference approach to contrast is quite simple, it's literally the difference between two color's
+lightness as provided by CIELab. This method does not care which color is text or background.
 
 ```playground
-Color("blue").contrast("red", method='wcag21')
+Color('hct', [30, 20, 70]).contrast(Color('hct', [30, 20, 50]), method='lstar')
+```
+
+In order to use this contrast method, the plugin must be registered. This assumes the CIELab color space is currently
+registered, which it is by default.
+
+```py
+from coloraide import Color as Base
+from coloraide.contrast.lstar import LstarContrast
+
+class Color(Base): ...
+
+Color.register(LstarContrast())
 ```
diff --git a/docs/src/markdown/distance.md b/docs/src/markdown/distance.md
index b9b28915a..06d89126c 100644
--- a/docs/src/markdown/distance.md
+++ b/docs/src/markdown/distance.md
@@ -44,28 +44,211 @@ Color("red").delta_e("blue", method="2000")
 !!! warning "Distancing and Symmetry"
     It should be noted that not all distancing algorithms are symmetrical. Some are order dependent.
 
-Below are all the supported ∆E methods that are included in the default `coloraide.Color` object. Follow relevant links
-to read the specs and find out more about a given ∆E method.
+### Delta E CIE76
+
+!!! success "The ∆E~ab~ distancing algorithm is registered in `Color` by default"
 
 Delta\ E                                 | Symmetrical           | Name            | Parameters
 ---------------------------------------- | --------------------- | --------------- | ----------
 [∆E^\*^~ab~][de76]\ (CIE76)              | :octicons-check-16:   | `76`            |
+
+One of the first approaches to color distancing and is actually just Euclidean distancing in the [CIELab](./colors/lab_d65.md)
+color space.
+
+### Delta E CMC (1984)
+
+!!! success "The ∆E~cmc~ distancing algorithm is registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | ----------
 [∆E^\*^~cmc~][decmc]\ (CMC\ l:c\ (1984)) | :octicons-check-16:   | `cmc`           | `l=2, c=1`
+
+Delta E CMC is based on the [CIELCh](./colors/lch_d65.md) color space. The CMC calculation mathematically defines an
+ellipsoid around the standard color with semi-axis corresponding to hue, chroma and lightness.
+
+Parameter | Acceptability | Perceptibility
+--------- | ------------- | --------------
+`l`       | 2             | 1
+`c`       | 1             | 1
+
+### Delta E CIE94
+
+!!! success "The ∆E~94~ distancing algorithm is registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | ----------
 [∆E^\*^~94~][de94]\ (CIE94)              | :octicons-x-16:       | `94`            | `kl=1, k1=0.045, k2=0.015`
+
+The [1976](#delta-e-cie76) definition was extended to address perceptual non-uniformities, while retaining the
+[CIELab](./colors/lab_d65.md) color space, by the introduction of application-specific weights derived from an
+automotive paint test's tolerance data.
+
+Parameter | Graphic\ Arts | Textiles
+--------- | ------------- | --------
+`kl`      | 1             | 2
+`k1`      | 0.045         | 0.048
+`k2`      | 0.015         | 0.014
+
+### Delta E CIEDE2000
+
+!!! success "The ∆E~00~ distancing algorithm is registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | ----------
 [∆E^\*^~00~][de2000]\ (CIEDE2000)        | :octicons-check-16:   | `2000`          | `kl=1, kc=1, kh=1`
+
+Since the 1994 definition did not adequately resolve the perceptual uniformity issue, the CIE refined their definition,
+adding five corrections:
+
+- A hue rotation term (RT), to deal with the problematic blue region (hue angles in the neighborhood of 275°)
+- Compensation for neutral colors (the primed values in the LCh differences)
+- Compensation for lightness (SL)
+- Compensation for chroma (SC)
+- Compensation for hue (SH)
+
+### Delta E HyAB
+
+!!! success "The ∆E~HyAB~ distancing algorithm is registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | ----------
 [∆E~HyAB~][dehyab]\ (HyAB)               | :octicons-check-16:   | `hyab`          | `space="lab-d65"`
+
+A combination of a Euclidean metric in hue and chroma with a city‐block metric to incorporate lightness differences. It
+can be used on any Lab like color space, the default being CIELab D65.
+
+### Delta E OK
+
+!!! success "The ∆E~ok~ distancing algorithm is registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | ----------
 ∆E~ok~                                   | :octicons-check-16:   | `ok`            | `scalar=1`
 
-The following are also available, but they must be manually registered by creating a
-[custom class](./color.md#custom-color-classes), or can be accessed by use `coloraide.everything.ColorAll` instead of
-`coloraide.Color`. The associated color space must be registered as well.
+A color distancing algorithm that performs Euclidean distancing in the [Oklab](./colors/oklab.md) color space. This is
+used in the [OkLCh Chroma gamut mapping algorithm](./gamut.md#oklch-chroma). The `scalar` parameter allows you to scale
+the result up if desired.
+
+### Delta E ITP
+
+!!! failure "The ∆E~itp~ distancing algorithm is **not** registered in `Color` by default"
 
 Delta\ E                                 | Symmetrical           | Name            | Parameters
 ---------------------------------------- | --------------------- | --------------- | --------------------
 [∆E~itp~][deitp]\ (ICtCp)                | :octicons-check-16:   | `itp`           | `scalar=720`
+
+Various algorithms are designed for and perform decently in the SDR range, but ∆E~itp~ aims to provide good
+distancing in the HDR range using the [ICtCp](./colors/ictcp.md) color space (must be registered in order to use ∆E~itp~).
+It was determined that a `scalar` of 240 was more comparable to the average [∆E~00~](#delta-e-2000) result from the JND
+data set and 720 equates them to a JND.
+
+Both the ICtCp color space and the ∆E algorithm must be registered to use.
+
+```py
+from coloraide import Color as Base
+from coloraide.distance.delta_e_itp import DEITP
+from coloraide.spaces.ictcp import ICtCp
+
+class Color(Base): ...
+
+Color.register([ICtCp(), DEITP()])
+```
+
+### Delta E Z
+
+!!! failure "The ∆E~z~ distancing algorithm is **not** registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | --------------------
 [∆E~z~][dez]\ (Jzazbz)                   | :octicons-check-16:   | `jz`            |
+
+Performs Euclidean distancing in the [Jzazbz](./colors/jzazbz.md) color space, useful for the HDR range.
+
+Both the Jzazbz color space and the ∆E algorithm must be registered to use.
+
+```py
+from coloraide import Color as Base
+from coloraide.distance.delta_e_z import DEZ
+from coloraide.spaces.jzazbz import Jzazbz
+
+class Color(Base): ...
+
+Color.register([Jzazbz(), DEZ()])
+```
+
+### Delta E 99o
+
+!!! failure "The ∆E~99o~ distancing algorithm is **not** registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | --------------------
 [∆E~99o~][de99o]\ (DIN99o)               | :octicons-check-16:   | `99o`           |
 
+∆E~99o~ performs Euclidean distancing in the [DIN99o](./colors/din99o.md) color space.
+
+Both the DIN99o color space and the ∆E algorithm must be registered to use.
+
+```py
+from coloraide import Color as Base
+from coloraide.distance.delta_e_99o import DE99o
+from coloraide.spaces.din99o import DIN99o
+
+class Color(Base): ...
+
+Color.register([DIN99o(), DE99o()])
+```
+
+### Delta E CAM16
+
+!!! failure "The ∆E~cam16~ distancing algorithm is **not** registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | --------------------
+∆E~cam16~\ (CAM16)                       | :octicons-check-16:   | `cam16`         | `model='ucs'`
+
+The [CAM16 UCS](./colors/cam16_ucs.md) uniform color space applies an additional nonlinear transformation to lightness
+and colorfulness so that a color difference metric ΔE can be based more closely on Euclidean distance. This algorithm
+performs distancing using the CAM16 UCS color space. If desired `model` can be changed to use the SCD or LCD model for
+"small" and "large" distancing respectively
+
+Parameter | Default | Small | Large
+--------- | ------- | ----- | -----
+`model`   | `ucs`   | `scd` | `lcd`
+
+The one or more of the CAM16 (UCS/SCD/LCD) color spaces and the ∆E algorithm must be registered to use.
+
+```py
+from coloraide import Color as Base
+from coloraide.distance.delta_e_cam16 import DECAM16
+from coloraide.spaces.cam16_ucs import CAM16UCS, CAM16SCD, CAM16LCD
+
+class Color(Base): ...
+
+Color.register([CAM16UCS(), CAM16SCD(), CAM16LCD(), DECAM16()])
+```
+
+### Delta E HCT
+
+!!! failure "The ∆E~hct~ distancing algorithm is **not** registered in `Color` by default"
+
+Delta\ E                                 | Symmetrical           | Name            | Parameters
+---------------------------------------- | --------------------- | --------------- | --------------------
+∆E~HCT~                                  | :octicons-check-16:   | `hct`           |
+
+This takes the HCT color space C and H components (CAM16's M and h) and converts them to CAM16 UCS M and h, and applies
+Euclidean distancing on them along with the T component (CIELab's L\*). This is necessary for the
+[HCT Chroma gamut mapping approach](./gamut.md/#hct-chroma).
+
+```py
+from coloraide import Color as Base
+from coloraide.distance.delta_e_hct import DEHCT
+from coloraide.spaces.HCT import HCT
+
+class Color(Base): ...
+
+Color.register([HCT(), DEHCT()])
+```
+
 ## Finding Closest Color
 
 ColorAide implements a simple way to find the closest color, given a list of colors, to another color. The method is
diff --git a/docs/src/markdown/gamut.md b/docs/src/markdown/gamut.md
index 949057e13..ced15f9ee 100644
--- a/docs/src/markdown/gamut.md
+++ b/docs/src/markdown/gamut.md
@@ -150,7 +150,7 @@ Color('color(display-p3 1 1 0)').clip('srgb')
 ```
 
 The `#!py3 fit()` method, is the generic gamut mapping method that exposes access to all the different gamut mapping
-methods available. By default, `#!py3 fit()` uses a more advanced method of color mapping that tries to preserve hue and
+methods available. By default, `#!py3 fit()` uses a more advanced method of gamut mapping that tries to preserve hue and
 lightness, hue being the attribute the human eye is most sensitive to. If desired, a user can also specify any currently
 registered gamut mapping algorithm via the `method` parameter.
 
@@ -189,24 +189,118 @@ c1.in_gamut()
 
 There are actually many different ways to gamut map a color. Some are computationally expensive, some are quite simple,
 and many do really good in some cases and not so well in others. There is probably no perfect gamut mapping method, but
-some are better than others. ColorAide currently only offers a couple simple methods to gamut map.
+some are better than others.
 
-Method         | Description
--------------- | -----------
-`clip`         | Simple, naive clipping.
-`lch-chroma`   | Uses a combination of chroma reduction and MINDE in the CIELCh color space to bring a color into gamut. This is the default method used.
-`oklch-chroma` | Like `lch-chroma`, but uses the OkLCh color space instead. This is currently what the [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch) recommends.
+### Clip
 
-!!! note "CSS Level 4 Gamut Mapping"
-    The CSS [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch) currently recommends using
-    OkLCh as the gamut mapping color space. `oklch-chroma` is our implementation of the CSS Level 4 color specification.
+!!! success "The `clip` gamut mapping is registered in `Color` by default and cannot be unregistered"
 
-    OkLCh is a very new color space to be used in the field of gamut mapping. While CIELCh is not perfect, its weakness
-    are known. OkLCh does seem to have certain quirks of its own, and may have more that have yet to be discovered.
-    While we have not made `oklch-chroma` our default yet, we have exposed the algorithm so users can begin exploring
-    it.
+Clipping is a simple and naive approach to gamut mapping. If the color space is bounded by a gamut, clip will compare
+each channel's value against the bounds for that channel set the value to the limit it exceeds.
 
-### Why Not Just Clip?
+Clip can be performed via `fit` by using the method name `clip` or by using the `clip()` method.
+
+```playground
+c = Color('srgb', [2, 1, 1.5])
+c.fit(method='clip')
+c = Color('srgb', [2, 1, 1.5])
+c.clip()
+```
+
+Clipping is unique to all other clipping methods in that it has its own dedicated method `clip()` method and that its
+method name `clip` is reserved. While not always the best approach for gamut mapping in general, clip is very important
+to some other gamut mapping and has specific cases where its speed and simplicity are of great value.
+
+### LCh Chroma
+
+!!! success "The `lch-chroma` gamut mapping is registered in `Color` by default"
+
+LCh Chroma uses a combination of chroma reduction and MINDE in the CIELCh color space to bring a color into gamut. By
+reducing chroma in the CIELCh color space, LCh Chroma can hold hue and lightness in the LCh color space relatively
+constant. This is currently the default method used.
+
+The algorithm generally works by performing both clipping and chroma reduction. Using bisection, the chroma is reduced
+and then the chroma reduced color is clipped. Using ∆E~2000~, the distance between the chroma reduced color and the
+clipped chroma reduced color is measured. If the resultant distance falls within the specified threshold, the clipped
+color is returned.
+
+Computationally, LCh Chroma is slower to compute than clipping, but generally provides better results. LCh, is not
+necessarily the best perceptual color space available, but it is generally well understood color space that has been
+available a long time. It does suffer from a purple shift when dealing with blue colors, but generally can generally
+colors far out of gamut in a reasonable manner.
+
+While CSS has currently proposed LCh Chroma reduction to be done with OkLCh, and we do offer an [OkLCh variant](#oklch-chroma),
+we currently still use CIELCh as the default until OkLCh can be evaluated more fully.
+
+LCh Chroma is the default gamut mapping algorithm by default, unless otherwise changed, and can be performed by simply
+calling `fit()` or by calling `fit(method='lch-chroma')`.
+
+```playground
+c = Color('srgb', [2, -1, 0])
+c.fit(method='clip')
+c = Color('srgb', [2, -1, 0])
+c.fit(method='clip')
+```
+
+### OkLCh Chroma
+
+!!! success "The `lch-chroma` gamut mapping is registered in `Color` by default"
+
+The CSS [CSS Color Level 4 specification](https://drafts.csswg.org/css-color/#binsearch) currently recommends using
+OkLCh as the gamut mapping color space. OkLCh Chroma is performed exactly like [LCh Chroma](#lch-chroma) except that it
+uses the perceptually uniform OkLCh color space as the LCh color space of choice.
+
+OkLCh has the advantage of doing a better job at holding hues uniform than CIELCh.
+
+```playground
+c = Color('srgb', [2, -1, 0])
+c.fit(method='oklch-chroma')
+```
+
+OkLCh is a very new color space to be used in the field of gamut mapping. While CIELCh is not perfect, its weakness are
+known. OkLCh does seem to have certain quirks of its own, and may have more that have yet to be discovered. While we
+have not made `oklch-chroma` our default yet, we have exposed the algorithm so users can begin exploring it.
+
+### HCT Chroma
+
+!!! failure "The `hct-chroma` gamut mapping is **not** registered in `Color` by default"
+
+Much like the other LCh chroma reduction algorithms, HCT Chroma performs gamut mapping exactly like
+[LCh Chroma](#lch-chroma) with the exception that it uses the HCT color space as the working LCh color space.
+
+Google's Material Design uses a new color space called [HCT](./colors/hct.md). It uses the hue and chroma from
+[CAM16 (JMh)](./colors/cam16.md) space and the tone/lightness from the [CIELab](./colors/lab_d65.md) space. HCT takes
+advantage of the good hue preservation of CAM16 and has the better lightness predictability of CIELab. Using these
+characteristics, the color space is adept at generating tonal palettes with predictable lightness. This makes it easier
+to construct UIs with decent contrast. But to do this well, you must work in HCT and gamut map in HCT. For this reason,
+the HCT Chroma gamut mapping method was added.
+
+HCT Chroma is computationally the most expensive gamut mapping method that is offered. Since the color space used is
+based on the already computationally expensive CAM16 color space, and is made more expensive by blending that color
+space with CIELab, it is not the most performant approach, but when used in conjunction with the HCT color space, it
+can allow creating good tonal palettes:
+
+```playground
+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])
+```
+
+To HCT Chroma plugin is not registered by default, but can be added by subclassing `Color`. You must register the
+[∆E~hct~](./distance.md#delta-e-hct) distancing algorithm and the HCT color space as well.
+
+```py
+from coloraide import Color as Base
+from coloraide.gamut.fit_hct_chroma import HCTChroma
+from coloraide.distance.delta_e_hct import DEHCT
+from coloraide.spaces.hct import HCT
+
+class Color(Base): ...
+
+Color.register([HCT(), DEHCT(), HCTChroma()])
+```
+
+## Why Not Just Clip?
 
 In the past, clipping has been the default way in which out of gamut colors have been handled in web browsers. It is
 fast, and has generally been fine as most browsers have been constrained to using sRGB. But as modern browsers begin to
diff --git a/docs/src/markdown/images/cam16-jmh.png b/docs/src/markdown/images/cam16-jmh.png
new file mode 100644
index 000000000..b84fe62b1
Binary files /dev/null and b/docs/src/markdown/images/cam16-jmh.png differ
diff --git a/docs/src/markdown/images/cam16-lcd.png b/docs/src/markdown/images/cam16-lcd.png
new file mode 100644
index 000000000..59df14ce8
Binary files /dev/null and b/docs/src/markdown/images/cam16-lcd.png differ
diff --git a/docs/src/markdown/images/cam16-scd.png b/docs/src/markdown/images/cam16-scd.png
new file mode 100644
index 000000000..b641cae0d
Binary files /dev/null and b/docs/src/markdown/images/cam16-scd.png differ
diff --git a/docs/src/markdown/images/cam16-ucs.png b/docs/src/markdown/images/cam16-ucs.png
new file mode 100644
index 000000000..278ebb07a
Binary files /dev/null and b/docs/src/markdown/images/cam16-ucs.png differ
diff --git a/docs/src/markdown/images/cam16.png b/docs/src/markdown/images/cam16.png
new file mode 100644
index 000000000..e891dd33c
Binary files /dev/null and b/docs/src/markdown/images/cam16.png differ
diff --git a/docs/src/markdown/images/hct.png b/docs/src/markdown/images/hct.png
new file mode 100644
index 000000000..dd819a901
Binary files /dev/null and b/docs/src/markdown/images/hct.png differ
diff --git a/docs/src/markdown/interpolation.md b/docs/src/markdown/interpolation.md
index 78d9307c5..71b71f4f4 100644
--- a/docs/src/markdown/interpolation.md
+++ b/docs/src/markdown/interpolation.md
@@ -8,6 +8,8 @@ ColorAide provides a number of useful utilities based on interpolation.
 
 ## Linear Interpolation
 
+!!! success "Linear interpolation is registered in `Color` by Default"
+
 One of the most common, and easiest ways to interpolate data between two points is to use linear interpolation. An easy
 way of thinking about this concept is to imagine drawing a straight line that connects two colors within a color space.
 We could then navigate along that line and return colors at different points to simulate mixing colors at various
@@ -93,6 +95,8 @@ Inspired by some efforts seen on the [web](catmull-observe) and in the great Jav
 
 ### B-Spline
 
+!!! success "B-Spline interpolation is registered in `Color` by Default"
+
 ![B-spline](images/bspline-interpolation.png)
 
 B-spline is a piecewise spline similar to Bezier curves. It utilizes "control points" that help shape the interpolation
@@ -108,6 +112,8 @@ Color.interpolate(['red', 'green', 'blue', 'orange'], method='bspline')
 
 ### Natural
 
+!!! success "Natural interpolation is registered in `Color` by Default"
+
 ![Natural](images/natural-interpolation.png)
 
 The "natural" spline is the same as the B-spline approach except an algorithm is applied that uses the colors as data
@@ -126,6 +132,8 @@ Color.interpolate(['red', 'green', 'blue', 'orange'], method='natural')
 
 ### Monotone
 
+!!! success "Monotone interpolation is registered in `Color` by Default"
+
 ![Monotone](images/monotone-interpolation.png)
 
 The "monotone" spline is a piecewise interpolation spline that passes through all its data points and helps to preserve
@@ -138,7 +146,7 @@ Color.interpolate(['red', 'green', 'blue', 'orange'], method='monotone')
 
 ### Catmull-Rom
 
-!!! fail "Catmull-Rom is Not Registered By Default"
+!!! failure "Catmull-Rom interpolation is not registered in `Color` by Default"
 
 ![Catmull-Rom](images/catmull-rom-interpolation.png)
 
diff --git a/docs/src/markdown/plugins/space.md b/docs/src/markdown/plugins/space.md
index 3a24f7d8e..f57b19b05 100644
--- a/docs/src/markdown/plugins/space.md
+++ b/docs/src/markdown/plugins/space.md
@@ -229,6 +229,21 @@ mix-in class: `Cylindrical`, `Labish`, or `LChish`. It should be noted that `LCh
             """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.
+            """
+
+            return 0.0
     ```
 
 === "Labish"
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 0bdd8fc59..db50dbbed 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -92,6 +92,10 @@ nav:
         - IPT: colors/ipt.md
         - ICtCp: colors/ictcp.md
         - IgPgTg: colors/igpgtg.md
+        - CAM16: colors/cam16.md
+        - CAM16 UCS: colors/cam16_ucs.md
+        - CAM16 SCD: colors/cam16_scd.md
+        - CAM16 LCD: colors/cam16_lcd.md
 
       - LCh Like Spaces:
         - LCh D50: colors/lch.md
@@ -100,6 +104,8 @@ nav:
         - LCh(uv): colors/lchuv.md
         - DIN99 LCh: colors/lch99o.md
         - JzCzhz: colors/jzczhz.md
+        - CAM16 JMh: colors/cam16_jmh.md
+        - HCT: colors/hct.md
 
       - ACES Spaces:
         - ACES 2065-1: colors/aces2065_1.md
diff --git a/docs/theme/announce.html b/docs/theme/announce.html
index bbbbd6855..3c2c2281e 100644
--- a/docs/theme/announce.html
+++ b/docs/theme/announce.html
@@ -2,7 +2,7 @@
 {% set icon = "material/alert-decagram" %}
 {% include ".icons/" ~ icon ~ ".svg" %}
 </span>
-Stable 1.5 has been released! Check out the <a href="{{ 'about/releases/1.0' | url }}"> release notes</a> for more information and migration tips.
+Stable 1.7 has been released! Check out the <a href="{{ 'about/releases/1.0' | url }}"> release notes</a> for more information and migration tips.
 <br>
 <a href="{{ 'about/contributing/#become-a-sponsor' | url }}">Sponsorship</a>
 is now available!
diff --git a/mkdocs.yml b/mkdocs.yml
index ef88e4b0b..8006cf560 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -92,6 +92,10 @@ nav:
         - IPT: colors/ipt.md
         - ICtCp: colors/ictcp.md
         - IgPgTg: colors/igpgtg.md
+        - CAM16: colors/cam16.md
+        - CAM16 UCS: colors/cam16_ucs.md
+        - CAM16 SCD: colors/cam16_scd.md
+        - CAM16 LCD: colors/cam16_lcd.md
 
       - LCh Like Spaces:
         - LCh D50: colors/lch.md
@@ -100,6 +104,8 @@ nav:
         - LCh(uv): colors/lchuv.md
         - DIN99 LCh: colors/lch99o.md
         - JzCzhz: colors/jzczhz.md
+        - CAM16 JMh: colors/cam16_jmh.md
+        - HCT: colors/hct.md
 
       - ACES Spaces:
         - ACES 2065-1: colors/aces2065_1.md
diff --git a/tools/calc_cam16_min_m.py b/tools/calc_cam16_min_m.py
new file mode 100644
index 000000000..e1d4f781b
--- /dev/null
+++ b/tools/calc_cam16_min_m.py
@@ -0,0 +1,156 @@
+"""
+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
+import math
+
+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.cat import WHITES  # noqa: E402
+
+
+def main():
+    """Main."""
+
+    parser = argparse.ArgumentParser(
+        prog='calc_cam16_ucs_jmh_min_m.py',
+        description='Calculate min M for achromatic colors in CAM16 UCS JMh and map current spline against real values.'
+    )
+    # Flag arguments
+    parser.add_argument(
+        '--res', '-r', type=int, default=50000, help="Resolution to use when calculating range, default is 50000."
+    )
+    parser.add_argument(
+        '--white-point', '-w', type=str, default='2deg:D65', help="White point 'deg:wp', example: '2deg:D65'."
+    )
+    parser.add_argument(
+        '--adapting-luminance', '-a', type=float, default=64 / math.pi * 0.2, help="Adapting luminance."
+    )
+    parser.add_argument(
+        '--background-luminance', '-b', type=float, default=20, help="Background luminace - default 20 (gray world)."
+    )
+    parser.add_argument(
+        '--surround', '-s', type=str, default='average', help="Surround: 'average', 'dim', 'dark'"
+    )
+    parser.add_argument(
+        '--discounting', '-d', action='store_true', help="Enable discounting."
+    )
+    parser.add_argument(
+        '--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)"
+    )
+    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)"
+    )
+    args = parser.parse_args()
+
+    return run(
+        args.white_point,
+        args.adapting_luminance,
+        args.background_luminance,
+        args.surround,
+        args.discounting,
+        args.spline,
+        args.low,
+        args.mid,
+        args.high,
+        args.res
+    )
+
+
+def run(white_point, la, ba, surround, discounting, spline, low, mid, high, res):
+    """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(':'))]
+    }
+
+    env = Environment(WHITES[deg][wp], la, ba, surround, discounting)
+    convert = xyz_d65_to_cam16
+
+    test = Achromatic(tuning, 0.06, env, spline)
+
+    color = Color('srgb', [0, 0, 0])
+    points1 = {}
+    points2 = {}
+    diff = 0
+    max_m = 0
+
+    for i in range(res + 1):
+        div = res / 5
+        color.update('srgb', [i / div, i / div, i / div])
+        xyz = color.convert('xyz-d65')
+        coords = convert(xyz[:-1], env)[:]
+        j, m = coords[0], coords[5]
+
+        if m > max_m:
+            max_m = m
+
+        if not env.discounting:
+            domain = test.scale(j)
+            calc = test.spline(domain)
+        else:
+            calc = (j, 1e-08)
+
+        delta = abs(calc[1] - m)
+        if delta > diff:
+            diff = delta
+
+        points1[j] = m
+        points2[calc[0]] = calc[1]
+
+    print('Delta: ', diff)
+    print('Max M: ', max_m)
+
+    j1 = []
+    j2 = []
+    m1 = []
+    m2 = []
+    for j in sorted(points1):
+        j1.append(j)
+        m1.append(points1[j])
+    for j in sorted(points2):
+        j2.append(j)
+        m2.append(points2[j])
+
+    figure = plt.figure()
+
+    # Create axes
+    ax = plt.axes(
+        xlabel='M',
+        ylabel='J'
+    )
+    ax.set_aspect('auto')
+    ax.set_title('JMh: Delta = {} - Max M = {}'.format(diff, 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.show()
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/calc_hct_min_c.py b/tools/calc_hct_min_c.py
new file mode 100644
index 000000000..cd20bb7e7
--- /dev/null
+++ b/tools/calc_hct_min_c.py
@@ -0,0 +1,119 @@
+"""
+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.hct import HCT, xyz_to_hct, Achromatic  # noqa: E402
+
+
+def main():
+    """Main."""
+
+    parser = argparse.ArgumentParser(
+        prog='calc_hct_min_chroma.py',
+        description='Calculate minimum chroma for achromatic colors in HCT and maps current spline against real values.'
+    )
+    # Flag arguments
+    parser.add_argument(
+        '--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"
+    )
+    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)"
+    )
+    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)"
+    )
+    args = parser.parse_args()
+
+    return run(args.spline, args.low, args.mid, args.high, args.res)
+
+
+def run(spline, low, mid, high, res):
+    """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(':'))]
+    }
+    env = HCT.ENV
+    test = Achromatic(tuning, 0.06, env, spline)
+
+    color = Color('srgb', [0, 0, 0])
+    points1 = {}
+    points2 = {}
+    diff = 0
+    max_m = 0
+
+    for i in range(res + 1):
+        div = res / 5
+        color.update('srgb', [i / div, i / div, i / div])
+        xyz = color.convert('xyz-d65')
+        m, t = xyz_to_hct(xyz[:-1], HCT.ENV)[1:]
+
+        if m > max_m:
+            max_m = m
+
+        domain = test.scale(t)
+        calc = test.spline(domain)
+
+        delta = abs(calc[1] - m)
+        if delta > diff:
+            diff = delta
+
+        points1[t] = m
+        points2[calc[0]] = calc[1]
+
+    print('Delta: ', diff)
+    print('Max Chroma: ', max_m)
+
+    t1 = []
+    t2 = []
+    m1 = []
+    m2 = []
+    for t in sorted(points1):
+        t1.append(t)
+        m1.append(points1[t])
+    for t in sorted(points2):
+        t2.append(t)
+        m2.append(points2[t])
+
+    figure = plt.figure()
+
+    # Create axes
+    ax = plt.axes(
+        xlabel='C',
+        ylabel='T'
+    )
+    ax.set_aspect('auto')
+    ax.set_title('HCT: Delta = {} - Max C = {}'.format(diff, 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.show()
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/calc_lch_threshold.py b/tools/calc_lch_threshold.py
index 3b82bf450..0f1b72030 100644
--- a/tools/calc_lch_threshold.py
+++ b/tools/calc_lch_threshold.py
@@ -51,9 +51,9 @@ def run(lch, lab, verify):
         max_a = 0.0
         max_b = 0.0
 
-        for x in range(500):
+        for x in range(5000):
             # Create an achromatic RGB color
-            color = Color(space, [x / 100] * 3)
+            color = Color(space, [x / 1000] * 3)
             if lab:
                 labish = color.convert(lab)
                 a_name, b_name = labish._space.labish_names()[1:]
@@ -104,7 +104,7 @@ def run(lch, lab, verify):
             '\n* Only potential recommendations, adjustments can be made if desired.'
             '\n  If consraints are very tight, the minimum is probably best while the'
             '\n  relaxed would give a little more room assuming achromatic colors even'
-            '\n  further out rolled the next decimal point by one.'
+            '\n  further out did not roll the next decimal point by one.'
         )
 
     return 0
diff --git a/tools/gamut_3d_diagrams.py b/tools/gamut_3d_diagrams.py
index 0efe1c9e9..7502be29a 100644
--- a/tools/gamut_3d_diagrams.py
+++ b/tools/gamut_3d_diagrams.py
@@ -34,7 +34,7 @@
     '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-ucs-jmh': {'chroma': 'm'}
+    'cam16-jmh': {'chroma': 'm'}
 }
 
 
@@ -130,7 +130,7 @@ def add_cyl_color(space, color, x, y, z, c):
     hue = cyl.get(color_options.get(space, {}).get('hue', 'hue'))
 
     if is_nan(hue):
-        hue = 0
+        hue = cyl._space.achromatic_hue()
 
     x.append(chroma * math.sin(math.radians(hue)))
     y.append(chroma * math.cos(math.radians(hue)))


Test patch:
diff --git a/tests/test_algebra.py b/tests/test_algebra.py
index 34fb2f1e0..bbf33314e 100644
--- a/tests/test_algebra.py
+++ b/tests/test_algebra.py
@@ -7,6 +7,11 @@
 class TestAlgebra(unittest.TestCase):
     """Test Algebra."""
 
+    def test_nlog(self):
+        """Test `nlog`."""
+
+        self.assertEqual(alg.nlog(-3), -1.0986122886681098)
+
     def test_cross(self):
         """Test cross product."""
 
diff --git a/tests/test_cam16.py b/tests/test_cam16.py
new file mode 100644
index 000000000..e8b1bb7c2
--- /dev/null
+++ b/tests/test_cam16.py
@@ -0,0 +1,134 @@
+"""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
+import pytest
+
+CAM16Coords = namedtuple("CAM16Coords", "J C h s Q M H")
+
+
+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)
+
+    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=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)
+
+
+class TestCAM16CAM16(util.ColorAssertsPyTest):
+    """Test CAM16."""
+
+    COLORS = [
+        ('red', 'color(--cam16 46.026 72.143 37.385)'),
+        ('orange', 'color(--cam16 68.056 13.955 41.212)'),
+        ('yellow', 'color(--cam16 94.682 -19.662 50.83)'),
+        ('green', 'color(--cam16 33.976 -40.301 31.147)'),
+        ('blue', 'color(--cam16 25.066 13.785 -60.901)'),
+        ('indigo', 'color(--cam16 16.046 28.336 -32.712)'),
+        ('violet', 'color(--cam16 63.507 41.066 -22.402)'),
+        ('white', 'color(--cam16 100 -1.9463 -1.1026)'),
+        ('gray', 'color(--cam16 43.042 -1.2764 -0.72317)'),
+        ('black', 'color(--cam16 0 0 0)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('cam16'), Color(color2))
+
+
+class TestCAM16Poperties(util.ColorAsserts, unittest.TestCase):
+    """Test CAM16."""
+
+    def test_j(self):
+        """Test `j`."""
+
+        c = Color('color(--cam16 0.51332 0.92781 1.076)')
+        self.assertEqual(c['j'], 0.51332)
+        c['j'] = 0.2
+        self.assertEqual(c['j'], 0.2)
+
+    def test_a(self):
+        """Test `a`."""
+
+        c = Color('color(--cam16 0.51332 0.92781 1.076)')
+        self.assertEqual(c['a'], 0.92781)
+        c['a'] = 0.1
+        self.assertEqual(c['a'], 0.1)
+
+    def test_b(self):
+        """Test `b`."""
+
+        c = Color('color(--cam16 0.51332 0.92781 1.076)')
+        self.assertEqual(c['b'], 1.076)
+        c['b'] = 0.1
+        self.assertEqual(c['b'], 0.1)
+
+    def test_alpha(self):
+        """Test `alpha`."""
+
+        c = Color('color(--cam16 0.51332 0.92781 1.076)')
+        self.assertEqual(c['alpha'], 1)
+        c['alpha'] = 0.5
+        self.assertEqual(c['alpha'], 0.5)
diff --git a/tests/test_cam16_jmh.py b/tests/test_cam16_jmh.py
new file mode 100644
index 000000000..dddac47b4
--- /dev/null
+++ b/tests/test_cam16_jmh.py
@@ -0,0 +1,111 @@
+"""Test CAM16 JMh."""
+import unittest
+from . import util
+from coloraide.everything import ColorAll as Color, NaN
+import pytest
+
+
+class TestCAM16JMh(util.ColorAssertsPyTest):
+    """Test CAM16 JMh."""
+
+    COLORS = [
+        ('red', 'color(--cam16-jmh 46.026 81.254 27.393)'),
+        ('orange', 'color(--cam16-jmh 68.056 43.51 71.293)'),
+        ('yellow', 'color(--cam16-jmh 94.682 54.5 111.15)'),
+        ('green', 'color(--cam16-jmh 33.976 50.934 142.3)'),
+        ('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)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('cam16-jmh'), 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'))
+
+    def test_h(self):
+        """Test `h`."""
+
+        c = Color('color(--cam16-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['h'], 111.05)
+        c['h'] = 270
+        self.assertEqual(c['h'], 270)
+
+    def test_c(self):
+        """Test `m`."""
+
+        c = Color('color(--cam16-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['m'], 75.504)
+        c['m'] = 30
+        self.assertEqual(c['m'], 30)
+
+    def test_j(self):
+        """Test `j`."""
+
+        c = Color('color(--cam16-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['j'], 97.139)
+        c['j'] = 50
+        self.assertEqual(c['j'], 50)
+
+    def test_alpha(self):
+        """Test `alpha`."""
+
+        c = Color('color(--cam16-jmh 97.139 75.504 111.05)')
+        self.assertEqual(c['alpha'], 1)
+        c['alpha'] = 0.5
+        self.assertEqual(c['alpha'], 0.5)
+
+
+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.53333446353292)
+
+    def test_null_input(self):
+        """Test null input."""
+
+        c = Color('cam16-jmh', [30, 20, NaN], 1)
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_none_input(self):
+        """Test `none` null."""
+
+        c = Color('color(--cam16-jmh 30 20 none / 1)')
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_null_normalization_min_chroma(self):
+        """Test minimum saturation."""
+
+        c = Color('color(--cam16-jmh 740 0.03 90 / 1)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+        c = Color('color(--cam16-jmh 30 0.03 90 / 1)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+        c = Color('color(--cam16-jmh 1 0.03 30)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_achromatic_hue(self):
+        """Test that all RGB-ish colors convert to CAM16 JMh with a null hue."""
+
+        for space in ('srgb', 'display-p3', 'rec2020', 'a98-rgb', 'prophoto-rgb'):
+            for x in range(0, 256):
+                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'))
diff --git a/tests/test_cam16_ucs.py b/tests/test_cam16_ucs.py
new file mode 100644
index 000000000..7c7e634e9
--- /dev/null
+++ b/tests/test_cam16_ucs.py
@@ -0,0 +1,110 @@
+"""Test CAM16 UCS."""
+import unittest
+from . import util
+from coloraide.everything import ColorAll as Color
+import pytest
+
+
+class TestCAM16CAM16UCS(util.ColorAssertsPyTest):
+    """Test CAM16 UCS."""
+
+    COLORS = [
+        ('red', 'color(--cam16-ucs 59.178 40.82 21.153)'),
+        ('orange', 'color(--cam16-ucs 78.364 9.6945 28.629)'),
+        ('yellow', 'color(--cam16-ucs 96.802 -12.779 33.037)'),
+        ('green', 'color(--cam16-ucs 46.661 -26.746 20.671)'),
+        ('blue', 'color(--cam16-ucs 36.252 8.5723 -37.87)'),
+        ('indigo', 'color(--cam16-ucs 24.524 19.714 -22.758)'),
+        ('violet', 'color(--cam16-ucs 74.738 27.949 -15.247)'),
+        ('white', 'color(--cam16-ucs 100 -1.8983 -1.0754)'),
+        ('gray', 'color(--cam16-ucs 56.23 -1.2555 -0.71134)'),
+        ('black', 'color(--cam16-ucs 0 0 0)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('cam16-ucs'), Color(color2))
+
+
+class TestCAM16CAM16SCD(util.ColorAssertsPyTest):
+    """Test CAM16 SCD."""
+
+    COLORS = [
+        ('red', 'color(--cam16-scd 59.178 33.597 17.41)'),
+        ('orange', 'color(--cam16-scd 78.364 8.3723 24.725)'),
+        ('yellow', 'color(--cam16-scd 96.802 -10.847 28.041)'),
+        ('green', 'color(--cam16-scd 46.661 -22.82 17.637)'),
+        ('blue', 'color(--cam16-scd 36.252 7.1995 -31.806)'),
+        ('indigo', 'color(--cam16-scd 24.524 17.032 -19.663)'),
+        ('violet', 'color(--cam16-scd 74.738 24.003 -13.094)'),
+        ('white', 'color(--cam16-scd 100 -1.8713 -1.0602)'),
+        ('gray', 'color(--cam16-scd 56.23 -1.2436 -0.70457)'),
+        ('black', 'color(--cam16-scd 0 0 0)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('cam16-scd'), Color(color2))
+
+
+class TestCAM16CAM16LCD(util.ColorAssertsPyTest):
+    """Test CAM16 LCD."""
+
+    COLORS = [
+        ('red', 'color(--cam16-lcd 59.178 59.994 31.089)'),
+        ('orange', 'color(--cam16-lcd 78.364 12.557 37.084)'),
+        ('yellow', 'color(--cam16-lcd 96.802 -17.273 44.653)'),
+        ('green', 'color(--cam16-lcd 46.661 -35.677 27.573)'),
+        ('blue', 'color(--cam16-lcd 36.252 11.909 -52.61)'),
+        ('indigo', 'color(--cam16-lcd 24.524 25.511 -29.451)'),
+        ('violet', 'color(--cam16-lcd 74.738 36.686 -20.013)'),
+        ('white', 'color(--cam16-lcd 100 -1.9348 -1.0961)'),
+        ('gray', 'color(--cam16-lcd 56.23 -1.2714 -0.72038)'),
+        ('black', 'color(--cam16-lcd 0 0 0)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('cam16-lcd'), Color(color2))
+
+
+class TestCAM16UCSPoperties(util.ColorAsserts, unittest.TestCase):
+    """Test CAM16 UCS."""
+
+    def test_j(self):
+        """Test `j`."""
+
+        c = Color('color(--cam16-ucs 0.51332 0.92781 1.076)')
+        self.assertEqual(c['j'], 0.51332)
+        c['j'] = 0.2
+        self.assertEqual(c['j'], 0.2)
+
+    def test_a(self):
+        """Test `a`."""
+
+        c = Color('color(--cam16-ucs 0.51332 0.92781 1.076)')
+        self.assertEqual(c['a'], 0.92781)
+        c['a'] = 0.1
+        self.assertEqual(c['a'], 0.1)
+
+    def test_b(self):
+        """Test `b`."""
+
+        c = Color('color(--cam16-ucs 0.51332 0.92781 1.076)')
+        self.assertEqual(c['b'], 1.076)
+        c['b'] = 0.1
+        self.assertEqual(c['b'], 0.1)
+
+    def test_alpha(self):
+        """Test `alpha`."""
+
+        c = Color('color(--cam16-ucs 0.51332 0.92781 1.076)')
+        self.assertEqual(c['alpha'], 1)
+        c['alpha'] = 0.5
+        self.assertEqual(c['alpha'], 0.5)
diff --git a/tests/test_contrast.py b/tests/test_contrast.py
index ea95cc90a..485b4454e 100644
--- a/tests/test_contrast.py
+++ b/tests/test_contrast.py
@@ -1,6 +1,6 @@
 """Test contrast."""
 import unittest
-from coloraide import Color
+from coloraide.everything import ColorAll as Color
 from . import util
 
 
@@ -48,3 +48,25 @@ def test_symmetry(self):
             Color('white').contrast('blue', method='wcag21'),
             Color('blue').contrast('white', method='wcag21'),
         )
+
+
+class TestContrastLstar(util.ColorAsserts, unittest.TestCase):
+    """Test L* contrast difference."""
+
+    def test_contrast_same(self):
+        """Test contrast of to same colors."""
+
+        self.assertEqual(Color('blue').contrast('blue', method='lstar'), 0)
+
+    def test_contrast_bigger(self):
+        """Test greater contrast."""
+
+        self.assertCompare(Color('orange').contrast('blue', method='lstar'), 42.63303)
+
+    def test_symmetry(self):
+        """Test symmetry."""
+
+        self.assertEqual(
+            Color('orange').contrast('blue', method='lstar'),
+            Color('blue').contrast('orange', method='lstar')
+        )
diff --git a/tests/test_distance.py b/tests/test_distance.py
index 054850fa3..fc1877625 100644
--- a/tests/test_distance.py
+++ b/tests/test_distance.py
@@ -481,6 +481,150 @@ def test_euclidean_different_space(self, color1, color2, value):
             rounding=4
         )
 
+    @pytest.mark.parametrize(
+        'color1,color2,value',
+        [
+            ('red', 'red', 0),
+            ('red', 'orange', 44.506),
+            ('red', 'yellow', 80.2846),
+            ('red', 'green', 83.4202),
+            ('red', 'blue', 85.0841),
+            ('red', 'indigo', 68.2811),
+            ('red', 'violet', 49.9396),
+            ('red', 'white', 71.0854),
+            ('red', 'black', 77.2615),
+            ('red', 'gray', 54.351),
+            ('red', 'red', 0),
+            ('orange', 'red', 44.506),
+            ('yellow', 'red', 80.2846),
+            ('green', 'red', 83.4202),
+            ('blue', 'red', 85.0841),
+            ('indigo', 'red', 68.2811),
+            ('violet', 'red', 49.9396),
+            ('white', 'red', 71.0854),
+            ('black', 'red', 77.2615),
+            ('gray', 'red', 54.351)
+        ]
+    )
+    def test_delta_e_hct(self, color1, color2, value):
+        """Test delta e HCT."""
+
+        print('color1: ', color1)
+        print('color2: ', color2)
+        self.assertCompare(
+            Color(color1).delta_e(color2, method="hct"),
+            value,
+            rounding=4
+        )
+
+    @pytest.mark.parametrize(
+        'color1,color2,value',
+        [
+            ('red', 'red', 0),
+            ('red', 'orange', 37.32),
+            ('red', 'yellow', 66.5558),
+            ('red', 'green', 68.7175),
+            ('red', 'blue', 71.0578),
+            ('red', 'indigo', 59.7878),
+            ('red', 'violet', 41.6256),
+            ('red', 'white', 63.1299),
+            ('red', 'black', 74.938),
+            ('red', 'gray', 47.5086),
+            ('red', 'red', 0),
+            ('orange', 'red', 37.32),
+            ('yellow', 'red', 66.5558),
+            ('green', 'red', 68.7175),
+            ('blue', 'red', 71.0578),
+            ('indigo', 'red', 59.7878),
+            ('violet', 'red', 41.6256),
+            ('white', 'red', 63.1299),
+            ('black', 'red', 74.938),
+            ('gray', 'red', 47.5086)
+        ]
+    )
+    def test_delta_e_cam16(self, color1, color2, value):
+        """Test delta e CAM16 UCS."""
+
+        print('color1: ', color1)
+        print('color2: ', color2)
+        self.assertCompare(
+            Color(color1).delta_e(color2, method="cam16"),
+            value,
+            rounding=4
+        )
+
+    @pytest.mark.parametrize(
+        'color1,color2,value',
+        [
+            ('red', 'red', 0),
+            ('red', 'orange', 53.9171),
+            ('red', 'yellow', 92.4214),
+            ('red', 'green', 97.1066),
+            ('red', 'blue', 101.0159),
+            ('red', 'indigo', 82.9438),
+            ('red', 'violet', 59.6913),
+            ('red', 'white', 87.6459),
+            ('red', 'black', 102.335),
+            ('red', 'gray', 69.1376),
+            ('red', 'red', 0),
+            ('orange', 'red', 53.9171),
+            ('yellow', 'red', 92.4214),
+            ('green', 'red', 97.1066),
+            ('blue', 'red', 101.0159),
+            ('indigo', 'red', 82.9438),
+            ('violet', 'red', 59.6913),
+            ('white', 'red', 87.6459),
+            ('black', 'red', 102.335),
+            ('gray', 'red', 69.1376)
+        ]
+    )
+    def test_delta_e_cam16_lcd(self, color1, color2, value):
+        """Test delta e CAM16 LCD."""
+
+        print('color1: ', color1)
+        print('color2: ', color2)
+        self.assertCompare(
+            Color(color1).delta_e(color2, method="cam16", model='lcd'),
+            value,
+            rounding=4
+        )
+
+    @pytest.mark.parametrize(
+        'color1,color2,value',
+        [
+            ('red', 'red', 0),
+            ('red', 'orange', 30.4827),
+            ('red', 'yellow', 54.8533),
+            ('red', 'green', 57.3138),
+            ('red', 'blue', 58.8291),
+            ('red', 'indigo', 49.2931),
+            ('red', 'violet', 34.3513),
+            ('red', 'white', 51.7972),
+            ('red', 'black', 60.9053),
+            ('red', 'gray', 39.3403),
+            ('red', 'red', 0),
+            ('orange', 'red', 30.4827),
+            ('yellow', 'red', 54.8533),
+            ('green', 'red', 57.3138),
+            ('blue', 'red', 58.8291),
+            ('indigo', 'red', 49.2931),
+            ('violet', 'red', 34.3513),
+            ('white', 'red', 51.7972),
+            ('black', 'red', 60.9053),
+            ('gray', 'red', 39.3403)
+        ]
+    )
+    def test_delta_e_cam16_scd(self, color1, color2, value):
+        """Test delta e CAM16 SCD."""
+
+        print('color1: ', color1)
+        print('color2: ', color2)
+        self.assertCompare(
+            Color(color1).delta_e(color2, method="cam16", model='scd'),
+            value,
+            rounding=4
+        )
+
 
 class TestDistanceSpecificCases(util.ColorAsserts, unittest.TestCase):
     """Test distance specific cases."""
diff --git a/tests/test_gamut.py b/tests/test_gamut.py
index fed474590..0f85ac6d0 100644
--- a/tests/test_gamut.py
+++ b/tests/test_gamut.py
@@ -1,5 +1,6 @@
 """Test gamut mapping fitting."""
 import unittest
+import math
 from coloraide.everything import ColorAll as Color
 from . import util
 
@@ -132,3 +133,42 @@ def test_out_of_gamut_other_space(self):
         """Test if a color is in gamut in a different space."""
 
         self.assertFalse(Color('color(srgb 2 0 0)').convert('lch').in_gamut('srgb'))
+
+
+class TestHCTGamut(util.ColorAsserts, unittest.TestCase):
+    """
+    Test HCT gamut mapping by producing tonal maps.
+
+    When we are off, we seem to only be off on a given channel by at most < +/- 1 (between 0 - 255 floating point).
+    As this is not a 1:1 port of the Material utilities, different precision of matrices and different gamut mapping
+    can cause slight differences in results. We are not aiming to match the Material implementation, but aiming to match
+    the intent.
+    """
+
+    def test_blue(self):
+        """Test blue tonal maps."""
+
+        hct = Color('#0000ff').convert('hct')
+        tones = [100, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0]
+        expect = ['#ffffff', '#f1efff', '#e0e0ff', '#bec2ff',
+                  '#9da3ff', '#7c84ff', '#5a64ff', '#343dff',
+                  '#0000ef', '#0001ac', '#00006e', '#000000']
+
+        # A channel should be off no more than by 1 in a scale of 0 - 255
+        # Due to the fact that Material uses less precise matrices
+        for tone, answer in zip(tones, expect):
+            s1 = [c * 255 for c in hct.clone().set('tone', tone).convert('srgb').fit(method='hct-chroma')[:-1]]
+            s2 = [c * 255 for c in Color(answer)[:-1]]
+            for c1, c2 in zip(s1, s2):
+                self.assertTrue(math.isclose(c1, c2, abs_tol=1.0))
+
+        hct.set('chroma', 16)
+        expect = ['#ffffff', '#f1efff', '#e1e0f9', '#c5c4dd',
+                  '#a9a9c1', '#8f8fa6', '#75758b', '#5c5d72',
+                  '#444559', '#2e2f42', '#191a2c', '#000000']
+
+        for tone, answer in zip(tones, expect):
+            s1 = [c * 255 for c in hct.clone().set('tone', tone).convert('srgb').fit(method='hct-chroma')[:-1]]
+            s2 = [c * 255 for c in Color(answer)[:-1]]
+            for c1, c2 in zip(s1, s2):
+                self.assertTrue(math.isclose(c1, c2, abs_tol=1.0))
diff --git a/tests/test_hct.py b/tests/test_hct.py
new file mode 100644
index 000000000..55c4c244e
--- /dev/null
+++ b/tests/test_hct.py
@@ -0,0 +1,111 @@
+"""Test HCT."""
+import unittest
+from . import util
+from coloraide.everything import ColorAll as Color, NaN
+import pytest
+
+
+class TestHCT(util.ColorAssertsPyTest):
+    """Test HCT."""
+
+    COLORS = [
+        ('red', 'color(--hct 27.41 113.36 53.237)'),
+        ('orange', 'color(--hct 71.257 60.528 74.934)'),
+        ('yellow', 'color(--hct 111.05 75.504 97.139)'),
+        ('green', 'color(--hct 142.23 71.136 46.228)'),
+        ('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)')
+    ]
+
+    @pytest.mark.parametrize('color1,color2', COLORS)
+    def test_colors(self, color1, color2):
+        """Test colors."""
+
+        self.assertColorEqual(Color(color1).convert('hct'), Color(color2))
+
+
+class TestHCTPoperties(util.ColorAsserts, unittest.TestCase):
+    """Test HCT properties."""
+
+    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'))
+
+    def test_h(self):
+        """Test `h`."""
+
+        c = Color('color(--hct 111.05 75.504 97.139)')
+        self.assertEqual(c['h'], 111.05)
+        c['h'] = 270
+        self.assertEqual(c['h'], 270)
+
+    def test_c(self):
+        """Test `c`."""
+
+        c = Color('color(--hct 111.05 75.504 97.139)')
+        self.assertEqual(c['c'], 75.504)
+        c['c'] = 30
+        self.assertEqual(c['c'], 30)
+
+    def test_t(self):
+        """Test `t`."""
+
+        c = Color('color(--hct 111.05 75.504 97.139)')
+        self.assertEqual(c['t'], 97.139)
+        c['t'] = 50
+        self.assertEqual(c['t'], 50)
+
+    def test_alpha(self):
+        """Test `alpha`."""
+
+        c = Color('color(--hct 111.05 75.504 97.139)')
+        self.assertEqual(c['alpha'], 1)
+        c['alpha'] = 0.5
+        self.assertEqual(c['alpha'], 0.5)
+
+
+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.54293597883213)
+
+    def test_null_input(self):
+        """Test null input."""
+
+        c = Color('hct', [NaN, 20, 30], 1)
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_none_input(self):
+        """Test `none` null."""
+
+        c = Color('color(--hct none 20 30 / 1)')
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_null_normalization_min_chroma(self):
+        """Test minimum saturation."""
+
+        c = Color('color(--hct 90 0.05 30 / 1)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+        c = Color('color(--hct 30 0.05 7)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+        c = Color('color(--hct 30 0.05 1)').normalize()
+        self.assertTrue(c.is_nan('hue'))
+
+    def test_achromatic_hue(self):
+        """Test that all RGB-ish colors convert to HCT with a null hue."""
+
+        for space in ('srgb', 'display-p3', 'rec2020', 'a98-rgb', 'prophoto-rgb'):
+            for x in range(0, 256):
+                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'))
diff --git a/tests/test_hsi.py b/tests/test_hsi.py
index f1f2ab825..67a6348e6 100644
--- a/tests/test_hsi.py
+++ b/tests/test_hsi.py
@@ -68,6 +68,11 @@ 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 0a0242280..ca09d9c49 100644
--- a/tests/test_hsl.py
+++ b/tests/test_hsl.py
@@ -240,6 +240,11 @@ 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."""
 
diff --git a/tests/test_hsluv/test_hsluv.py b/tests/test_hsluv/test_hsluv.py
index 354ac2924..f14ac11da 100644
--- a/tests/test_hsluv/test_hsluv.py
+++ b/tests/test_hsluv/test_hsluv.py
@@ -282,6 +282,11 @@ 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."""
 
@@ -316,6 +321,11 @@ def test_null_normalization_min_light(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."""
 
diff --git a/tests/test_hsv.py b/tests/test_hsv.py
index faff0deba..56d71c631 100644
--- a/tests/test_hsv.py
+++ b/tests/test_hsv.py
@@ -119,6 +119,11 @@ 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."""
 
diff --git a/tests/test_hwb.py b/tests/test_hwb.py
index abfec2088..a84d79033 100644
--- a/tests/test_hwb.py
+++ b/tests/test_hwb.py
@@ -169,6 +169,11 @@ 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."""
 
diff --git a/tests/test_jzCzhz.py b/tests/test_jzCzhz.py
index 03ffd88ce..133e5a1f3 100644
--- a/tests/test_jzCzhz.py
+++ b/tests/test_jzCzhz.py
@@ -126,6 +126,11 @@ 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."""
 
diff --git a/tests/test_lch.py b/tests/test_lch.py
index 1b69880be..23f88a93c 100644
--- a/tests/test_lch.py
+++ b/tests/test_lch.py
@@ -169,6 +169,11 @@ 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."""
 
diff --git a/tests/test_lch99o.py b/tests/test_lch99o.py
index 8fa0fe7b5..a25a74217 100644
--- a/tests/test_lch99o.py
+++ b/tests/test_lch99o.py
@@ -130,6 +130,11 @@ 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 81de2d597..d09e12895 100644
--- a/tests/test_lchuv.py
+++ b/tests/test_lchuv.py
@@ -130,6 +130,11 @@ 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."""
 
diff --git a/tests/test_okhsl.py b/tests/test_okhsl.py
index f952db47e..d0076d3b4 100644
--- a/tests/test_okhsl.py
+++ b/tests/test_okhsl.py
@@ -129,6 +129,11 @@ 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 1425242e2..670f05dd1 100644
--- a/tests/test_okhsv.py
+++ b/tests/test_okhsv.py
@@ -120,6 +120,11 @@ 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."""
 
diff --git a/tests/test_oklch.py b/tests/test_oklch.py
index 1b3d45c16..25e937258 100644
--- a/tests/test_oklch.py
+++ b/tests/test_oklch.py
@@ -150,6 +150,11 @@ 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."""
 
diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py
index 713977d38..5a44c2db0 100644
--- a/tests/test_roundtrip.py
+++ b/tests/test_roundtrip.py
@@ -8,6 +8,7 @@
 
 # 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:
@@ -53,7 +54,7 @@ class TestRoundTrip:
     IGNORE_ACHROMA = ['okhsl', 'okhsv']
 
     # Ignore gray cases. Spaces that don't offer enough precision near grey.
-    IGNORE_GREY = ['jzczhz']
+    IGNORE_GREY = ['jzczhz', 'cam16-jmh', 'hct']
 
     ACHROMA = {'grey', 'white', 'darkgrey', 'lightgrey'}
     GREY = {'grey', 'darkgrey', 'lightgrey'}