Colour
On this page
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:
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)}
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.
Markdown for agents: /docs/colour.md.Built from Jadpo main at a707efb.