Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions .agents/skills/adding-new-islands/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
name: adding-new-islands
description: >-
How to add a new built-in island to the OpenIslands monorepo end to end — first the
interface/API design discipline and the .describe() copy conventions (the island is authored
by an agent reading the schema, so the interface IS the product), then the exact file-by-file
wiring: the schema registration points, the compiler binding-check, the runtime renderer +
registry, the CLI skeleton, tests, and which docs are generated vs hand-edited. Use when adding,
designing, reviewing, or naming a new island/chart/visual-block type, or when answering "how do
I add an island to OpenIslands". The canonical worked example is the `divergence.bars` island.
---

# Adding a new island

An island is a stable, code-backed visual block with a typed config. An **agent** authors the
manifest by reading the island's schema and picking + configuring instances — it never writes
rendering code. So the schema and its `.describe()` copy ARE the product surface. Design the
interface first; the wiring is mechanical once the shape is right.

Before starting, read `CONTRIBUTING.md` ("Adding an island") and skim `packages/schema/src/index.ts`
to match the conventions of the nearest existing island. **Always copy a sibling**, not these
snippets — the real code is the source of truth.

## Part 1 — Design the interface (do this before any code)

1. **Name by intent, not by primitive.** Convention is `<intent>.<form>` (`category.bar`,
`waterfall.bars`, `correlation.scatter`). The intent word must tell the agent *when to pick this
island* and disambiguate it from neighbors — not describe the shape. Sanity-check the word for
baggage: `divergence.bars` was chosen over `deviation.bars` because "deviation" reads as
*standard deviation / error bars* in a data context.

2. **Happy path = one line.** Required fields are only those without which nothing renders
(`dataset` + the bindings). Everything else is optional with a sensible default. An agent should
get a working island from `{ type, dataset, <bindings> }` and refine from there.

3. **Make contradictory configs unrepresentable, not validated.** If two knobs can conflict,
collapse them into one. (For `divergence.bars` a separate `colors` knob was dropped because a
two-element `buckets` array already expresses a custom two-tone — one path, no XOR to police.)

4. **Declarative only — no computation in config.** Data shaping lives in SQL, never in the island.
(`divergence.bars` has no `baseline` field: diverge-from-target is a `value - target` subtraction
the author writes in SQL.) If you're tempted to add an `operation`/`formula`/`compute` field,
stop — that field belongs in the data layer.

5. **No "just in case" knobs.** Every optional field is more surface the agent must learn and more
error cases. Ship the minimal set; add fields later, backward-compatibly, when a real use case
appears.

6. **Bind only to fields that exist.** Every prop that names a dataset column is checked by the
compiler (Part 2, step 2) so `validate` fails loudly and names the island. That check is the
safety net — keep it honest, never route around it.

## Part 2 — Write the `.describe()` copy (the agent reads only this)

- **Type description:** name the intent, give 2–4 concrete use cases, and *explicitly disambiguate
from the nearest neighbors*. The pattern that works: "…Pick this over `category.bar` when values
are signed and direction is the message, and over `waterfall.bars` when each bar stands alone
rather than accumulating." Without the "pick this over X when…" clause the agent guesses.
- **Field description:** state the type, the semantics, and *what omitting it does* — "omit for a
default green-positive / red-negative two-tone", "rows with a null value are skipped". State the
default in the copy; don't make the agent infer it.
- Write for an LLM that is choosing and configuring, not for a human reading API docs.

## Part 3 — The wiring (every place to change)

The **schema is the keystone**: define it once, and the CLI, runtime, and compiler follow through
discriminated unions and `Record<IslandType, …>` maps — which fail to compile if you miss a spot.
That compile error is your checklist; `pnpm typecheck` enforces it.

### 1. `packages/schema/src/index.ts` — define + SEVEN registrations
- Define the Zod object next to its closest sibling (a chart → near `WaterfallBars`/`CategoryBar`),
with `...baseFields` and a `.describe()` per Part 2; `export type X = z.infer<typeof X>`.
- Register the type in all six of these (each is keyed by `IslandType`, so a miss is a TS error):
`BUILTIN_ISLAND_SCHEMAS`, the `BuiltinIsland` union, the `DrilldownIsland` union *(only if it may
be embedded in a row drilldown — charts yes; full-page/editor islands no)*, and the three span
maps `ISLAND_MIN_SPAN` / `ISLAND_MAX_SPAN` / `ISLAND_DEFAULT_SPAN` (charts are typically 4 / 12 / 6).

### 2. `packages/compiler/src/index.ts` — `islandRequirements()`
Add a `case "<type>":` that `add(...)`s every prop naming a dataset column. Literal config (color
thresholds, labels) adds nothing. This is what makes `validate` check bindings and name the island.

