moonrgbacolour for MoonBit

Guide

From a value on the clipboard to a theme scale.

Fourteen short chapters, in the order you are likely to need them. The listings are the code of one program — src/examples/guide — and the right-hand pane of each is what that program printed when it ran.

14 chaptersMoonBitApache-2.0

01

Install

Add the package and import it under a short name. Everything lives in one package, so one import is all there is.

shell

moon add moonbitstack/moonrgba

moon.pkg

import {
  "moonbitstack/moonrgba",
}

After that, @moonrgba. reaches everything on this site. There is no configuration, no initialisation step, and no I/O: a colour is a value, and every function here is a pure conversion of one.

Four targets. The library is compiled for the native, wasm, wasm-gc and JavaScript backends on every run, so the same code works in a build script, in a page, and in a wasm module. The homepage board is the library itself, compiled to JavaScript.
02

A value, read and written

decode takes one value — what would sit on the right of color: — and gives a colour. encode gives back the shortest spelling that reads as the same colour.

moonbit

let brand = @moonrgba.Color::decode("#0891b2")

brand.encode()
@moonrgba.Color::decode("rgb(10 20 30 / 50%)").encode()
@moonrgba.Color::decode("rebeccapurple").encode()
@moonrgba.Color::decode("hsl(190deg 91% 36%)").encode()

prints

#0891b2
rgb(10 20 30 / 0.5)
#639
hsl(190 91% 36%)

Three of those four results are shorter than what went in, and none of them is a different colour. rebeccapurple is written #639 because three digits hold it exactly; the deg goes because degrees are the default unit; and a half is written as a fraction rather than as a percentage.

The writer only shortens when the short form is exact. A channel of 127.5 has no two-digit hexadecimal spelling, so it stays rgb(127.5 127.5 127.5) rather than being rounded into a slightly different colour.

03

Into another space

Every conversion is a method named after its destination, and it returns a colour tagged with that space. Nothing is converted twice by hand.

moonbit

brand.to_oklch().encode()
brand.to_display_p3().encode()
brand.to_xyz_d65().encode()
brand.to_srgb().components()
brand.to_cmyk().k

prints

oklch(0.60891 0.11094 221.72312)
color(display-p3 0.25345 0.55999 0.68426)
color(xyz-d65 0.1826 0.23516 0.45697)
0.03137254901960784, 0.5686274509803921, 0.6980392156862745
0.3019607843137255

Two hubs do the work: a conversion between two spaces with primaries goes through D65 XYZ, and a conversion involving HSL, HSV, HWB or YCbCr goes through sRGB, where those four are defined. Which route a pair takes is in the table on the homepage, and it is the reason a round trip through a related space is exact rather than nearly exact.

to_cmyk is the exception: four inks do not fit in three components, so it returns a Cmyk of its own, and the colour survives the round trip while the ink quantities need not.

04

Building one directly

Each constructor takes its space's own units: rgb takes 0–255, the rest take the scale their space is defined on. The colour stays in the space it was built in, which is why an HSL colour is written back as HSL.

moonbit

@moonrgba.Color::rgb(8, 145, 178).encode()
@moonrgba.Color::hsl(190.0, 0.91, 0.36).encode()
@moonrgba.Color::oklch(0.6, 0.11, 222.0).encode()
@moonrgba.Color::rgb(8, 145, 178, alpha=0.4).encode()

prints

#0891b2
hsl(190 91% 36%)
oklch(0.6 0.11 222)
#0891b266

Alpha is an optional labelled argument everywhere, and it is a fraction of one rather than a byte, so nothing rounds while the colour is being built. The last line is a hexadecimal spelling with alpha: 66 is 0.4 × 255, and 0.4 has an exact byte only because it was chosen to.

05

The gamut

Oklch can describe colours no screen can show. Asking whether one fits is a question; doing something about it is a choice between two answers.

moonbit

let wide = @moonrgba.Color::oklch(0.7, 0.4, 30.0)

wide.in_gamut(space=@moonrgba.Space::Srgb)
wide.clip(space=@moonrgba.Space::Srgb).encode()
wide.to_gamut(space=@moonrgba.Space::Srgb).encode()
wide.to_gamut(space=@moonrgba.Space::Srgb).to_oklch().encode()

prints

