Styling

How outputs draw. The rule that keeps packages portable: outputs are numbers, some numbers are decisions, and the sheet maps decisions to looks. Styling is…

How outputs draw. The rule that keeps packages portable: outputs are numbers, some numbers are decisions, and the sheet maps decisions to looks. Styling is declared in the sheet (or in the output(...) options the sheet is derived from), never computed in the module, so charts restyle without recompute and every runtime renders the same declaration. kScript (legacy) styled per call, with size tiers, tooltips, z-order, palettes, and per-element gradients; this page is the Indicator vocabulary for each, and which of them have no form yet.

Placement, per output

  • plot: line, bar, area, histogram, candle, shape, scatter, or "" (none in code) for a data-only output: computed, never drawn, the building block for gates and palettes below.
  • panel: overlay (the price chart) or lower (its own pane). Put small-magnitude series (probabilities, oscillators) in lower; an odds-scale line on the price axis hugs the axis floor.
  • unit: axis formatting (price, %, else an abbreviated label). This is kScript's define(format=...), per output instead of per script.

Static style, per output

color (or a colors palette), width, opacity (0..1), line_style (solid, dashed, dotted), description (the legend text).

Decisions to looks

  • Per-bar coloring: emit the decision as an ordinary data-only output ("plot": ""), then on the styled output set color_by: "<that output>" plus a colors palette (at least 2 entries); each bar's floored value indexes the palette, and a missing or out-of-range index falls back to entry 0. That floor + entry-0 rule is the chart engine's color_by rule, and the host-implemented ladders below adopt it unchanged. An output cannot color itself. This is the port of colorIndex: the index expression becomes an output.
  • Per-bar width: the color ladder's sibling. On the styled output set width_by: "<data-only output>" plus a widths ladder (1..10 entries, each 0.5..20); each bar's value indexes the ladder (floor; a finite out-of-range index clamps to entry 0; a non-finite value, NaN or infinite, keeps the static width). Both halves or neither, each refused by name without the other. An output cannot set its own width.
  • Gated markers: a plot: "shape" output with shape_where: "<gate output>" renders only where the gate is nonzero. An output cannot gate itself.
  • Shaded bands: top-level fills: [{ "between": ["upper", "lower"], "color": "#94a3b8", "opacity": 0.15 }]; both sides must be DIFFERENT rendered outputs. Sheet-only today: there is no declaration form, and the chart does not draw fills yet (Drawing primitives has the per-bar box that does).
  • Banded ranges: top-level ranges: [{ "upper": "band_hi", "lower": "band_lo", "color": "#94a3b8", "edge_width": 2, "edge_line_style": "dotted", "smooth": true }], or range(upper, lower, options) in code; both sides must be DIFFERENT rendered outputs. Instead of the static color, a colors palette plus color_by (a data-only output) tints the band per bar (floor; finite out-of-range clamps to entry 0; a non-finite value = no tint that bar); color_by requires colors. colors WITHOUT color_by is the band's sign palette: entry 0 tints spans where upper plots above lower, entry 1 where it plots below; a single entry tints everywhere, and entries past index 1 are unread. edge_width is an integer 1..10, edge_line_style one of solid/dashed/dotted, and smooth a boolean smoothing hint. Declare as many ranges as needed; the same pair may repeat with different options. Ranges ride the sheet for hosts that honor them; the chart lane does not draw them today.
  • User style knobs: a param with style: { "output": "<name>", "property": "color" | "width" | "opacity" | "lineStyle" } never reaches the module (no accessor, its slot stays zero-filled), shows in the chart settings dialog, and redraws without recompute. color and lineStyle knobs take string defaults ("#22c55e"); width and opacity take numeric defaults. The bound output must be a rendered one. This is the port of kScript's style-only inputs (type="color", a width slider), and it is sheet-only: no declaration form yet.

Every reference is cross-validated: color_by, width_by, shape_where, style.output, and each fills and ranges side must name a declared (and, where required, rendered) output, none of the three may name its own output (the self-reference is refused by name), and a publish names the broken field.

Styling dresses per-output SERIES. When the package should also put text, tables, shaped marks, or free-standing objects (lines, boxes, polylines, labels) on the chart, that is the renderer and drawing vocabulary: top-level renderers and drawings arrays over named outputs and string slots, in Script definition. Shapes that repeat on EVERY bar (a zone behind the last few bars, a projection ray from each bar) are the boxes and segments below.

Text size, tooltips, alignment, z-order, palettes

The kScript styling vocabulary, item by item:

