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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.