### 3. `packages/runtime/src/islands/<Name>.tsx` + `registry.tsx`
- New renderer mirroring a sibling: a **pure** data-shaping function (exported, so tests assert it
without ECharts/DOM), a `buildOptions()` for the Kumo `<Chart>`, and the component. Reuse the
shared helpers (`format.js`, `chart.js`, `SeriesLegend`, `isDateCategories`) — don't reinvent.
- Register in `registry.tsx`'s `REGISTRY` via `lazyIsland(() => import("./<Name>.js"), "<Name>")`
(renderers are lazy-loaded — every chart pulls in echarts; the registry keeps it code-split).
If the island binds no dataset, also update `islandNeedsData()`.

### 4. `packages/cli/src/scaffold.ts` — `islandSkeleton()`
Add a starter config (`dataset`/bindings = `"TODO"`). `scaffold.test.ts` asserts every built-in type
has a skeleton that passes validation, so a miss fails tests.

### 5. Tests
- `packages/runtime/test/<name>.test.ts`: assert the pure data-shaping function (boundaries, default
behavior, null/missing handling, empty input). Mirror `waterfallBars.test.ts`.
- `packages/schema/test/index.test.ts`: add a parse case (minimal config + one exercising options).

### 6. Docs — hand-edited vs generated (DO NOT mix these up)
- **Hand-edit:** `docs/data-app-model.md` catalog row; `apps/docs/content/docs/islands/overview.mdx`
row; `apps/docs/content/docs/islands/charts.mdx` (a new `## <type>` section with a `<LiveIsland>`
example — copy the `waterfall.bars` block).
- **Generated — never hand-edit:** `apps/docs/content/docs/reference/manifest.mdx` is built from the
schema. Run `pnpm --filter @openislands/docs gen:reference` (or `pnpm gen:reference` in `apps/docs`)
to regenerate it; the island's section + field table come straight from your `.describe()` copy.
- **Skill source:** add the island to the catalog in `skills/openislands/SKILL.md` (the single
source), then `pnpm sync:skill` to propagate to `templates/*/.agents`, `.mcp.json`, `AGENTS.md`.
Never hand-edit the synced copies.

## Part 4 — Gate
```
pnpm build && pnpm typecheck && pnpm test && pnpm lint && pnpm validate:templates
```
`typecheck` catches a missed registration; `test` catches a missing skeleton; `validate:templates`
re-syncs the skill and checks every template manifest's bindings against live data. (Ignore oxfmt
`format:check` — it flags pre-existing files and is not a gate.)

## Worked example — `divergence.bars`
The diverging bar chart added via this exact process. Read these files as the canonical template:
- `packages/schema/src/index.ts` — search `DivergenceBars` (schema + the 7 registrations)
- `packages/compiler/src/index.ts` — search `case "divergence.bars"`
- `packages/runtime/src/islands/DivergenceBars.tsx` — pure `bucketColor`/`buildDivergenceBars` + renderer
- `packages/runtime/test/divergenceBars.test.ts` — the pure-function test
- `packages/cli/src/scaffold.ts` — its `islandSkeleton` entry

