Summary#
A theme's colours are fixed when an app is compiled. An app that lets a person choose colours — a brand colour in
a setup screen, a label's tint, a board of swatches — needs to do colour maths while it runs: is this text readable
on that ground, what is the same colour but lighter, what palette goes with this one. Osysharp.Color is that maths
as ordinary functions over hex strings, the same on the server and in the browser.
app Atelier {
use Osysharp.Color@0;
model "model/**/*.osy";
}Signature#
using Osysharp.Color;
string NormalizeHex(string text) // "#abc" → "#AABBCC"; "" when it is not a colour
bool IsHexColor(string text)
double ContrastRatio(string one, string other) // WCAG 2: 1 (none) to 21 (black on white)
string ContrastText(string one, string other) // "4.6 : 1"
bool PassesAA(string text, string ground, bool large = false) // 4.5, or 3 for large text
double Luminance(string hex)
bool IsDark(string hex)
string InkOn(string ground) // black or white, whichever reads better
string ReadableOn(string colour, string ground, double ratio = 4.5)
Oklch ToOklch(string hex) // .L 0–1, .C 0–~0.37, .H degrees
string FromOklch(double l, double c, double h)
string WithLightness(string hex, double lightness)
string WithChroma(string hex, double chroma)
string Rotated(string hex, double degrees) // 180 is the complement
string Mix(string first, string second, double amount)
List<SuggestedPalette> SuggestPalettes(string baseColor)
bool IsReadable(PaletteMode mode)Description#
Why OKLCH?#
In OKLCH a colour is its lightness, its chroma (how colourful) and its hue. Equal steps in those numbers look like equal steps, so "the same colour, lighter" keeps its hue — where doing the same on red, green and blue drifts toward grey or toward a neighbouring hue. Everything in the kit that changes a colour changes it there.
FromOklch never answers a colour a screen cannot show: one that is too colourful for its lightness keeps its
lightness and hue and gives up chroma — the nearest colour of that lightness and hue that exists.
Can this text be read?#
ContrastRatio is WCAG 2's: the ratio of the two colours' relative luminance. Ordinary text needs 4.5 (PassesAA),
large text and the outline of a control 3 (large: true).
ReadableOn(colour, ground) answers the colour made readable on the ground: the same hue and chroma, its
lightness moved away from the ground's only as far as the ratio needs. A colour that already passes comes back
unchanged; a ratio the pair cannot reach answers black or white.
var label = ReadableOn(brand, "#FFFFFF"); // the brand colour, usable as text on white
var chip = InkOn(brand); // what to write ON the brand colourPalettes from one colour#
SuggestPalettes(base) answers four palettes, each with a Light and a Dark mode, each mode a Ground,
Surface, Text, MutedText, Accent and OnAccent:
| palette | what it is |
|---|---|
| Quiet | the base's own hue, kept low: a tinted paper, ink of the same family, the base as the accent |
| Base as accent | a neutral ground with no tint, and the base as the only colour on it |
| Complement | the base tints the ground and the text; the accent is the colour opposite it |
| Deep | the base itself is the ground, dark; in the light mode it is the ink on a pale ground |
Every palette it proposes can be read. Each mode is built and then checked — text and quieter text against the
ground and the surface, the accent against the ground, the words on the accent — and whatever falls short is moved in
lightness until it passes. IsReadable(mode) is that check, for a mode you have changed yourself.
The role names are a theme's: Ground is Bg, Surface is Surface, Text is OnSurface, MutedText is TextMuted,
Accent is Primary, OnAccent is OnPrimary (theme tokens).
Examples#
app Atelier {
use Osysharp.Color@0;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Color;
[Composable]
component Swatch(string hex) {
render {
Row(gap: 2, align: Align.Center, bg: hex, color: InkOn(hex), px: 3, py: 2, rounded: Radius.Md) {
Text(NormalizeHex(hex));
Text(ContrastText(InkOn(hex), hex));
}
}
}[Test]
void the_ink_chosen_for_a_ground_passes_on_it() {
Assert.Equal("#AABBCC", NormalizeHex("#abc"));
Assert.True(PassesAA(InkOn("#0B1F25"), "#0B1F25"));
Assert.True(PassesAA(InkOn("#F4F1EA"), "#F4F1EA"));
}See also#
- theme tokens — a theme's tokens, light and dark, and
Palette.Fromfor a ramp fixed at compile time - Designing an app that looks designed — direction, type, colour, composition and motion — choosing a look on purpose