Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active October 22, 2025 15:52
Show Gist options
  • Select an option

  • Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.

Select an option

Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.

Revisions

  1. facelessuser revised this gist Feb 12, 2024. 1 changed file with 1 addition and 4 deletions.
    5 changes: 1 addition & 4 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -14,10 +14,7 @@ lightness as it is more ideal for contrast. Combining these two models creates t
    color model, you pick a color and just adjust the tone (lightness) and get palettes with much better contrast.

    For similar results to Google, we need to take the HCT color model, generate colors with different tones and gamut map
    them in HCT fairly tight in sRGB. The caveat is that, especially in low light, it is easy to end up with a chroma that is
    proportionally higher than the lightness that the algorithm can't quite handle things. This is more a limitation of CAM16.
    To account for this, we check if the round trip gives us a diffrent chroma and hue, if this happens, we cut the chroma in
    half so we have a color we can work with. The function assumes you are giving it reasonable colors in your gamut.
    them in HCT fairly tight in sRGB.

    ```py play
    def hct_tonal_palette(c):
  2. facelessuser revised this gist Dec 28, 2023. 1 changed file with 3 additions and 45 deletions.
    48 changes: 3 additions & 45 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -25,21 +25,7 @@ def hct_tonal_palette(c):

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]


    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
    @@ -140,21 +126,7 @@ def hct_tonal_palette(c):

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    @@ -190,21 +162,7 @@ def hct_tonal_palette(c):

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

  3. facelessuser revised this gist Dec 23, 2023. 1 changed file with 7 additions and 7 deletions.
    14 changes: 7 additions & 7 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -38,7 +38,7 @@ def hct_tonal_palette(c):
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors


    @@ -73,7 +73,7 @@ def oklch_tonal_palette(c):

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    @@ -106,7 +106,7 @@ def oklch_tonal_palette(c):

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002) for tone in tones]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0) for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    @@ -133,7 +133,7 @@ def oklch_tonal_palette(c):

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""
    @@ -153,7 +153,7 @@ def hct_tonal_palette(c):
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
    @@ -183,7 +183,7 @@ def oklch_tonal_palette(c):

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""
    @@ -203,7 +203,7 @@ def hct_tonal_palette(c):
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb'))
    return colors

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
  4. facelessuser revised this gist Dec 23, 2023. 1 changed file with 5 additions and 4 deletions.
    9 changes: 5 additions & 4 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -164,12 +164,13 @@ for color in colors:
    ```

    The results are surprisingly similar, but there is still a noticeable difference in lighting in the dark region. Is it
    possible to tweak the toe to get a little closer to HCT results? Here we cut the `K2` value in half from `0.03` to
    `0.0095`. This gives us results that can be more difficult to distinguish against, except for the different blue results.
    possible to tweak the toe to get a little closer to HCT results? Here we change the `K_1` value to `0.173` and the `K-2`
    value to `0.004`. This changes achromatic lightness to more closely match CIE Lab and gives us results that appear
    closer to the HCT results.

    ```py play
    K_1 = 0.206
    K_2 = 0.0095
    K_1 = 0.173
    K_2 = 0.004
    K_3 = (1.0 + K_1) / (1.0 + K_2)

    def toe_inv(x: float) -> float:
  5. facelessuser revised this gist Dec 22, 2023. 1 changed file with 2 additions and 2 deletions.
    4 changes: 2 additions & 2 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -165,11 +165,11 @@ for color in colors:

    The results are surprisingly similar, but there is still a noticeable difference in lighting in the dark region. Is it
    possible to tweak the toe to get a little closer to HCT results? Here we cut the `K2` value in half from `0.03` to
    `0.015`. This gives us results that can be more difficult to distinguish against, except for the different blue results.
    `0.0095`. This gives us results that can be more difficult to distinguish against, except for the different blue results.

    ```py play
    K_1 = 0.206
    K_2 = 0.015
    K_2 = 0.0095
    K_3 = (1.0 + K_1) / (1.0 + K_2)

    def toe_inv(x: float) -> float:
  6. facelessuser revised this gist Dec 22, 2023. 1 changed file with 7 additions and 7 deletions.
    14 changes: 7 additions & 7 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -24,7 +24,7 @@ def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    @@ -72,7 +72,7 @@ def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
    @@ -105,7 +105,7 @@ def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002) for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']
    @@ -132,14 +132,14 @@ def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    @@ -181,14 +181,14 @@ def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
  7. facelessuser revised this gist Dec 22, 2023. 1 changed file with 53 additions and 7 deletions.
    60 changes: 53 additions & 7 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -14,15 +14,33 @@ lightness as it is more ideal for contrast. Combining these two models creates t
    color model, you pick a color and just adjust the tone (lightness) and get palettes with much better contrast.

    For similar results to Google, we need to take the HCT color model, generate colors with different tones and gamut map
    them in HCT fairly tight in sRGB.
    them in HCT fairly tight in sRGB. The caveat is that, especially in low light, it is easy to end up with a chroma that is
    proportionally higher than the lightness that the algorithm can't quite handle things. This is more a limitation of CAM16.
    To account for this, we check if the round trip gives us a diffrent chroma and hue, if this happens, we cut the chroma in
    half so we have a color we can work with. The function assumes you are giving it reasonable colors in your gamut.

    ```py play
    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    return colors


    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    @@ -121,8 +139,22 @@ def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    return colors

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    @@ -156,8 +188,22 @@ def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]
    tones = [0, 2, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    colors = []
    for tone in tones:
    c1 = c.clone().set('t', tone)
    # Any color in CAM16 that has too high of chroma can cause issues,
    # but this is most likely to occur in low lightness.
    if tone < 20:
    c2 = c1.convert('xyz-d65').convert('hct', in_place=True)
    # We are either using a seed color too far out of gamut
    # or the seed color's chroma is grossly over what the conversion can handle.
    # The indicator is round trip conversion gives you a different chroma and hue.
    # Cut the chroma in half so we can work with the color.
    if abs(c2[1] - c1[1]) > 1:
    c1[1] *= 0.5
    colors.append(c1.fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb'))
    return colors

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

  8. facelessuser revised this gist Dec 21, 2023. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -148,7 +148,7 @@ def toe_inv(x: float) -> float:
    def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklab')
    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

  9. facelessuser created this gist Dec 21, 2023.
    175 changes: 175 additions & 0 deletions exploring-tonal-palettes.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,175 @@

    # Exploring Tonal Palettes

    ## HCT

    HCT is a color model developed by [Google][material-hct]. It aims to solve a problem related to generating color
    palettes with good contrast. While HCT may seem like a revolutionary color model, the idea behind it is quite simple,
    take the perceptually uniform color model CAM16 and combine it with the CIE Lab's lightness.

    ### Upside of HCT

    When constructing HCT, Google chose CAM16 as it is more perceptually uniform than CIE Lab, but also chose CIE Lab's
    lightness as it is more ideal for contrast. Combining these two models creates the best of both worlds. With this new
    color model, you pick a color and just adjust the tone (lightness) and get palettes with much better contrast.

    For similar results to Google, we need to take the HCT color model, generate colors with different tones and gamut map
    them in HCT fairly tight in sRGB.

    ```py play
    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    for color in colors:
    Steps(hct_tonal_palette(color))
    ```

    It was determined that when using this model, that CIE Lab lightness could be the sole deciding factor to determine
    contrast between these colors.

    ### Downside of HCT

    CAM16 is an expensive color model to calculate. Combining two disparate color models means calculating back out of the
    space is now more difficult and requires more complex calculations to approximate back out of the color model. And while
    CAM16 is "perceptually accurate", there is no perfectly perceptual model. CAM16 still suffers from purple
    shifts in the blue region as an example.

    ## What About OkLCh?

    Some people may be interested in other solutions. CSS already makes Oklab/OkLCh available, it is much easier and far
    less expensive to calculate. It also has much better hue preservation in the blue region. But if we try it, we can see
    the lightness is not so desirable.

    Scaling the tone pattern from [0, 100] down to [0, 1] (Oklab's lightness scaling) and gamut mapping the colors to sRGB
    tightly using OkLCh, we can see that the lightness poses an issue.

    ```py play
    def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    for color in colors:
    Steps(oklch_tonal_palette(color))
    ```

    But Björn Ottosson, in his blog post about Okhsl and Okhsv provided an [alternative lightness][L_r] for Oklab/OkLCh.
    This alternative lightness was used to create Okhsl and Okhsv with a lightness response similar to what people expect
    with CIE Lab. Using a toe function to adjust the black level, he was able to better approximate CIE Lab lightness.

    So what happens if we try to use this lightness to generate tonal maps in OkLCh? Let's find out!

    To do so, we can use the existing OkLCh color space, but when setting the tone, we will approximate CIE Lab lightness
    by using the inverse toe to translate the tone values back to normal Oklab and OkLCh lightness.


    ```py play
    K_1 = 0.206
    K_2 = 0.03
    K_3 = (1.0 + K_1) / (1.0 + K_2)

    def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

    def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002) for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    for color in colors:
    Steps(oklch_tonal_palette(color))

    ```

    The results seem to have pretty decent contrast, and our blue palette doesn't have a purple shift. But let's compare and
    see how OkLCh looks next to HCT.

    ```py play
    K_1 = 0.206
    K_2 = 0.03
    K_3 = (1.0 + K_1) / (1.0 + K_2)

    def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

    def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))
    ```

    The results are surprisingly similar, but there is still a noticeable difference in lighting in the dark region. Is it
    possible to tweak the toe to get a little closer to HCT results? Here we cut the `K2` value in half from `0.03` to
    `0.015`. This gives us results that can be more difficult to distinguish against, except for the different blue results.

    ```py play
    K_1 = 0.206
    K_2 = 0.015
    K_3 = (1.0 + K_1) / (1.0 + K_2)

    def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

    def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklab')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.002).convert('srgb') for tone in tones]

    def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 100]
    return [c.clone().set('t', tone).fit('srgb', method='hct-chroma', jnd=0.02).convert('srgb') for tone in tones]

    colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

    for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))
    ```

    ## Conclusion

    While HCT does make it easier to create palettes with decent contrast, there may be less computationally expensive
    approaches to get similar results.

    [L_r]: https://bottosson.github.io/posts/colorpicker/#intermission---a-new-lightness-estimate-for-oklab
    [material-hct]: https://material.io/blog/science-of-color-design