kScriptIndicator
size tiers ("tiny" 8 to "huge" 24) on textsize, an integer pixel count 6..64, on render.text and render.label
tooltip on plots, cells, drawingsnot in Indicators yet; description is the legend's long text
horizontal and vertical alignment wordsnot in Indicators yet on text; the nine anchors (top_left ... bottom_right) are a table's position
zOrder / set_zordernot in Indicators yet; declaration order is draw order
palette("viridis"), a fresh mutable arraya colors literal array on the output, box, range, or bgcolor; bucket the driving value into its index (Color functions)
per-element glow, opacity, gradientsopacity per output or box; a color_by ladder per bar; no glow, no continuous gradient

Value-driven styling, kScript's pattern of computing the style from the data per element, survives as the ladder: normalize the driving value, floor it into a bucket, write the bucket to a none output, and index a palette with it. The palette is finite, so the gradient is stepped, and the decision is a metric anyone can read.

Boxes and segments

Per-bar shapes over outputs the package already computes, declared as the top-level boxes and segments arrays (at most 16 entries each). They are sheet-only and ABI-neutral like fills and ranges: no new wire data (the referenced outputs already stream), legal on both runtime contracts, rendered by chart hosts and ignored by scalar surfaces. Every referenced output must be declared and MAY be data-only (plot: ""), which is the usual shape: compute the coordinates, never draw them as lines.

On every bar row i:

  • nothing is drawn when any referenced output is non-finite on that row, or when the optional when gate output is 0 or non-finite;
  • a box spans bars i + x_from .. i + x_to inclusive and prices min(top, bottom) .. max(top, bottom);
  • a segment is the straight line from (i + x_from, y_from) to (i + x_to, y_to).

x_from and x_to are bar OFFSETS relative to the current bar (negative = past, positive = ahead; default 0): an integer in -500..500, or the name of an output whose per-bar value, truncated to an integer, is the offset. Offsets past the loaded range clamp to its edge. The forming (last) bar re-evaluates on every tick: the host replaces that bar's shapes, never stacks them. When color is absent the shape takes the referenced output's color (top for a box, y_from for a segment), and when panel is absent it takes that output's panel. Names follow the output grammar and share one namespace with outputs, renderers, and drawings.

Box fields: name, top, bottom, x_from, x_to, when, panel (overlay or lower), color, border_color, opacity (0..1, default 0.2), border_width (0..10, default 1). Segment fields: name, y_from, y_to, x_from, x_to, when, panel, color, width (0.5..20, default 1), line_style (solid, dashed, or dotted).

A box on every bar: the last five bars' high-low range, tinted only while the range is expanding. Three data-only outputs carry the coordinates and the gate; the sheet turns them into a zone:

{
  "id": "range-box",
  "abi_version": "wrun-1",
  "warmup_bars": 5,
  "params": [],
  "inputSources": {
    "high": { "source": "ohlcv", "field": "high" },
    "low": { "source": "ohlcv", "field": "low" }
  },
  "inputs": [
    { "index": 0, "name": "high" },
    { "index": 1, "name": "low" }
  ],
  "outputs": [
    { "index": 0, "name": "range_hi", "plot": "" },
    { "index": 1, "name": "range_lo", "plot": "" },
    { "index": 2, "name": "expanding", "plot": "" }
  ],
  "boxes": [
    { "name": "range_zone", "top": "range_hi", "bottom": "range_lo", "x_from": -4, "x_to": 0, "when": "expanding", "panel": "overlay", "color": "#f59e0b", "opacity": 0.15, "border_color": "#f59e0b", "border_width": 1 }
  ]
}

A segment on every bar: a dotted projection from each bar's anchor line to a target level, its length set per bar by a data-only output (reach, an output-valued offset):

{
  "id": "projection-segment",
  "abi_version": "wrun-1",
  "warmup_bars": 1,
  "params": [],
  "inputSources": { "close": { "source": "ohlcv", "field": "close" } },
  "inputs": [{ "index": 0, "name": "close" }],
  "outputs": [
    { "index": 0, "name": "anchor", "plot": "line", "panel": "overlay", "color": "#38bdf8" },
    { "index": 1, "name": "target", "plot": "" },
    { "index": 2, "name": "reach", "plot": "" }
  ],
  "segments": [
    { "name": "projection", "y_from": "anchor", "y_to": "target", "x_from": 0, "x_to": "reach", "color": "#38bdf8", "width": 1, "line_style": "dotted" }
  ]
}

Code first, both declare from output HANDLES: output(...) returns one, so bind it to a const and pass it. from / to take integer literals or handles, and the option names are camelCase (borderColor, borderWidth, yFrom, yTo, lineStyle), landing in the sheet as the snake_case fields above. A complete package: a moving average with a one-percent band drawn as a zone behind the last five bars while the average rises, plus a dotted ray from each bar's average to the band top three bars ahead:

import { box, input, line, none, ohlcv, output, overlay, param, segment } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_band_hi, out_band_lo, out_mid, out_rising } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Sma } from "./sdk/ta";