false
#f00
rgb(255 88.00945 67.46665)
oklch(0.6831 0.2062 30.18163)

clip pushes each channel into 0–1 and keeps whatever comes out. It is cheap, it is what a browser does to a colour it cannot draw, and it is why the clipped result is a pure red: two channels were below zero and both became zero, so the hue that was there is gone.

to_gamut keeps the hue and lowers chroma until the colour fits. The result is still red, but a red with some orange left in it and a lightness close to the one asked for. The last line shows what it cost: chroma 0.4 became 0.206, and lightness moved by 0.017, because the search lands on the surface of the gamut rather than on the line through it.

The gamut page draws this: the sRGB surface at a chosen lightness, and the path the search takes to it.

06

Mixing

mix is color-mix(). The space decides what the mixture means, and weight is how much of the first colour is left: 1 is the first colour, 0 the second.

moonbit

brand.mix(@moonrgba.Color::white(), space=@moonrgba.Space::Oklch).encode()
brand.mix(@moonrgba.Color::white(),
  space=@moonrgba.Space::Oklab, weight=0.25).encode()

let red = @moonrgba.Color::hsl(350.0, 1.0, 0.5)
let orange = @moonrgba.Color::hsl(10.0, 1.0, 0.5)
red.mix(orange, space=@moonrgba.Space::Hsl, hue=@moonrgba.Hue::Shorter).hue()
red.mix(orange, space=@moonrgba.Space::Hsl, hue=@moonrgba.Hue::Longer).hue()

prints

oklch(0.80446 0.05547 221.72312)
oklab(0.90223 -0.0207 -0.01846)
0
180

Mixing with white in Oklch keeps the hue and lifts lightness; doing it in Oklab gives a different colour, because Oklab interpolates the two axes rather than the polar pair. Neither is more correct — the space is the choice.

350° and 10° are twenty degrees apart the short way round and three hundred and forty the long way. The four directions name those two routes explicitly, which is what stops a gradient between two reds from sweeping through blue.

Alpha is premultiplied, as CSS Color 5 requires: a transparent colour contributes less to the result than an opaque one at the same weight. A missing component is filled in from the other colour before the arithmetic, and stays missing only if both sides miss it.
07

Contrast

Two measures, and they are not two spellings of one idea.

moonbit

let paper = @moonrgba.Color::white()
let ink = @moonrgba.Color::black()

brand.contrast(paper)
brand.apca(paper)
paper.apca(ink)
ink.apca(paper)
brand.luminance()

prints

3.6820433648230324
63.83199046294621
-107.88473318309848
106.04067321268862
0.23516774409322186

contrast is WCAG 2: a ratio from 1 to 21, symmetric, and the number the guidelines state thresholds in. 3.68 is below 4.5, so this blue is not a body text colour on white.

apca is signed, and that is the point. Light text on dark and dark text on light are not the same problem at the same ratio, because the eye is not symmetric about the background: the same near-black on white reads −107.9 and white on near-black +106.0. Compare magnitudes when you want one number; keep the sign when you want to know which way round the pair is.

luminance is the relative luminance both measures are built on, and it is the reason a saturated blue is darker than it looks.

08

Difference

How far apart two colours are, under four measures that disagree about what "far" means — which is why each one is asked for by name.

moonbit

let alarm = @moonrgba.Color::rgb(220, 38, 38)

brand.delta_e(alarm, delta=@moonrgba.Delta::E76)
brand.delta_e(alarm, delta=@moonrgba.Delta::E2000)
brand.delta_e(alarm, delta=@moonrgba.Delta::Ok)

prints

119.23892113137784
65.2938363262246
0.32534740298804965

These are not three estimates of one number. 119 and 65 are both "these two colours are obviously different", written on scales that happen to be numbered differently, and neither can be compared to the other. ΔE OK is measured in Oklab, where 0.02 is about the smallest step an eye notices; that is the unit the gamut mapping in the previous chapter spends.

ΔE 94 is the fourth, and the only one where the order of the arguments changes the answer: its first colour is the reference.

09

Adjusting

Four verbs, each of which moves one quantity of the colour it is given.

moonbit

brand.lighten(0.1).encode()
brand.darken(0.1).encode()
brand.saturate(0.2).encode()
brand.fade(0.5).encode()

