Colors

View as MarkdownOpen the editor

A color in a wrun indicator lives in one of three places: as a literal on a declaration, as a per-bar palette index on a color_by output, or as a packed number that ./sdk/color computes for a drawing handle while the module runs; in every place, seven theme tokens name the chart's own colours instead of a fixed one.

This page covers the forms a declaration accepts and where a name is refused, the theme tokens, the per-bar ladder, and the run-time kit: ink, fromHex, alpha, mix, lighten, darken, the channel readers, toPacked and fromPacked for the packed colour an output carries, ColorScale and Thresholds.

Where a color lives

PlaceWhat it isWho reads it
a declaration (color, colors, borderColor, a renderer's color)a string literal, or a param.color named as "@name"Run reads it as text; the module never executes it
a color_by outputa number per bar, floored into the colors palettethe chart, per bar
a drawing handle (draw.line, draw.box, draw.label, draw.polyline)a packed i32 from ./sdk/color or rgba(...)the module, through color(...) and fill(...) while it runs

The split is the one behind every declaration: the module computes numbers and the chart draws them from the declaration, so the decision behind a color (a regime, a bucket) stays a number your file can test, read at the Console prompt, or hand to a declared alert. What you would ask a color function for, and where each answer lives:

You wantOn a declarationOn a handle
an RGB colora literal: "#2563eb", "rgb(37, 99, 235)", "hsl(221, 83%, 53%)"ink.SKY, fromHex("#0ea5e9"), or rgba(14, 165, 233, 255) from ./gen/draw
transparencythe opacity option (0..1), or alpha inside the color ("#2563eb66")alpha(c, 0.4)
a lighter, darker or blended colorwrite the result as a literallighten, darken, mix
a gradient driven by a valuea colors palette plus color_by over a bucket index: stepped, as fine as the palette is longmix(a, b, t) per bar: continuous
a named palettea colors literal array; there are no named palettesink.*, twenty named packed colors
a color the user picksparam.color, bound by "@name" (The Style page)p_<name>() reads it as one packed number (Setting kinds)

Color forms on declarations

The chart parses a declared color string as a CSS color. Every form, and where it is accepted:

FormExampleAccepted on
six-digit hex"#2563eb"everything
eight-digit hex, alpha in the last two digits"#2563eb66"everything; the built-in way to carry alpha
rgb() / rgba()"rgb(37, 99, 235)"everything
hsl() / hsla()"hsl(221, 83%, 53%)"everything
a named color"orange"an output's color and its colors palette, a segment, a box borderColor, render.*, draw.*, a range(); refused on a box fill
"@name""@basis_color"color and a colors entry, naming a param.color
a theme token"theme.up"everything: the chart's own colour, resolved when it paints (Theme tokens)

A box's color is its fill, and the fill takes the box's opacity, so the host must be able to rewrite the color with an alpha channel. That is why a box color must be hex (3, 4, 6 or 8 digits), rgb() / rgba(), hsl() / hsla() or a theme token: a name there is refused at validation with color must be a hex, rgb() or hsl() color (the fill takes the opacity). borderColor is a stroke and takes any form.

Alpha has two spellings. An eight-digit hex, rgba() or hsla() bakes it into the color; the opacity option (0..1) on an output, a box, a range, a fill, a handle or a card applies it on top. The two compose: a box with color: "#2563eb" and opacity: 0.15 fills at fifteen percent, and an output's opacity fades every colour it draws, a ladder's rungs and a candle's border and wick included.

The sheet validator checks a box fill's form before the module runs; the chart parses every other color string when it draws. A channel outside its range (rgb(300, 0, 0)) is not a build error, but it is not a color the chart can render either, so keep channels in 0..255 and hue, saturation and lightness in their own ranges. Six- or eight-digit hex everywhere is the safe habit: it validates on a fill, it carries alpha, and it is what every worked example in this tree uses.

Theme tokens

Seven words name the chart's own colours instead of a fixed one, and every colour key accepts them: theme.up and theme.down (the candle colours), theme.text (the axis text), theme.muted (that text at 55 percent), theme.bg (the background), theme.grid (the grid lines) and theme.accent (the app's accent). The chart resolves a token when it paints and again when the theme switches, with no rerun, so an indicator whose chrome is inked "theme.text" over "theme.bg" reads on a dark and a light chart alike. A token carries no alpha of its own: fade it through the opacity word of the surface (opacity, fill_opacity, background_opacity) or, at run time, alpha(theme.UP, 0.2) from ./sdk/color, which also has the tokens as packed values for the drawing handles (Theme colours). A theme. word outside the seven is refused by name, and a param.color default or preset stays a concrete colour: put the token on the colour key, not on the setting.

