color — colour as a value
Colour sits on a fault line. It is presentation — it belongs to the theme, to the visitor, to the eyes — and yet a colour derived from data is a decision the analysis made, and a decision the analysis made must be reproducible to the byte, or replay drifts and a server can no longer re-derive what a client claims to have computed.
Flux resolves this with a clean split rather than a compromise. The per-bar colour decision is analysis: deterministic, replayable, inside the oracle. The mapping to pixels is presentation: theme-aware, per visitor. Determinism lives where the data lives; theme lives where the eyes are. Everything on this page follows from that one line.
This page covers the color kind end to end: how a colour is represented, how you construct one,
how two colours interpolate, the channels through which a colour reaches the chart, and the
boundaries the design deliberately keeps closed. Some samples below are negative — a fragment
followed by ✗ and an error code is an illustration of a rule, not a program.
New here? Start with Guide §8 — The four planes → — the analysis/presentation firewall this whole pillar rests on, in plain terms.
The value — a u32 RGBA carried as an exact f64 integer
A color is RGBA8, packed 0xRRGGBBAA (red in the high byte, alpha in the low byte, straight
alpha), and carried through the dataflow as a non-negative f64 integer.
That is not a compromise; it is exact. A u32 is below 2³², and every integer below 2⁵³ is represented exactly in an f64 and round-trips through one. So a colour is an f64 whose value happens to be an integer, and the consequences are entirely good:
| Consequence | Why it falls out |
|---|---|
| No new compute channel | The whole f64 engine — const, select, na, the I7 gate over f64 columns — is reused as-is. |
if c then a else b on colours |
Already the existing f64 select. Nothing was added for it. |
color == color |
An exact f64 == — bit equality, → signal. |
na colour |
The canonical NaN — distinct from every finite colour integer. At the host it means no per-bar override (transparent). It propagates through select, so totality holds. |
| Serialization | The one place the representation shows: a colour column ships as a Uint32Array, never an f32 array. na serializes to 0x00000000. |
Why the sink is
Uint32Arrayand never f32. An f32 has 24 bits of mantissa, so it stops representing consecutive integers past 2²⁴ — an RGBA value above that would be silently rounded to a neighbouring colour. The colour would still look plausible, which is the worst kind of bug. The column ships as u32, and the class of bug does not exist.
The packing order 0xRRGGBBAA matches the CSS #rrggbbaa notation, which is convenient at the
boundary but is otherwise an internal, pinned detail: the host extracts channels explicitly.
What I7 cares about is not the layout — it is that the interpreter and the compiled module compute
the same f64 integers, which they do.
The absent colour
na is a colour like it is a number: the value that is not there. On a colour column it means
no per-bar override, so the bar keeps whatever the chart would have drawn. That makes
“colour only the bars I care about” an ordinary expression rather than a special mode:
fresh = barssince(close cross_up ema(close, 50)) < 5 // signal
color bars: if fresh then up else na // colour the fresh bars; leave the rest aloneThe same rule covers warm-up at no cost: on the bars where an indicator has not yet filled its
window its value is na, so a colour derived from it is na, so those bars are left
un-overridden. Nothing throws, nothing is undefined, and no branch had to be written for it.
The analysis / presentation split
Two channels leave the analysis plane, and choosing between them is the first decision you make. The rule that governs the split — what may cross from analysis into presentation, and what may never cross back — is the four-planes firewall, told in full in The four planes & the firewall; this section is how that firewall lands on colour specifically.
Figure — analysis emits either a
dir decision the host maps to the theme (colour-vision-deficiency-safe) or an explicit colour from pinned constants and pinned maths; theme tokens resolve per visitor, so they stay on the presentation side of the firewall.
dir — the semantic channel, and the idiomatic one
dir is the kind {-1, 0, +1}. Analysis answers the semantic question — is this bar up, flat
or down? — and the host maps that answer to the theme’s up / neutral / down colours.
st = superTrend(10, 3) // sourceless (it reads high/low/close) → record{ st: price, dir: dir }
color bars: st.dir // the host maps {-1, 0, +1} to the themeThis buys two things at once. Theme-awareness without breaking determinism: the analysis value
is a dir, not an RGBA, so what the oracle byte-compares is the decision, not its appearance.
And accessibility for free: because the host owns the mapping, it can map dir to a
colour-vision-deficiency-safe palette — roughly one man in twelve cannot separate red from green,
and a script that had hard-coded red and green would have made that unfixable.
dir is the idiomatic way to colour bars. Reach for an explicit color when you need a colour
the theme cannot name.
color — the explicit channel, for custom colours and gradients
An explicit colour in analysis is legal, and it is deterministic because of where its values can
come from: they are pinned constants (up, down, neutral) or computed by pinned maths
(mix, rgb of computed channels). Both are inside the oracle and covered by I7.
color bars: if close > ema(close, 200) then up else down // pinned constants; the host remaps to themeWhat is not legal is a colour that varies with the viewer. A theme token resolves against the visitor’s current theme, which makes it an ambient, per-visitor input — the same class as reading the wall clock or the screen:
color bars: if close > open then token.bull else token.bear // ✗ [ErrFirewall] — a token resolves per visitor
color bars: if minute(now()) > 30 then up else down // ✗ [ErrFirewall] — now() is a presentation symbolTheme is a host remap of pinned or semantic values at render time, never an ambient input into analysis. Tokens are for the plane where the eyes are:
triangle { at: (bar.i, low), r: 6, fill: token.bull } // CANVAS — theme-aware, and correctly soWhy the firewall is drawn here and not one step later. If a theme colour could enter analysis, then the value a script produced would depend on who was looking at it. Two visitors would compute different bytes from the same data; a golden would depend on a theme; a server re-deriving a client’s run would have to know the client’s colour scheme to agree with it. The line is drawn where the data is, so that the data means the same thing everywhere.
Choosing a channel
| What you want | What you write |
|---|---|
| bars coloured by a semantic state — trend direction, the side of a stop | a dir; the host maps it to the theme, and to a safe palette for colour-vision deficiency |
| a brand colour, a scientific palette, a gradient | an explicit color — pinned constants or pinned maths |
| a theme colour in the UI or on the canvas | a token.* |
| a theme colour in analysis | nothing: it is [ErrFirewall]. Emit a dir and let the host map it — that is the same picture, computed on the right side of the line |
Construction — the color.* surface
Every constructor is an ordinary call of a closed set of host-vetted functions. There is no grammar change anywhere in this pillar: no colour literal, no new token, no re-verification of the frozen grammar.
Pinned semantic constants, versioned like the pinned maths, aligned with the chart theme:
up, down, neutral.
| Constructor | Arguments | Notes |
|---|---|---|
rgb(r, g, b) |
r, g, b ∈ [0, 255] |
clamped deterministically |
rgba(r, g, b, a) |
plus a : ratio ∈ [0, 1] |
straight alpha |
hsl(h, s, l) / hsla(h, s, l, a) |
h ∈ [0, 360), s, l ∈ [0, 1] |
HSL → RGB is piecewise-linear — zero transcendentals, so determinism is trivial |
hex(s) |
"#rgb", "#rrggbb", "#rrggbbaa" |
a string literal, parsed at compile; a malformed literal is na plus a diagnostic |
withAlpha(c, a) / fade(c, a) |
a channel replace — cheap | |
lighten(c, amt) / darken(c, amt) |
perceptual: they move OKLab lightness | |
mix(a, b, t) |
t : ratio |
the perceptual blend — see below |
brand = rgb(34, 211, 163) // a const-folded colour node
mint = hex("#22d3a3") // parsed at compile — the same node
ghost = up.withAlpha(0.25) // straight alpha, not premultiplied
def ramp(t) = mix(down, up, t) // a gradient — blended in OKLabA constructor whose arguments are all constant const-folds to a const colour node.
A constructor with dynamic arguments lowers to a deterministic runtime operation — and is
byte-identical between the interpreter and the compiled module, like any other kernel.
Theme tokens
token.bull, token.bear, token.grid are the theme’s colours. They are theme-aware — they
resolve against the visitor’s current theme — and they are the right default for anything
semantic in the UI and the canvas. That same property is what keeps them out of analysis
(previous section). An explicit color is, by contrast, theme-blind by the author’s choice:
that is what you want for a brand colour or a scientific palette, and it is what you do not want
for “the bullish one”.
The pinned palette scales
Scientific and categorical palettes come from a host-pinned, versioned palette table — at the same rank as the currency-symbol and Unicode tables — sampled by a closed set of calls:
| Call | What it gives |
|---|---|
color.seq(scheme, t) |
a perceptual sequential scheme sampled at t : ratio |
color.div(scheme, t) |
a diverging scheme |
color.cat(scheme, i) |
a categorical, perceptually balanced entry at index i |
color.quantize(scheme, t, n: lit) |
the same, quantized into n discrete steps |
The scheme names a row of that closed table (the sequential family — viridis, magma — and the
diverging and categorical families beside it). Like hex, it is checked at compile: a palette
lookup is a compile-time selection, never a string interpreted at run time. These are the
first-party colour scales that the viz.* encoding channels use — see display.
Hue-true scheme generation in OKLCH — and the withHue / withChroma / withLightness family —
waits on the pinned-maths completion lot that delivers atan2 and the sine-cosine pair; OKLab
Cartesian (below) ships first and needs neither of them.
That gate is narrow, and two neighbouring families sit outside it. Both are pure channel
arithmetic, and both need only pow — which is pinned, and available:
- the WCAG helpers —
color.relLuminance(c), which is the linearization the OKLab path already performs, exposed; andcolor.contrast(a, b) -> ratio, so an author can validate a palette’s contrast at compile time rather than discovering it in an accessibility audit; - the compositing algebra —
color.over(a, b), source-over on straight alpha, plusmultiply,screenandoverlay, as pinned pure functions. The scene’sblendproperty names a host mode; this is the value-level algebra, for an author computing a colour rather than asking for one.
Neither waits on the transcendental lot, and it is worth being explicit about that, because the natural assumption — “they are colour maths, so they are behind the colour-maths gate” — is wrong in a way that would defer two useful families for no reason.
Interpolation — OKLab, perceptually uniform, deterministic
mix(a, b, t) blends two colours in OKLab. The pipeline, per channel:
sRGB8 → [÷255] → linear (gamma decode, pow 2.4) → LMS (3×3 matrix)
→ L′M′S′ (cbrt) → OKLab (matrix) → lerp L, a, b by t
→ the exact inverse (cube is x·x·x) → linear → sRGB (pow 1/2.4) → [×255, round]Why not a straight lerp in RGB. Interpolating red to green in raw sRGB passes through a muddy brown, because sRGB is not perceptually uniform: equal steps in its channels are not equal steps to the eye, and the midpoint of two saturated colours lands somewhere dark and grey. In OKLab, equal steps are perceptually equal, so a red-to-green ramp stays clean the whole way across. A gradient is a communication device; a gradient with a muddy middle is a broken one.
Determinism. Every pow in that pipeline routes through the pinned maths library
(see compute) — the same one an ema’s logarithm uses — so it produces identical
bits in the interpreter and in the compiled module. I7 holds through a colour blend exactly as it
holds through a moving average.
Three details of the pipeline are load-bearing, and each one is a decision:
- OKLab Cartesian, not OKLCH. The Cartesian form needs only
pow, which is pinned and available. The cylindrical form needsatan2and the sine-cosine pair, which are not — so choosing OKLab first is what lets perceptual blending ship now rather than behind a dependency. cbrtis realised as an odd function —cbrt(x) = sign(x) · pow(|x|, ⅓)— so a slightly out-of-gamut negative LMS value stays total instead of producing a NaN. Totality is not a property you get to suspend inside a colour routine.- The output is gamut-clamped to
[0, 255]per channel, with the rounding pinned (ties-to-even, exactly as the sinks round).
A gradient is therefore an ordinary expression over data, and it is in the oracle like any other:
t = norm(volume) // ratio in [0, 1] — normalized against its own range
heat = mix(neutral, up, t) // an OKLab ramp, one colour per bar
plot close { color: heat } // the series, coloured by relative volumeThree lines, no palette object, no colour-space bookkeeping, and a byte-identical result on every machine that runs it.
The output channels
There are three ways a colour reaches the chart, and only three:
| Channel | Accepts | What it produces |
|---|---|---|
color bars: … |
signal | dir | color |
a per-bar colour column on the candles |
{ color: … } inside a plot block |
a color per bar |
a parallel colour column attached to that plot’s sink |
a dir column |
— | the host maps {-1, 0, +1} to the theme’s up / neutral / down |
The { color: } channel is what makes a coloured histogram fall out of the ordinary algebra
rather than out of a special case:
m = macd(close, 12, 26, 9) // record{ macd, signal, hist : level }
plot m.hist { color: if m.hist > 0 then up else down }In v1 the { color: } block channel is plot-only. On a fill or a mark block it is an
error rather than a silently ignored property:
bb = bollinger(close, 20, 2)
fill bb.upper..bb.lower { color: up } // ✗ [ErrArg] — the { color: } channel is plot-only in v1Why an error and never a silent drop. A property that is accepted, ignored and never drawn is a bug you find by staring at a chart and wondering. The compiler knows the channel does not exist on that block; it says so.
How a colour reaches the chart engine
The chart carries a per-bar colour input, and it has three arms that mirror the three channels above:
- a
dirorsignalcolumn recolours the candles through the existing per-bar mechanism — theme and colour-vision-deficiency aware, because the mapping is the host’s; - an explicit
colorcolumn (u32) drives a per-bar RGBA path — the candle body and wick, or a series; - a
plotwith a{ color: }channel colours each bar or segment of that series.
Per-bar colour on an overlay-scale series — a moving average, a price line — ships as coloured segments.
Colouring a mark glyph (a dot), and colouring a fill band, land after the two v1 colour
consumers — color bars: and the plot { color: } channel. Under an MTF lock — where the geometry
belongs to a series on another timeframe — a per-bar colour falls back to the base colour; per-bar
colour by the locked timeframe is a follow-up.
The I7 gate — a colour is data, so it is checked like data
I7, the oracle and what “byte-identical” is worth are defined in Guarantees; here is what that gate means for a colour. The gate compares every sink column at the f64 bit level, and it is deliberately agnostic to what a column means. A colour column is u32-in-f64, so it is compared exactly like a price column: the gate needed no change to accept colour, and it will not accept a colour that differs by one bit between the two engines.
What that costs, concretely: every colour operation must emit identical bits on both sides
(const and select are free; pack and the OKLab path route through the pinned maths), and the
corpus that exercises the gate is deliberately hostile — channels at 0 and 255, alpha at both
ends, na, and out-of-gamut inputs to mix.
This is the whole reason a colour is not a “style”. A style would be outside the oracle, and a colour programme would be unverifiable. A colour is data, and it is byte-checked like every other value.
Totality and the lattice
color is a flat categorical sort. It is not on the numeric spine, and the lattice enforces
that:
shade = up + 1 // ✗ [ErrDim] — a colour is not a number: there is no meaning to add to itThe only operator defined between two colours is == (bit equality, → signal). Colour carries
no order, so < and its relatives do not type-check on it either. And a colour is not
line-plottable:
plot up // ✗ [ErrPlot] — a colour is not a series; it is consumed by `color bars:` or the { color: } channelEvery colour operation is total: channels clamp, na propagates, and there is no undefined
behaviour to hit. That is what lets a colour flow through select and na without a single
special case anywhere in the engine.
Deliberate boundaries
| Boundary | Status |
|---|---|
A free CSS colour string — url(…), expression(…), embedded HTML |
Never. No constructor produces one and the lexer carries no colour literal, so it is structurally inexpressible — not filtered, not sanitized, not reachable. |
A #rrggbb grammar literal |
Not adopted: a new token would force a zero-conflict re-verification of the frozen grammar, and hex("#…") already covers the need. |
| OKLCH and hue interpolation | Waits on the pinned-maths completion lot (atan2, sine-cosine, cbrt); OKLab Cartesian ships first. |
Colouring a fill band or a mark glyph |
Lands after the v1 runtime colour consumers, color bars: and the plot { color: } channel. |
| Theme and colour-vision-deficiency palette definition | A host concern by design — analysis emits dir or pinned colours, and the host maps them. |
| Wide gamut, HDR, premultiplied alpha | Out of scope. 8-bit straight-alpha sRGB ships. |
The first row is the one worth dwelling on, because it is the only genuine injection surface a colour system has. The reason a free CSS string cannot reach the renderer is not that a sanitizer rejects it. It is that there is no way to say it: the constructors are a closed set, none of them takes an arbitrary style string, and the grammar has no colour literal for one to hide in. A boundary you enforce with a filter is a boundary you will eventually get wrong. A boundary you enforce with the grammar is one you cannot.
For richer backgrounds, the closed structural sort paint — a variant over Solid, Linear,
Radial and Texture — is the sanctioned surface, and its texture arm carries an asset key
that the host resolves under an allowlist, never bytes from the script. See
display.
See also
- display — the scene, the
viz.*encoding channels, andpaint. - Kinds — where the
colorsort sits, and why it has no arithmetic. - compute — the pinned maths that the OKLab path routes through.
- Guarantees — I7, the oracle, and what “byte-identical” is worth.
- The four planes & the firewall — the firewall this pillar is drawn against.