# Colour

`Colour` is a colour as a web page uses it: written as CSS writes a hex
colour, `#1e6fd9`, and held as its red, green, blue and alpha channels.
Use it for a brand colour, a theme, or a check that text is readable on
its background.

Here a design system reads its brand colour from a form, checks that body
text is readable on its page colour, lightens a button for its hover
state, makes a tint, and turns a designer's HSL and OKLCH values into
colours:

```jadpo
type FormText = Text {}

type YesNo = Bool {}

type SwatchHue = Angle {}

type SwatchShare = Percentage {
    min: 0%
    max: 100%
}

type SwatchChroma = Decimal {
    scale: 4
    min: 0.0000
}

type NotAColour = Failure {
    kind: FailureKind.invalid_value
}

/// The brand colour a settings form names.
function settings_colour(raw: FormText) fails NotAColour -> Colour {
    var typed_colour: Colour = attempt text.parse(raw, as: Colour) {
        malformed: NotAColour
    }
    return typed_colour
}

/// A colour as a style sheet writes it.
function css_colour(swatch: Colour) -> FormText {
    return FormText(text.from(swatch))
}

/// Whether body text in `text_colour` on `page_colour` meets WCAG 2's AA.
function body_text_passes(text_colour: Colour, page_colour: Colour) -> YesNo {
    return YesNo(colour.contrast(text_colour, page_colour) >= 4.5)
}

/// A button's hover colour: its hue, lighter.
function hover_colour(button_colour: Colour) -> Colour {
    return colour.with_lightness(button_colour, 75%)
}

/// A tint of a colour: a fifth of the way to white.
function tint(swatch: Colour) -> Colour {
    var white: Colour = Colour {
        red: 255
        green: 255
        blue: 255
        alpha: 255
    }
    return colour.mix(swatch, white, 20%)
}

/// A designer's HSL swatch.
function hsl_swatch(
    swatch_hue: SwatchHue,
    swatch_saturation: SwatchShare,
    swatch_lightness: SwatchShare
) -> Colour {
    return colour.from_hsl(swatch_hue, swatch_saturation, swatch_lightness)
}

/// A designer's OKLCH swatch.
function oklch_swatch(
    swatch_lightness: SwatchShare,
    swatch_chroma: SwatchChroma,
    swatch_hue: SwatchHue
) -> Colour {
    return colour.from_oklch(swatch_lightness, swatch_chroma, swatch_hue)
}
```

```jadpo
test "a colour is read and written as CSS writes it" {
    var house_colour: Colour = attempt settings_colour("#1E6FD9")
    assert css_colour(house_colour) == "#1e6fd9"
    assert house_colour.blue == 217
    assert settings_colour("#1e6") fails NotAColour
    assert settings_colour("rgb(30 111 217)") fails NotAColour
}

test "contrast is WCAG 2's ratio, never rounded up" {
    var black: Colour = attempt settings_colour("#000000")
    var white: Colour = attempt settings_colour("#ffffff")
    var mid_grey: Colour = attempt settings_colour("#767676")
    var light_grey: Colour = attempt settings_colour("#777777")
    assert colour.contrast(black, white) == 21.00
    assert colour.contrast(white, white) == 1.00
    assert colour.contrast(mid_grey, white) == 4.54
    assert body_text_passes(mid_grey, white)
    assert not body_text_passes(light_grey, white)
}

test "lightness, mixes, HSL and OKLCH give sRGB colours" {
    var house_colour: Colour = attempt settings_colour("#1e6fd9")
    assert css_colour(hover_colour(house_colour)) == "#5fadff"
    assert css_colour(tint(house_colour)) == "#518de3"
    assert css_colour(hsl_swatch(120deg, 100%, 25%)) == "#008000"
    assert css_colour(hsl_swatch(210deg, 75%, 45%)) == "#1d73c9"
    assert css_colour(oklch_swatch(62.8%, 0.2577, 29.23deg)) == "#ff0000"
}
```

## The value

A `Colour` is an object of four channels, `red`, `green`, `blue` and
`alpha`, each a whole number from 0 to 255, in the sRGB colour space that
web pages use. Alpha is how opaque the colour is: 255 is fully opaque.

Its text is CSS Color 4's hex form: `#1e6fd9`, or `#1e6fd980` when the
colour isn't opaque. It is written in lower case and read in either case.
CSS's three- and four-digit short forms, such as `#1e6`, are refused, so
every colour has text of one length. `#1e6fd9ff` reads as opaque, the same
colour as `#1e6fd9`.

A colour is sent as that text and stored as one `smallint` column per
channel. It isn't ordered, so `<` doesn't compare two colours. It is shown
as its text in every locale, and it isn't personal data.

CMYK, the colours a printer uses, needs a colour profile, so a package
provides it rather than the standard library.

