◆ Flux

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 Uint32Array and 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:

FLUX
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 alone

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

The two colour channels leaving analysis 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.

FLUX
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 theme

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

FLUX
color bars: if close > ema(close, 200) then up else down   // pinned constants; the host remaps to theme

What 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:

FLUX
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 symbol

Theme 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:

FLUX
triangle { at: (bar.i, low), r: 6, fill: token.bull }   // CANVAS — theme-aware, and correctly so

Why 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
FLUX
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 OKLab

A 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:

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:

A gradient is therefore an ordinary expression over data, and it is in the oracle like any other:

FLUX
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 volume

Three 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:

FLUX
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:

FLUX
bb = bollinger(close, 20, 2)
fill bb.upper..bb.lower { color: up }   // ✗ [ErrArg] — the { color: } channel is plot-only in v1

Why 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:

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:

FLUX
shade = up + 1   // ✗ [ErrDim] — a colour is not a number: there is no meaning to add to it

The 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:

FLUX
plot up   // ✗ [ErrPlot] — a colour is not a series; it is consumed by `color bars:` or the { color: } channel

Every 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