The colour words that arrived with the styling round (an output's fill_color, fill_gradient, gradient, up_color, down_color, border_colors, wick_colors; a fill(...); a card, feed, meter, ladder or HUD surface; a tile; a plot.levels profile and a panel.* series; a frame's colour strings) take #rrggbb, #rrggbbaa or a theme token only, one colour grammar on every new surface: the refusal reads a colour must be #rrggbb, #rrggbbaa or one of theme.up, theme.down, theme.text, theme.muted, theme.bg, theme.grid, theme.accent. The older surfaces (an output's color and colors, a segment, a box border, the render.* and draw.* colours, a range()) keep taking a CSS name, rgb() and hsl() beside the token, and the new keys of those surfaces (background_color, border_color, glow_color, emblem_color, text_color, fill_color, a gradient stop) take the same forms as their siblings.

Named colors

The common names and the hex each one stands for. Write the hex instead of the name and the result is identical on every surface, including the one that refuses names.

NameHexNameHexNameHex
red#FF0000green#008000blue#0000FF
orange#FFA500lime#00FF00navy#000080
yellow#FFFF00olive#808000teal#008080
maroon#800000purple#800080aqua#00FFFF
fuchsia#FF00FFgray#808080silver#C0C0C0
black#000000white#FFFFFF

A name and its hex are interchangeable on an output, and each output owns its color: there is no shared array with an index argument.

text
output("sma20", line, overlay, { color: "orange", width: 2 });
output("sma50", line, overlay, { color: "#FFA500", width: 2 });   // the same orange

Choosing colors

  • Never rely on color alone: a shape mark or a data-only output carries the same decision for anyone who cannot see the tint, and for an alert, which cannot see it at all.
  • Semantic colors read fastest: green for bullish signals and positive values, red for bearish signals and losses, blue for neutral lines and references, orange or yellow for warnings.
  • Keep one scheme across related indicators. A package's colors are declarations, so they stay aligned across versions.
  • Three sets that read well together: green, red, blue for signals; orange, purple, teal for overlays; lime, yellow, fuchsia for volume.
  • The worked examples in this tree use one muted set that reads on dark and light themes alike: #2563eb blue, #16a34a green, #dc2626 red, #f59e0b amber, #7c3aed violet, #94a3b8 slate for reference lines, and #22d3a5 / #ff5b7f for bull and bear tints.
  • Theme tokens for chrome, fixed colours for meaning. Ink a label's background theme.bg, its text theme.text and a grid line theme.grid, so the indicator follows the viewer's theme; keep the colours that carry a signal (green, red, amber) as hex, so they mean the same on every chart.
  • The light-theme guard. On a light chart the chart darkens a colour that would vanish against the background; an indicator whose colours are chosen for both themes (theme tokens, or a palette tested on both) opts out with chart.contrast_guard(false) at the top of the file and draws exactly as written.

Per-bar color

A declared color cannot change per bar, and the module cannot compute one for a declaration. Per-bar color is a ladder: a data-only (none) output holds a palette index, and the drawn output names it with color_by over a literal colors array. Normalize the value into 0 .. n - 1, floor it, write it to the none output, and put an n-entry palette on the drawn output. What each bar draws:

Index on this barThe output draws
finite, inside the palettethat entry (floored)
finite, outside the paletteentry 0
NaN or infiniteits static color; entry 0 when none is declared

