---
title: "Colors"
description: "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…"
order: 52
section: "functions"
---

<!-- source: docs/indicators/functions/colors-kit.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Colors

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

| Place | What it is | Who 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` output | a number per bar, floored into the `colors` palette | the 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 want | On a declaration | On a handle |
| --- | --- | --- |
| an RGB color | a literal: `"#2563eb"`, `"rgb(37, 99, 235)"`, `"hsl(221, 83%, 53%)"` | `ink.SKY`, `fromHex("#0ea5e9")`, or `rgba(14, 165, 233, 255)` from `./gen/draw` |
| transparency | the `opacity` option (0..1), or alpha inside the color (`"#2563eb66"`) | `alpha(c, 0.4)` |
| a lighter, darker or blended color | write the result as a literal | `lighten`, `darken`, `mix` |
| a gradient driven by a value | a `colors` palette plus `color_by` over a bucket index: stepped, as fine as the palette is long | `mix(a, b, t)` per bar: continuous |
| a named palette | a `colors` literal array; there are no named palettes | `ink.*`, twenty named packed colors |
| a color the user picks | `param.color`, bound by `"@name"` ([The Style page](../settings/style-page.md)) | `p_<name>()` reads it as one packed number ([Setting kinds](../settings/kinds.md)) |

## Color forms on declarations

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

| Form | Example | Accepted 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](#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](#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.

| Name | Hex | Name | Hex | Name | Hex |
| --- | --- | --- | --- | --- | --- |
| `red` | `#FF0000` | `green` | `#008000` | `blue` | `#0000FF` |
| `orange` | `#FFA500` | `lime` | `#00FF00` | `navy` | `#000080` |
| `yellow` | `#FFFF00` | `olive` | `#808000` | `teal` | `#008080` |
| `maroon` | `#800000` | `purple` | `#800080` | `aqua` | `#00FFFF` |
| `fuchsia` | `#FF00FF` | `gray` | `#808080` | `silver` | `#C0C0C0` |
| `black` | `#000000` | `white` | `#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 bar | The output draws |
| --- | --- |
| finite, inside the palette | that entry (floored) |
| finite, outside the palette | entry 0 |
| `NaN` or infinite | its 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:

```typescript sample=fn-color-ladders
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);
}
```

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:

```typescript sample=cc-colors-palette
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);
}
```

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](../presentation/styling.md), [Legend](../presentation/legend.md)).

### Tinting bars

The ladder also drives a background tint through `render.bgcolor` and a
candle tint through `render.barcolor` ([Plotting](../presentation/plotting.md)):
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.

```typescript sample=fn-bgcolor-heat
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);
}
```

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](../presentation/drawing-objects.md)). 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

| Export | Signature | Definition |
| --- | --- | --- |
| `ink` | `ink.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 |
| `fromHex` | `fromHex(hex: string): i32` | `"#rrggbb"` (opaque) or `"#rrggbbaa"`, either case; any other text aborts with `color: bad hex <x>` |
| `alpha` | `alpha(color: i32, a: f64): i32` | the color with its alpha byte set from an opacity `0..1`, RGB untouched |
| `mix` | `mix(a: i32, b: i32, t: f64): i32` | every channel, alpha included, `t` of the way from `a` to `b`, linear in sRGB |
| `lighten` | `lighten(color: i32, amount: f64): i32` | each RGB channel moved toward 255 by the fraction, alpha kept |
| `darken` | `darken(color: i32, amount: f64): i32` | each RGB channel moved toward 0 by the fraction, alpha kept |
| `red`, `green`, `blue` | `red(color: i32): i32` | one channel of a packed color, `0..255` (Pine's `color.r`, `color.g`, `color.b`) |
| `opacity` | `opacity(color: i32): f64` | the alpha byte over `255`, `0..1`: what `alpha` takes, so `alpha(c, opacity(c))` is `c` again |
| `transparency` | `transparency(color: i32): f64` | Pine's `color.t`: `(255 - alpha) * 100 / 255`, `0` opaque to `100` invisible |
| `ColorScale` | `new ColorScale(min: f64, max: f64, steps: i32)`, `.index(v: f64): f64` | `floor((v - min) / (max - min) * steps)` clamped to `0..steps - 1`; `NaN` in, `NaN` out |
| `Thresholds` | `new Thresholds(levels: StaticArray<f64>)`, `.index(v: f64): f64` | the number of levels at or below `v`, `0..levels.length`; `NaN` in, `NaN` out |
| `theme` | `theme.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 |
| `isThemeToken` | `isThemeToken(c: i32): bool` | whether a packed value is one of the seven tokens (at any alpha) |
| `toPacked`, `fromPacked` | `toPacked(c: i32): f64`, `fromPacked(v: f64): i32` | a 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](../presentation/styling.md#theme-colours)).

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](../settings/sessions-and-units.md#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.

| Name | Swatch | Name | Swatch |
| --- | --- | --- | --- |
| `ink.RED` | `#ef4444` | `ink.INDIGO` | `#6366f1` |
| `ink.ORANGE` | `#f97316` | `ink.VIOLET` | `#8b5cf6` |
| `ink.AMBER` | `#f59e0b` | `ink.PURPLE` | `#a855f7` |
| `ink.YELLOW` | `#eab308` | `ink.PINK` | `#ec4899` |
| `ink.LIME` | `#84cc16` | `ink.ROSE` | `#f43f5e` |
| `ink.GREEN` | `#22c55e` | `ink.SLATE` | `#64748b` |
| `ink.EMERALD` | `#10b981` | `ink.GRAY` | `#9ca3af` |
| `ink.TEAL` | `#14b8a6` | `ink.WHITE` | `#ffffff` |
| `ink.CYAN` | `#06b6d4` | `ink.BLACK` | `#000000` |
| `ink.SKY` | `#0ea5e9` | `ink.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:

```typescript sample=fn-colors-kit-ladder
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));
}
```

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()`:

```typescript sample=fn-colors-kit-handles
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));
}
```

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

```typescript sample=fn-colors-kit-channels
// 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);
}
```

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

| Pine | wrun |
| --- | --- |
| `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 |
