# Chart Rules

Handwritten helpers for authoring the _behaviour_ of an AI-generated dashboard
chart in a form that can be read back and edited.

A dashboard chart is drawn by a visualization function the agent writes. Some of
what that function decides — which colour a bar takes — is worked out fresh each
render from the data. Written as ordinary code, that decision vanishes into the
output: the chart shows _what_ colour it drew, never _why_. This library lets the
agent state the decision as a small rule instead, so the settings panel can show
it, explain it, and let a user change it.

This is not a charting library. It does not render, own an ECharts option, fetch
data, or touch the DOM. It produces and interprets plain data.

## Scope (v1: conditional colour)

v1 is deliberately narrow — the **colour channel only**: colour a series or datum
by comparing its value against a constant.

- **`value`** — this data point's plotted numeric value (a bar's height, a
  point's y, a slice's amount). It is what the rule compares.
- **Comparisons** — `equals`, `isNot`, `lessThan`, `greaterThan`, `lessOrEqual`, `greaterOrEqual`.
- **`when(condition, ifTrue, ifFalse?)`** — a conditional colour. Both results
  are colour strings; `ifFalse` defaults to `null` (keep the chart default).
- **`color(rule)`** — target the colour channel. Wrapping `when(...)` in `color(...)`
  is what says "this rule sets the colour".
- **`literal`** — bare constants (`100`, `'#E24B4A'`) are wrapped automatically.

```ts
import { color, when, lessThan, greaterThan, value } from '@rippling/rippling-sdk/lib/charts';

// red when this point's value is below 100, blue otherwise
const barColour = color(when(lessThan(value, 100), '#E24B4A', '#378ADD'));

// highlight only the points over target; leave the rest their default colour
const highlight = color(when(greaterThan(value, 1000), '#E24B4A'));
```

## Intended flow

```text
Agent writes a rule with these builders (the only consumer that imports the SDK)
  -> color(when(lessThan(value, 100), '#E24B4A', '#378ADD'))  returns a {$rule} placeholder
  -> the placeholder is emitted into the chart option, like the {$fn} placeholders
     a chart already emits, and crosses to the app as plain JSON
  -> renderer resolves {$rule} against the point's value -> a colour, drawn
  -> settings panel reads the same flat {$rule} value -> controls the user can edit
```

```json
{
  "channel": "color",
  "$rule": {
    "kind": "ifElse",
    "cond": { "kind": "lessThan", "left": { "kind": "value" }, "right": { "kind": "literal", "value": 100 } },
    "then": { "kind": "literal", "value": "#E24B4A" },
    "else": { "kind": "literal", "value": "#378ADD" }
  }
}
```

`channel: 'color'` says what the rule targets, so a reader sees it without
inferring from where it sits. `evaluate(rule, { value })` resolves it to a colour
for the renderer; `describe(rule)` turns it into the controls the settings panel
shows. Both accept the placeholder or a bare node. A node can only ever be one
of a fixed set of kinds. `chartRuleSchema` is the executable Valibot schema for
that closed set, and `parseChartRule(unknown)` is the SDK-owned runtime boundary
for persisted or agent-authored JSON.

## How it is consumed

The rule crosses as data — a `{$rule}` placeholder alongside the `{$fn}` ones.
Consumers have two integration choices:

1. **Shared (preferred)** — import `evaluate`, `describe`, and
   `parseChartRule` from the SDK. Parse once when the chart option is hydrated,
   then pass that parsed value to `evaluate` for every datum. The parsed value is
   an immutable validated clone, so repeated evaluation does not re-run schema
   validation.
2. **Serialized contract** — another language/runtime may interpret the JSON
   independently. It must maintain parity tests against SDK builder output and
   fail closed when the package-level `capabilities.json` contract version is
   unsupported or a serialized rule contains an unknown node kind. Individual
   serialized rules do not carry a version field.

Valibot is a production dependency because validation executes in shipped SDK
code. Node and Deno npm consumers resolve it transitively from the package; it
is not duplicated in the agent reference or capability manifest.

## Where it is going

The vocabulary starts small on purpose. The v1 schema stays flat; features that
require nesting or a different expression shape must use a new contract version
rather than silently widening v1. A compatible channel or leaf addition still
requires one builder, evaluator case, description case, and schema entry.
Potential future versions may add:

- **More channels** — opacity, label visibility (`show`), and the like, added
  alongside `channel: 'color'`.
- **Collection refs + aggregates** — `rows`, `series`, and `count`/`sum`/`avg`/`min`/`max`,
  so a rule can compare a point against the series (e.g. the max bar, or below average).
- **Logic** — `and`, `or`, `not` to compose several conditions.
- **More refs** — `index`, `name`, `field('column')`.
- **`percentile`, arithmetic** (`plus`, `minus`, `mod`), **`between`, `isIn`**.
- **Shaping** — `filter`, `sortBy`, `limit`, `groupSmallest` (change the data before it is drawn).

## What this library does not do

- It does not render or hold an ECharts option — it returns values and controls.
- It has no I/O. A rule cannot reach the network, the DOM, or the clock, because
  there is no node for it. Evaluating a rule reads only the context it is given.

## Safety

A rule is data, not code: its vocabulary has no callbacks, loops, or side
effects. The v1 schema is non-recursive, so rule input cannot create recursive
validation or evaluation work. Runtime parsing also rejects inherited rule
fields before Valibot reads them, preventing prototype data from supplying
operands. Malformed rules throw a `ChartRuleError`; callers can use that one
base class for every rule-specific failure.