color_by and colors go together: either without the other is refused by name. An output cannot color itself, so the decision is its own output. The ramp is as fine as the palette is long, and the palette is yours. Five stops of a viridis ramp over RSI, a declared opacity, a two-literal blend driven by a 0/1 index, and a box fill in hex:

param("period", 14, { min: 2, max: 200 });
// A five-step color ladder over RSI: cool when oversold, hot when overbought.
output("rsi", line, lower, { width: 2, color_by: "heat", colors: ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], description: "RSI colored by its own level" });
output("heat", none, lower, { description: "0..4: which fifth of 0..100 the RSI sits in" });
// opacity(color, 40) as a declared opacity.
output("sma20", line, overlay, { color: "#2563eb", opacity: 0.4, width: 2, description: "A faded average" });
// blend(green, red, weight) per regime as two literals and a 0/1 index.
output("trend", line, overlay, { width: 2, color_by: "trend_up", colors: ["#dc2626", "#16a34a"], description: "The average, red falling, green rising" });
output("trend_up", none, overlay, { description: "1 while the average rises" });
// A box fill: a hex color with the transparency in the declared opacity.
const bandHi = output("band_hi", none, overlay);
const bandLo = output("band_lo", none, overlay);
box("band", { top: bandHi, bottom: bandLo, color: "#2563eb", opacity: 0.12, borderWidth: 0 });

let rsi = new Rsi(14);
let sma = new Sma(20);
let average: f64 = NaN;
let prevAverage: f64 = NaN;

function onStart(): void {
  rsi = new Rsi(i32(p_period()));
  sma = new Sma(20);
}