## Colour operations

Every operation gives an sRGB `Colour`. Other colour spaces, such as HSL
and OKLCH, appear only as arguments. No operation fails: a value out of
range is clamped, and a colour sRGB can't show is clipped into it.

| Operation | Gives | Fails | SQL form |
|---|---|---|---|
| `colour.from_hsl(hue, saturation, lightness)` | `Colour`; `hue` an `Angle`, the others `Percentage` | | none |
| `colour.from_oklch(lightness, chroma, hue)` | `Colour`, clipped into sRGB; `lightness` a `Percentage`, `chroma` a `Decimal`, `hue` an `Angle` | | none |
| `colour.mix(a, b, ratio)` | `Colour`, mixed in OKLCH as CSS's `color-mix()` mixes; `ratio` a `Percentage` of `b` | | none |
| `colour.with_lightness(value, lightness)` | `Colour` with its OKLCH lightness set, chroma, hue and alpha kept | | none |
| `colour.contrast(a, b)` | `Decimal`, WCAG 2's contrast ratio, 1.00 to 21.00, rounded down to 2 places | | none |

A parameter written `Percentage` or `Angle` takes any type built on it, as
a parameter written `Money` does, so your own named `Angle` or
`Percentage` type passes. A bare number doesn't: it is `type.mismatch`, or
`type.unit_required` where an `Angle` is expected.

## Values out of range

As in CSS, saturation, lightness and a mix's ratio are clamped to 0% to
100%, and a hue wraps round the circle. An OKLCH lightness is clamped to
0% to 100%, and its chroma to 0 to 1. CSS clamps chroma only below 0; the
upper bound of 1 is past every display's gamut, and it keeps the
arithmetic bounded.

An `Angle` is held in radians to 9 places, so its degrees are rounded to
6 places before use. That gives an angle written in whole degrees, such
as `120deg`, exactly.

## How the conversions are worked out

The conversions are CSS Color 4's exactly. They use its sample code's
rational sRGB and XYZ matrices and its OKLab matrices, to their 16 places.
A hue is powerless, so it is ignored, at a chroma of 0.000004 or less.

To clip a colour into sRGB, Jadpo clamps each channel to 0 to 1 after the
transfer function. It doesn't use CSS Color 4's chroma-reducing gamut
mapping. Each channel is then rounded to the nearest whole number from 0
to 255, a half rounding up, as CSS writes a channel out and as culori's
`formatHex` does. So `hsl(120deg 100% 25%)`, whose green is 127.5, is
`#008000`.

`mix` is CSS Color 5's `color-mix(in oklch, a, b ratio)`. Both colours
are taken into OKLCH, with lightness and chroma premultiplied by alpha.
The hue goes the shorter way round, and a powerless hue takes the other
colour's. The alpha is mixed, then rounded as a channel is.

`with_lightness` sets the lightness, keeps the chroma, hue and alpha, and
then clips the result into sRGB.

The arithmetic is Jadpo's own: fixed point to 18 places in an `i128`,
with `Decimal`'s correctly rounded `exp`, `ln`, `sin`, `cos`, `atan2`,
`sqrt` and π. So every target gives the same channels. Tests hold the
results to CSS Color 4's sample code run in f64, on this page's values and
200 generated ones. They leave out values within a millionth of a
rounding boundary, where an f64 can't say which side it is on.

## Contrast

`colour.contrast` gives WCAG 2's contrast ratio, which says whether text
is readable on its background. Its success criteria 1.4.3 and 1.4.6 test
against it: 4.5 and 7 for body text.

The ratio is `(L1 + 0.05) / (L2 + 0.05)`, where `L1` is the relative
luminance of the lighter colour and `L2` of the darker, so the order of
the two arguments doesn't matter. Relative luminance uses WCAG 2.0 and
2.1's threshold of 0.03928, weighted 0.2126, 0.7152 and 0.0722. WCAG
2.2's sRGB threshold of 0.04045 gives the same result for every 8-bit
channel.

The ratio is worked out to 12 places, then rounded down to 2. WCAG says a
ratio isn't rounded up to meet a threshold, so `>= 4.5` never passes a
pair just below it: `#777777` on white is 4.47 (4.478) and fails 4.5, and
red on white is 3.99.

Alpha isn't read, because WCAG 2 defines contrast only for opaque colours.
WCAG 3's draft contrast method, APCA, isn't used.

## Your own colour types

`type.standard_exists` reports a type of your own with a colour's shape,
so that you use `Colour` instead. The shape is a red, a green and a blue,
named in full or by their initials (`red` or `r`) and built on `Int` or
`Decimal`, and optionally an alpha (`alpha` or `a`). It is the first shape
with a field that may be left out. The swap renames the fields. A literal
of a three-channel type then needs its `alpha`, which `check` asks for.