param("period", 20, { min: 1, max: 200 });
input("close", ohlcv.close);
const mid = output("mid", line, overlay, { color: "#38bdf8" });
const bandHi = output("band_hi", none);
const bandLo = output("band_lo", none);
const rising = output("rising", none);
box("band_zone", { top: bandHi, bottom: bandLo, from: -4, to: 0, when: rising, opacity: 0.15, borderWidth: 0 });
segment("mid_ray", { yFrom: mid, yTo: bandHi, from: 0, to: 3, lineStyle: "dotted" });

let sma = new Sma(20);
let value: f64 = NaN;
let prev: f64 = NaN;

export function init(): void {
  sma = new Sma(i32(p_period()));
}

export function state(): i32 {
  prev = value;
  value = sma.update(in_close());
  return isNaN(value) ? 0 : 1;
}

export function finalize(): void {
  out_mid(value);
  out_band_hi(value * 1.01);
  out_band_lo(value * 0.99);
  out_rising(!isNaN(prev) && value > prev ? 1.0 : 0.0);
  emitRow();
}

export function reset(): void {
  sma.reset();
  value = NaN;
  prev = NaN;
}

A worked example

Bollinger-style bands with a regime-colored (and regime-widened) midline, a shaded band, a banded range, and a width knob:

{
  "id": "trend-paint",
  "abi_version": "wrun-1",
  "warmup_bars": 1,
  "params": [
    { "name": "period", "default": 20, "min": 2, "max": 400 },
    { "name": "band_width", "default": 2, "min": 0.5, "max": 4, "description": "Stdev multiples" },
    { "name": "line_width", "default": 3, "style": { "output": "mid", "property": "width" } }
  ],
  "inputSources": { "close": { "source": "ohlcv", "field": "close" } },
  "inputs": [{ "index": 0, "name": "close" }],
  "outputs": [
    { "index": 0, "name": "mid", "plot": "line", "panel": "overlay", "color_by": "regime", "colors": ["#ef4444", "#22c55e"], "width_by": "regime", "widths": [1, 3] },
    { "index": 1, "name": "band_hi", "plot": "line", "panel": "overlay", "opacity": 0.6 },
    { "index": 2, "name": "band_lo", "plot": "line", "panel": "overlay", "opacity": 0.6 },
    { "index": 3, "name": "regime", "plot": "" }
  ],
  "fills": [{ "between": ["band_hi", "band_lo"], "color": "#94a3b8", "opacity": 0.15 }],
  "ranges": [{ "upper": "band_hi", "lower": "band_lo", "color": "#94a3b8", "edge_width": 1, "edge_line_style": "dotted" }]
}

The module computes four numbers per bar and never mentions a color:

import { in_close } from "./gen/inputs";
import { emitRow, out_band_hi, out_band_lo, out_mid, out_regime } from "./gen/outputs";
import { p_band_width, p_period } from "./gen/params";
import { Sma, Stdev } from "./sdk/ta";

let sma = new Sma(20);
let stdev = new Stdev(20);
let mult: f64 = 2.0;
let mid: f64 = NaN;
let sd: f64 = NaN;
let regime: f64 = 0.0;
let close: f64 = NaN;

export function init(): void {
  const period = i32(p_period());
  sma = new Sma(period);
  stdev = new Stdev(period);
  mult = p_band_width();
}

export function state(): i32 {
  close = in_close();
  mid = sma.update(close);
  sd = stdev.update(close);
  regime = close > mid ? 1.0 : 0.0;
  return isNaN(mid) || isNaN(sd) ? 0 : 1;
}

export function finalize(): void {
  out_mid(mid);
  out_band_hi(mid + mult * sd);
  out_band_lo(mid - mult * sd);
  out_regime(regime);
  emitRow();
}

export function reset(): void {
  sma.reset();
  stdev.reset();
  mid = NaN;
  sd = NaN;
  regime = 0.0;
  close = NaN;
}

Note the style knob (line_width) has no p_ accessor and the module never reads it: the chart applies it to the mid line directly. The regime output computes on every bar but never draws; it exists so the palette (and the widths ladder) on mid has a decision to index.

In code-first workspaces the output(...) options carry color, colors, width, opacity, line_style, color_by, shape_where, width_by, and widths, ranges declare as range(upper, lower, options?), boxes and segments as box(name, options) / segment(name, options) over output handles, and declaration order sets the indexes (Script definition). Top-level fills and style-knob params have no declaration form yet, and a generated sheet refuses hand edits, so a package that needs either stays metadata-first for now.

Where the rules live

Every styling reference is validated by the same schema at author, build, install, and publish, and every refusal names the field (outputs.0.color_by, boxes.0.color, ranges.0.edge_width); the messages are in Common errors. The chart engine's rendering of a declaration is the same on every host that draws it, because the declaration, not the module, is what it reads.