Design calls it embodies, as precedent: dropped a `baseline` field (diverge-from-target = SQL),
dropped a `colors` knob (a 2-bucket array covers it), chose half-open `[gte, lt)` buckets (no
boundary ambiguity), skipped rows with a null value (0 deviation ≠ no data).
1 change: 1 addition & 0 deletions .claude/skills/adding-new-islands
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ apps/docs/ # the docs site (Fumadocs on TanStack Start, static → Clo
- **Prefer built-in islands.** Unknown types render a placeholder until a renderer exists under
`components/custom/`.
- **Tests live in `test/`**, not `src/` (so the bundler doesn't ship them).
- **Templates stay minimal** — demo new islands/features in `apps/examples/`, not `templates/`.
- **The agent skill has one source: `skills/openislands/SKILL.md`.** Edit it there, then `pnpm sync:skill`
to regenerate the copies in `templates/*/.agents/skills/` and the shared `.mcp.json` + `AGENTS.md`
(from `scripts/template-files/`); never hand-edit the synced copies. `validate:templates` re-syncs first.
Expand Down
5 changes: 4 additions & 1 deletion apps/docs/content/docs/concepts/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ A page may also declare shared `filters` rendered in the page header — a `date
date column (optionally opening on a `default` period like `last-90-days`, resolved live against
today), or a `select` that narrows a categorical column (single or `multiple`, its choices
drawn from the column's live distinct values) — each re-querying every island bound to the
filtered column at once. See the [Manifest Reference](/reference/manifest) for the full shape.
filtered column at once. A filter only touches the datasets named in its `bind` map; an island
whose dataset isn't bound (e.g. a SQL transform you add to a filtered page later) keeps showing
all-time until you add it to `bind`. See the [Manifest Reference](/reference/manifest) for the
full shape.

## Spans and the grid

Expand Down
68 changes: 68 additions & 0 deletions apps/docs/content/docs/islands/charts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,74 @@ renderer, so the dataset stays a flat list of `(step, delta, kind)` rows.
- [`format`](/reference/value-formats) styles the y axis and the per-step tooltip, which shows each
step's signed value.

## divergence.bars

A diverging bar chart: one signed bar per category or day, extending above or below a zero baseline
and colored by which value band it falls into. Reach for it to show deviation from a reference — a
daily calorie surplus/deficit, budget variance, net cash flow, or a temperature anomaly. Pick it
over `category.bar` when the values are signed and the up/down direction is the message, and over
`waterfall.bars` when each bar stands on its own rather than accumulating into a running total. The
island reads a pre-computed signed `value`; to diverge from a non-zero target, compute the delta in
SQL.

<LiveIsland
type="divergence.bars"
config={{
title: "Daily calorie delta",
x: "day",
value: "delta",
buckets: [
{ gte: 300, color: "#16a34a", label: "High surplus" },
{ gte: 0, lt: 300, color: "#4ade80", label: "Surplus" },
{ gte: -500, lt: 0, color: "#fb923c", label: "Deficit" },
{ lt: -500, color: "#ef4444", label: "Large deficit" },
],
}}
data={sampleData(
[
{ name: "day", type: "date" },
{ name: "delta", type: "double" },
],
[
{ day: "2025-06-16", delta: 420 },
{ day: "2025-06-17", delta: -180 },
{ day: "2025-06-18", delta: 90 },
{ day: "2025-06-19", delta: -640 },
{ day: "2025-06-20", delta: 260 },
{ day: "2025-06-21", delta: -120 },
{ day: "2025-06-22", delta: 880 },
{ day: "2025-06-23", delta: -540 },
{ day: "2025-06-24", delta: 60 },
],
"calorie_delta",
)}
/>

```jsonc title="manifest.json"
{
"type": "divergence.bars",
"title": "Daily calorie delta",
"dataset": "calorie_delta",
"x": "day",
"value": "delta",
"buckets": [
{ "gte": 300, "color": "#16a34a", "label": "High surplus" },
{ "gte": 0, "lt": 300, "color": "#4ade80", "label": "Surplus" },
{ "gte": -500, "lt": 0, "color": "#fb923c", "label": "Deficit" },
{ "lt": -500, "color": "#ef4444", "label": "Large deficit" }
]
}
```

- `x`: the category or date field — one bar per row (required); `value`: the signed numeric field,
rising above the zero baseline when positive and dropping below it when negative (required). Rows
with a null or non-numeric `value` are skipped.
- `buckets`: color each bar by the band its value falls in, matched top-to-bottom with half-open
bounds `[gte, lt)` (omit either bound for an open end). Give every bucket a `label` to show a
legend; a value matching no band renders neutral grey. Omit `buckets` entirely for a default
green-positive / red-negative two-tone.
- [`format`](/reference/value-formats) styles the y axis and the per-bar tooltip.

## rank.list

A ranked Top-N list with proportional horizontal bars, the island for a leaderboard: top products,
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/islands/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Each type links to its section with a live preview and full configuration.
| [`category.bar`](/islands/charts#categorybar) | `dataset`, `x`, `y` | A bar chart, optionally grouped or stacked. |
| [`category.combo`](/islands/charts#categorycombo) | `dataset`, `x`, `bars`, `lines` | Bars on a primary axis and lines on a secondary one. |
| [`waterfall.bars`](/islands/charts#waterfallbars) | `dataset`, `label`, `value` | A waterfall of signed steps to a running total. |
| [`divergence.bars`](/islands/charts#divergencebars) | `dataset`, `x`, `value` | Signed bars diverging up/down from a zero baseline, colored by value band. |
| [`rank.list`](/islands/charts#ranklist) | `dataset`, `label`, `value` | A ranked Top-N list with proportional bars. |
| [`breakdown.treemap`](/islands/charts#breakdowntreemap) | `dataset`, `label`, `value` | A treemap of parts of a whole. |
| [`category.pie`](/islands/charts#categorypie) | `dataset`, `label`, `value` | A pie or donut of one series' share across a few categories. |
Expand Down
19 changes: 18 additions & 1 deletion apps/docs/content/docs/reference/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ A `layout.row` is a structural wrapper: it holds other islands and forces them o
their own full-width grid row. It carries no `span`, `title`, or data binding, and it
cannot nest another `layout.row`.

**Built-in islands:** [`metric.kpi`](#metrickpi) · [`metric.scorecard`](#metricscorecard) · [`timeseries.line`](#timeseriesline) · [`category.bar`](#categorybar) · [`category.combo`](#categorycombo) · [`waterfall.bars`](#waterfallbars) · [`breakdown.treemap`](#breakdowntreemap) · [`distribution.heatmap`](#distributionheatmap) · [`activity.calendar`](#activitycalendar) · [`funnel.steps`](#funnelsteps) · [`rank.list`](#ranklist) · [`compare.radar`](#compareradar) · [`map.choropleth`](#mapchoropleth) · [`correlation.scatter`](#correlationscatter) · [`category.pie`](#categorypie) · [`table.grid`](#tablegrid) · [`timeline.feed`](#timelinefeed) · [`gauge.rings`](#gaugerings) · [`gauge.goal`](#gaugegoal) · [`gauge.meter`](#gaugemeter) · [`status.grid`](#statusgrid) · [`search.box`](#searchbox) · [`note.card`](#notecard) · [`source.doc`](#sourcedoc) · [`content.editor`](#contenteditor) · [`form.entry`](#formentry)
**Built-in islands:** [`metric.kpi`](#metrickpi) · [`metric.scorecard`](#metricscorecard) · [`timeseries.line`](#timeseriesline) · [`category.bar`](#categorybar) · [`category.combo`](#categorycombo) · [`waterfall.bars`](#waterfallbars) · [`divergence.bars`](#divergencebars) · [`breakdown.treemap`](#breakdowntreemap) · [`distribution.heatmap`](#distributionheatmap) · [`activity.calendar`](#activitycalendar) · [`funnel.steps`](#funnelsteps) · [`rank.list`](#ranklist) · [`compare.radar`](#compareradar) · [`map.choropleth`](#mapchoropleth) · [`correlation.scatter`](#correlationscatter) · [`category.pie`](#categorypie) · [`table.grid`](#tablegrid) · [`timeline.feed`](#timelinefeed) · [`gauge.rings`](#gaugerings) · [`gauge.goal`](#gaugegoal) · [`gauge.meter`](#gaugemeter) · [`status.grid`](#statusgrid) · [`search.box`](#searchbox) · [`note.card`](#notecard) · [`source.doc`](#sourcedoc) · [`content.editor`](#contenteditor) · [`form.entry`](#formentry)

### `metric.kpi`

Expand Down Expand Up @@ -362,6 +362,23 @@ A waterfall / bridge chart — use for a P&L walk or variance: an opening anchor
| `colors` | `object` | no | CSS colors per tone, overriding the defaults (increase green, decrease red, total neutral) |
| `format` | [`value format`](/reference/value-formats) | no | Display format for a value. Currency: usd, eur, gbp, jpy. Number: int, decimal, pct (a 0–1 fraction shown as a %), compact (1.2K). Unit: kg, bytes (1024-scale), duration (a number of seconds → 1h 5m). Date/time: date, datetime, time, month. Omit for a plain number with up to 2 decimals. For any other ISO 4217 currency use 'currency:\<CODE>' (e.g. currency:RSD). See the Value formats reference (/reference/value-formats). |

### `divergence.bars`

A diverging bar chart: one signed bar per category extending above or below a zero baseline, optionally colored by which value band it falls into. Use for deviation from a reference — daily calorie surplus/deficit, budget variance, net cash flow, sentiment, temperature anomaly. Pick this over category.bar when values are signed and the up/down direction is the message, and over waterfall.bars when each bar stands on its own rather than accumulating into a running total.

**Span:** min 4, recommended 6, max 12 (of 12). Omit `span` to render at the recommended width; below min or above max is a named error.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | no | |
| `title` | `string` | no | |
| `span` | `number` | no | |
| `dataset` | `string` | yes | |
| `x` | `string` | yes | category or date field (x axis) — one bar per row |
| `value` | `string` | yes | signed numeric field: the bar rises above the zero baseline for positive values and drops below it for negative. Rows with a null or non-numeric value are skipped. Pre-compute the difference in SQL to diverge from a non-zero target. |
| `buckets` | `array of object` | no | color each bar by the value band it falls in — e.g. surplus tiers green, deficit tiers red. Bands are matched top-to-bottom with half-open bounds [gte, lt); a value matching none renders neutral grey. Omit entirely for a default green-positive / red-negative two-tone. |
| `format` | [`value format`](/reference/value-formats) | no | Display format for a value. Currency: usd, eur, gbp, jpy. Number: int, decimal, pct (a 0–1 fraction shown as a %), compact (1.2K). Unit: kg, bytes (1024-scale), duration (a number of seconds → 1h 5m). Date/time: date, datetime, time, month. Omit for a plain number with up to 2 decimals. For any other ISO 4217 currency use 'currency:\<CODE>' (e.g. currency:RSD). See the Value formats reference (/reference/value-formats). |

### `breakdown.treemap`

A treemap of part-to-whole composition — use to show how a total splits across (optionally hierarchical) parts.
Expand Down
Loading
Loading