function onBar(): void {
  const close = bar.close();
  const strength = rsi.update(close);
  prevAverage = average;
  average = sma.update(close);
  if (isNaN(strength) || isNaN(average)) return;
  // Bucket 0..100 into five steps; 100 itself lands in the last bucket.
  let bucket = Math.floor(strength / 20.0);
  if (bucket > 4.0) bucket = 4.0;
  out_rsi(strength);
  out_heat(bucket);
  out_sma20(average);
  out_trend(average);
  out_trend_up(!isNaN(prevAverage) && average >= prevAverage ? 1.0 : 0.0);
  out_band_hi(average * 1.01);
  out_band_lo(average * 0.99);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The heat output draws nothing, but it is a value like any other: after a Run, type last 20 heat at the editor's Console prompt to read the bucket on the newest twenty bars.

The index need not be a floor. A condition that answers 0, 1 or 2 is the same ladder over a three-entry palette, and a name is accepted as an entry:

param("period", 14, { min: 2, max: 200, description: "RSI length" });
// colors is the palette; level picks the entry per bar: 0 red (overbought), 1 green (oversold), 2 blue (neutral).
output("rsi", line, lower, { width: 2, color_by: "level", colors: ["#FF0000", "green", "#0000FF"], description: "RSI with conditional colors" });
output("level", none, lower, { description: "0 above 70, 1 below 30, 2 in between" });

let rsi = new Rsi(14);

function onStart(): void {
  rsi = new Rsi(i32(p_period()));
}

function onBar(): void {
  const value = rsi.update(bar.close());
  if (isNaN(value)) return;
  out_rsi(value);
  out_level(value > 70.0 ? 0.0 : value < 30.0 ? 1.0 : 2.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The same pair works on a width_by output with a widths array for line width, on a render.legend entry, and on a shape output (Styling, Legend).

Tinting bars

The ladder also drives a background tint through render.bgcolor and a candle tint through render.barcolor (Plotting): a where gate says which bars get a tint, and color_by with colors picks it per bar. A non-finite index means no tint on that bar (the static color never substitutes), so a NaN bucket is a clean way to leave a bar alone.

param("period", 14, { min: 2, max: 100, description: "RSI Period" });
param("overbought", 70, { min: 50, max: 100, description: "Overbought Level" });
param("oversold", 30, { min: 0, max: 50, description: "Oversold Level" });
output("rsi", line, lower, { color: "#2962ff", width: 2, description: "Relative strength index" });
output("zone", none, lower, { description: "0 oversold, 1 overbought: the tint ladder index; NaN in between" });
output("extreme", none, lower, { description: "1 on bars in either zone: the tint gate" });
// A background tint per condition: one declaration, a gate, and a two-entry ladder.
render.bgcolor("zones", { where: "extreme", color_by: "zone", colors: ["rgba(76, 175, 80, 0.3)", "rgba(244, 67, 54, 0.3)"] });

let rsi = new Rsi(14);
let overbought: f64 = 70.0;
let oversold: f64 = 30.0;

function onStart(): void {
  rsi = new Rsi(i32(p_period()));
  overbought = p_overbought();
  oversold = p_oversold();
}

function onBar(): void {
  const value = rsi.update(bar.close());
  if (isNaN(value)) return;
  const hot = value > overbought;
  const cold = value < oversold;
  out_rsi(value);
  out_zone(cold ? 0.0 : hot ? 1.0 : NaN);
  out_extreme(hot || cold ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Five zones (extreme oversold, oversold, neutral, overbought, extreme overbought) are a five-entry palette and a bucket that lands in 0 .. 4; the module above keeps two for clarity.

What has no form

A declared color is never computed at run time. Compute the color you want ahead of time and write it as a literal; a blend that depends on a per-bar weight is a ladder over a few blended literals, with the weight bucketed into the index. The module has no color variable type: a declared color is a string literal read once when the sheet is derived, and the only setting that holds a color is a param.color. The one place a computed color lands is a drawing handle, through the kit below.

Colors computed at run time

The line, box, label and polyline handles from ./gen/draw are created and restyled by the module while it runs, and their color(...) and fill(...) setters take a packed i32 (Drawing objects). That is where ./sdk/color goes: a color computed from a value on this bar, applied to a handle on this bar. Its two scales, ColorScale and Thresholds, serve the other half: they compute the index a color_by output carries, so the color itself stays a literal and the decision stays a number.

Every export

ExportSignatureDefinition
inkink.RED, ink.ORANGE, ink.AMBER, ink.YELLOW, ink.LIME, ink.GREEN, ink.EMERALD, ink.TEAL, ink.CYAN, ink.SKY, ink.BLUE, ink.INDIGO, ink.VIOLET, ink.PURPLE, ink.PINK, ink.ROSE, ink.SLATE, ink.GRAY, ink.WHITE, ink.BLACK (i32)opaque packed colors, the palette below, packed exactly as rgba(...) from ./gen/draw packs
fromHexfromHex(hex: string): i32"#rrggbb" (opaque) or "#rrggbbaa", either case; any other text aborts with color: bad hex <x>
alphaalpha(color: i32, a: f64): i32the color with its alpha byte set from an opacity 0..1, RGB untouched
mixmix(a: i32, b: i32, t: f64): i32every channel, alpha included, t of the way from a to b, linear in sRGB
lightenlighten(color: i32, amount: f64): i32each RGB channel moved toward 255 by the fraction, alpha kept
darkendarken(color: i32, amount: f64): i32each RGB channel moved toward 0 by the fraction, alpha kept
red, green, bluered(color: i32): i32one channel of a packed color, 0..255 (Pine's color.r, color.g, color.b)
opacityopacity(color: i32): f64the alpha byte over 255, 0..1: what alpha takes, so alpha(c, opacity(c)) is c again
transparencytransparency(color: i32): f64Pine's color.t: (255 - alpha) * 100 / 255, 0 opaque to 100 invisible
ColorScalenew ColorScale(min: f64, max: f64, steps: i32), .index(v: f64): f64floor((v - min) / (max - min) * steps) clamped to 0..steps - 1; NaN in, NaN out
Thresholdsnew Thresholds(levels: StaticArray<f64>), .index(v: f64): f64the number of levels at or below v, 0..levels.length; NaN in, NaN out
themetheme.UP, theme.DOWN, theme.TEXT, theme.MUTED, theme.BG, theme.GRID, theme.ACCENT (i32)the seven theme tokens as packed values the chart resolves when it paints: the candle up and down colours, the axis text, that text at 55 percent, the background, the grid lines and the app's accent
isThemeTokenisThemeToken(c: i32): boolwhether a packed value is one of the seven tokens (at any alpha)
toPacked, fromPackedtoPacked(c: i32): f64, fromPacked(v: f64): i32a packed color as the f64 a color_packed_by output carries, and back; both keep a theme token a token

Construct the scales and parse hex literals in onStart(): the constructors allocate, and nothing else in the module allocates per bar.

Theme colours

A theme token is a colour the module names but never computes: the chart fills it in when it paints, from its own theme, and again when the theme switches, so a label inked theme.TEXT reads on a dark and a light chart alike. alpha(theme.UP, 0.2) is still a token, carrying the alpha with it, and opacity(c) and transparency(c) read that alpha back; mix, lighten, darken, red, green and blue have no concrete colour to work on and abort with color: <fn> takes a concrete colour; theme tokens resolve at paint time. A concrete colour never spells a token by accident: an alpha-0 result whose red byte is 0x7e and green byte 1..7 (an invisible colour before tokens existed) reads as 0, transparent black, from rgba(...), alpha, mix, lighten, darken, fromHex and fromPacked alike, so it stays invisible and never aborts the readers. A token goes anywhere a packed colour goes: a handle's color(...) or fill(...), and an output a drawn output names with color_packed_by (write toPacked(theme.UP) to it). The same seven words ride every declared colour as strings ("theme.up", Styling).

For a concrete colour the module can compute with, the chart hands over its own: chart.up_color(), chart.down_color() and chart.grid_color() declared at the top of the file (like chart.bg_color()) are read in onStart() through i32(p_chart_up_color()) and the two others as packed colours, 0 when no chart is there to answer (Chart context); mix(i32(p_chart_up_color()), ink.WHITE, 0.3) is a lighter version of the chart's own up colour. The chart's font needs no reader: font_family: "ui" on any text resolves to the chart's own typography.

The palette

ink names the 500 row of the Tailwind palette plus white and black, each opaque. The swatch column is the hex you would write as a declared literal for the same color.

NameSwatchNameSwatch
ink.RED#ef4444ink.INDIGO#6366f1
ink.ORANGE#f97316ink.VIOLET#8b5cf6
ink.AMBER#f59e0bink.PURPLE#a855f7
ink.YELLOW#eab308ink.PINK#ec4899
ink.LIME#84cc16ink.ROSE#f43f5e
ink.GREEN#22c55eink.SLATE#64748b
ink.EMERALD#10b981ink.GRAY#9ca3af
ink.TEAL#14b8a6ink.WHITE#ffffff
ink.CYAN#06b6d4ink.BLACK#000000
ink.SKY#0ea5e9ink.BLUE#3b82f6

A packed color is one i32: ((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | (a & 0xff), copied from rgba(...) in ./gen/draw bit for bit, so ink.RED == rgba(239, 68, 68, 255) and the two mix freely. Because red sits in the top byte, most inks are negative numbers when you print them: that is the packing, not a fault.

An index from a value

ColorScale slices min..max into steps equal bands and answers the band a value falls in, so a colors palette with steps entries on the drawn output gets one entry per band. Thresholds answers how many of its levels sit at or below the value, so a palette with one entry more than the level count gets one entry per zone. Both return an f64 that goes straight into a data-only output, and the drawn output names that output with color_by. The RSI ladder with the kit computing the index, and a zone number you can read after a Run with last 20 zone at the Console prompt:

param("period", 14, { min: 2, max: 200, description: "RSI period" });
// Five bands of 0..100, coolest at the bottom: heat picks the entry per bar.
output("rsi", line, lower, { width: 2, color_by: "heat", colors: ["#3b82f6", "#0ea5e9", "#64748b", "#f97316", "#ef4444"], description: "RSI colored by its level" });
output("heat", none, lower, { description: "0..4: the fifth of 0..100 the RSI sits in" });
output("zone", none, lower, { description: "0 below 30, 1 between, 2 at or above 70" });

let rsi = new Rsi(14);
let scale = new ColorScale(0.0, 100.0, 5);
let zones = new Thresholds(new StaticArray<f64>(0));

function onStart(): void {
  rsi = new Rsi(i32(p_period()));
  // Built once here: index() never allocates, and neither holds bar state.
  scale = new ColorScale(0.0, 100.0, 5);
  const levels = new StaticArray<f64>(2);
  levels[0] = 30.0;
  levels[1] = 70.0;
  zones = new Thresholds(levels);
}

function onBar(): void {
  const strength = rsi.update(bar.close());
  out_rsi(strength);
  out_heat(scale.index(strength));
  out_zone(zones.index(strength));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

An RSI of exactly 100 lands in the last band (the scale clamps, it never answers steps), and a value on a threshold counts that threshold (70 is zone 2). A NaN RSI gives a NaN index, and the drawn output falls back to its static color on that bar.

Colors on a handle

Handle setters take the packed color as it is computed, so here color can follow a value continuously instead of through a ladder. The window mean drawn as a line handle in ink.SKY, restyled to alpha(ink.SKY, 0.4) on the newest bar (the forming one), and the window's range as a box whose tint is mixed at run time between two hex literals parsed in onStart():

param("period", 20, { min: 2, max: 200, description: "Bars in the window" });
output("mean", line, overlay, { color: "#64748b", description: "The window mean" });
output("up_share", none, overlay, { description: "0..1: the share of up closes in the window, the box's mix weight" });
handles.line({ color: "#0ea5e9", width: 2 });
handles.box({ color: "#0ea5e9", opacity: 0.15, borderWidth: 1 });

const level = draw.line(0);
const span = draw.box(1);
let period: i32 = 20;
let sma = new Sma(20);
let ups = new Sma(20);
let highs = new Highest(20);
let lows = new Lowest(20);
let bull: i32 = 0;
let bear: i32 = 0;
let t: f64 = NaN;
let prevT: f64 = NaN;
let close: f64 = NaN;
let prevClose: f64 = NaN;

function onStart(): void {
  period = i32(p_period());
  sma = new Sma(period);
  ups = new Sma(period);
  highs = new Highest(period);
  lows = new Lowest(period);
  // Parse literals here: a bad string aborts with "color: bad hex <x>", and the message is readable from onStart().
  bull = fromHex("#16a34a");
  bear = fromHex("#dc2626");
}

function onBar(): void {
  prevT = t;
  t = bar.time();
  prevClose = close;
  close = bar.close();
  const mean = sma.update(close);
  const share = ups.update(!isNaN(prevClose) && close > prevClose ? 1.0 : 0.0);
  const hi = highs.update(bar.high());
  const lo = lows.update(bar.low());
  if (isNaN(mean) || isNaN(hi) || isNaN(prevT)) return;
  out_mean(mean);
  out_up_share(share);
  const start = t - (t - prevT) * f64(period - 1);
  // The mean across the window: sky, and on the newest bar the same sky at 40% opacity.
  level.set(start, mean, t, mean).color(bar.isLast() ? alpha(ink.SKY, 0.4) : ink.SKY);
  // The window's range, tinted by the share of up closes: a gradient computed per bar, not a ladder.
  const tone = mix(bear, bull, share);
  span.set(start, hi, t, lo).fill(alpha(lighten(tone, 0.2), 0.15)).color(darken(tone, 0.3));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

bar.isLast() answers 1 on the newest bar the chart holds, so the line is drawn faint while that bar is still forming and turns solid once the next bar opens. The box's fill and border are two derivations of one tone, so the pair always agree.

Read a color back

red, green and blue read one channel of a packed color, and opacity and transparency read its alpha. The chart's background arrives packed (chart.bg_color()), so a file can pick its ink from it: dark words on a light chart, light words on a dark one.

// A price tag on the newest bar whose words follow the chart's background: dark on a light chart, light on a dark one.
chart.bg_color();
output("close_line", line, overlay, { color: "#94a3b8", description: "The close" });
output("brightness", none, overlay, { description: "The background's brightness, 0 (black) to 255 (white); NaN when the chart did not say" });
output("bg_opacity", none, overlay, { description: "How solid the background is, 0 to 1" });
string("tag", { max_bytes: 32 });
handles.label({ text: "tag", size: 12 });

const tag = draw.label(0);
let words: i32 = 0;
let brightness: f64 = NaN;
let bgOpacity: f64 = NaN;

function onStart(): void {
  const bg = i32(p_chart_bg_color());
  // 0 means the chart did not fill the color; then the words stay light.
  if (bg != 0) {
    brightness = 0.299 * f64(red(bg)) + 0.587 * f64(green(bg)) + 0.114 * f64(blue(bg));
    bgOpacity = opacity(bg);
  }
  words = !isNaN(brightness) && brightness > 140.0 ? ink.BLACK : ink.WHITE;
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  out_close_line(close);
  out_brightness(brightness);
  out_bg_opacity(bgOpacity);
  if (!bar.isLast()) return;
  sb_clear();
  sb_text("last ");
  sb_auto(close);
  tag.set(bar.time(), close).text(str_tag_sb).color(words);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • Brightness from the channels. The three channels weighted 0.299, 0.587 and 0.114 give 0 for black and 255 for white; above about 140 the background reads as light. On a cream background (#f4f1ea) the tag is black.
  • 0 means unknown. Where the chart did not fill the background, p_chart_bg_color() reads 0 and the words stay white.
  • The alpha. opacity(bg) is 1 for a solid background; transparency(bg) is the same reading on Pine's 0 to 100 scale, where 0 is solid.
  • Only a handle takes it. The computed color lands on the label through .color(...); a declared color stays a literal (What has no form, above).

The rules, written out

  • Fractions. An opacity, a mix weight and a lighten or darken amount are clamped to 0..1; NaN reads as 0, so alpha(c, NaN) is invisible, mix(a, b, NaN) is a, and lighten(c, NaN) is c.
  • Rounding. A derived channel is floor(x + 0.5) held to 0..255: alpha(c, 0.5) sets the alpha byte to 128, mix(ink.RED, ink.BLUE, 0.5) is #95639d, lighten(ink.BLUE, 0.5) is #9dc1fb, darken(ink.BLUE, 0.5) is #1e417b.
  • Readers. red, green and blue are the packed bytes, exact; alpha(c, opacity(c)) rebuilds c bit for bit; transparency(c) is (255 - alpha) * 100 / 255, so a color at alpha 128 reads 49.80..., and alpha(c, 1.0 - transparency(c) / 100.0) is c again.
  • fromHex. Seven characters (#rrggbb, alpha 255) or nine (#rrggbbaa), hex digits in either case. Anything else aborts the run with color: bad hex <x> and the string you passed. Call it in onStart(): an abort raised there shows its message in the Console, while an abort at module start (a top-level const c = fromHex(...)) reaches you as a bare failure with the text lost.
  • ColorScale. v at max lands in the last band. steps below 1 reads as 1; max below min runs the scale backwards; max equal to min puts every value above it in the last band and the rest in band 0. An infinite value clamps to the end it points at.
  • Thresholds. A value exactly on a level counts it. The constructor copies the array, order does not matter, and a NaN level never counts.
  • State and allocation. Neither scale holds bar state, so a reset has nothing to restore and a reset bar answers the same as any other. index(), alpha, mix, lighten, darken, the five readers and a successful fromHex never allocate, so the per-bar path stays allocation-free under the module's runtime, which never frees.

From Pine

Pinewrun
color.new(c, transp)on a handle, alpha(c, (100 - transp) / 100): Pine counts transparency 0..100, alpha takes opacity 0..1; on a declaration, the opacity option
color.from_gradient(value, bottom, top, c1, c2)on a declaration, new ColorScale(bottom, top, n).index(value) into a color_by output over an n-entry colors palette from c1 to c2; on a handle, mix(c1, c2, t)
color.rgb(r, g, b)rgba(r, g, b, 255) from ./gen/draw (or rgb(r, g, b) there)
color.r(c), color.g(c), color.b(c)red(c), green(c), blue(c) on a packed handle color (a declared color is a literal the module never reads)
color.t(c)transparency(c), 0..100 as Pine counts it; opacity(c) is the same channel on the 0..1 scale alpha takes