prints

oklch(0.70891 0.11094 221.72312)
oklch(0.50891 0.11094 221.72312)
oklch(0.60891 0.19094 221.72312)
rgb(8 145 178 / 0.5)

The amount is a fraction of the scale the colour's own space uses for that quantity, which is why the first two results are in Oklch: an sRGB colour has no lightness to push on, so the adjustment happens where the quantity lives and the result stays there. Hue is untouched throughout — a lighten that turned the colour would be a different operation wearing the same name.

fade multiplies alpha, so 0.5 on an opaque colour leaves half, and applying it twice leaves a quarter.

10

Names

The 148 names CSS defines, both ways.

moonbit

@moonrgba.Color::of_name("RebeccaPurple").unwrap().encode()
brand.name().unwrap_or("no name")
@moonrgba.Color::rgb(0, 128, 128).name().unwrap()
@moonrgba.names_count()

prints

#639
no name
teal
148

Lookup ignores case, because CSS keywords do and a theme file written by hand will not agree about it. The reverse is exact: a colour has a name only if it is one of the 148, so the blue this guide has been using all along has none, and the library says so instead of offering the nearest.

11

In a terminal

Three tiers, and the nearest colour in each.

moonbit

brand.to_ansi16().to_string()
brand.to_ansi256().code()
brand.rgb8()
@moonrgba.Ansi256::of(196).color().encode()
brand.to_ansi16().color().encode()

prints

cyan
31
8, 145, 178
#f00
#008080

"Nearest" is measured in Oklab, not in sRGB. The palette is uneven — sixteen arbitrary colours, then a cube that is coarse near black and fine near white — and a distance measured in Oklab stays even across that unevenness.

The last two lines are the way back: a slot stands for a colour, and asking it is how you learn what a terminal will draw. These are xterm's defaults, which is what emulators are compatible with; a reader's theme may have replaced them.

12

Missing components

none is a component the author left out, and it is not zero.

moonbit

let unpainted = @moonrgba.Color::decode("oklch(0.5 0.1 none)")

unpainted.encode()
unpainted.hue()
unpainted.mix(@moonrgba.Color::oklch(0.8, 0.1, 20.0),
  space=@moonrgba.Space::Oklch).encode()
@moonrgba.Color::rgb(128, 128, 128).to_hsl().encode()

prints

oklch(0.5 0.1 none)
NaN
oklch(0.65 0.1 20)
hsl(none 0% 50.19608%)

Internally it is a NaN, which is the one value that cannot be mistaken for a real component. A zero hue is red, so a grey written as hsl(0 0% 50%) would drag an interpolation towards red; none takes the other colour's hue instead, which is why the mixed result above has one.

The last line is the same fact from the other direction: a grey converted to HSL has no hue to report, and the writer says none rather than inventing zero.

13

When it refuses

decode raises Refused, which says where it stopped and what it was looking for.

moonbit

let problem : @moonrgba.Refused = try {
  ignore(@moonrgba.Color::decode("rgb(1 2)"))
  abort("unreachable")
} catch {
  error => error
}

problem.to_string()
match problem {
  @moonrgba.Unexpected(at~, got~, want~) =>
    "at \{at}, found \{got}, wanted \{want}"
  @moonrgba.Unknown(at~, got~) => "at \{at}, unknown \{got}"
}
@moonrgba.Color::decode_opt("rgb(1 2)")
@moonrgba.Color::decode_opt("chucknorris")

prints

at 7: got ), want a number
at 7, found ), wanted a number
refused, as an option
refused, as an option

There are two kinds of refusal, and a caller can tell them apart without reading the message: Unexpected is a value that broke the grammar, Unknown is a name or a function that is not a colour. at is an offset in code points from the start of the value, so a theme reader can point at the line it was reading.

decode_opt is the same reader with the refusal turned into None, for the case where you only want to know whether a value can be read.

14

Where next

The reference has every public function and type, with the signature it actually has. The gamut page draws the algorithm from chapter five. The homepage is the library compiled to JavaScript, with a dial for a hue and a card for the numbers.

Two examples are worth reading as a whole: src/examples/tour takes one colour through every space and every measure, and src/examples/theme builds a theme scale from a single seed colour, including which of two inks to print on each step.