Skills DirectorySearch all skills ↗

AI agent skill · by @lightdash

Reusable Visualization

Build ONE reusable chart visualization component that receives its data and its settings from the host application instead of fetching them, and declares the fields and config options the host exposes to viewers. Use this whenever a single chart component is reused across many different queries rather than built for one — in Lightdash, the `data_app_viz` app template, also called a "viz". Covers the `useVizContext()` hook, the declaration contract, the config-option vocabulary, and series colours.

Archived 2026-10-23 · Source updated 2026-10-11. Check the original repository for the latest version.

SKILL.md instructions

# Reusable visualization

This is ONE reusable chart component, not an app. It runs no query and owns no explore.
Lightdash runs the query, then hands the component the result rows plus a mapping from the
field names the component declared to the columns in those rows. The same component is
reused across many different queries — every contract below follows from that: it cannot
hardcode column names, so it declares a field mapping; it cannot hardcode display choices
for queries it has never seen, so it declares config options.

No dashboard, navigation, multiple panels, filters or page chrome. Recharts, echarts, D3 or
plain SVG all work.

Everything the viewer can reach later exists only because the component declared it: the
columns they map, and every control in the chart's config panel. A literal hardcoded
instead stays frozen until somebody regenerates the whole viz.

## The hook

`useVizContext()` from `@lightdash/query-sdk` is the only channel to the host — data,
settings, and host actions all come from it. Do not add a message listener and do not
fetch anything yourself; the only host interaction is through the helpers the hook
returns.

```tsx
const context = useVizContext();
const {
  fieldMapping,
  fields,
  rows,
  options,
  colorPalette,
  pivotDetails,
  ready,
  pointMenu,
  underlyingData,
  drillDown,
  subtotals,
} = context;
```

- `fieldMapping` — `Record<string, string | string[]>`: each single-field input maps
  to one query field id; inputs declared with `multiple: true` map to ordered arrays.
  Narrow with `Array.isArray(binding)` before iterating. Read each id with
  `getFormatted(row, fieldId)` (display text) and `getRaw(row, fieldId)` (raw value).
- `fields` — `Record<string, { label, tableLabel?, format? }>`: display metadata for
  each bound query field id. Name fields with `getFieldLabel(context, fieldId)` — the
  field's label ("Total revenue"), or the raw id only when the host sent no metadata.
  `format` is the semantic-layer format (`type` such as `currency`/`percent`/`number`,
  plus `round`, `currency`, `compact`, `prefix`, `suffix`) for formatters that have no
  cell to hand, such as axis ticks; per-value text still comes from `getFormatted`.
- `rows` — the host-fetched result rows, keyed by query field id.
- `options` — `Record<string, boolean | number | string>`: the current value of each config
  option you declared (the viewer's choice, else your declared `default`).
- `colorPalette` — `string[]`: the Lightdash palette resolved for this chart. Always pushed,
  whether or not you declared `colorPalette`. Empty only when the host resolved none.
- `seriesColors` — `Record<string, string>`: final host-resolved colours keyed by backend
  pivot column name. Read them through `resolveSeriesColor(context, column, index)`.
- `valueColors` — `Record<string, Record<string, string>>`: final host-resolved colours keyed
  by query field id then raw value. Read them through
  `resolveValueColor(context, fieldId, rawValue, index)`.
- `pivotDetails` — the complete backend-pivot layout, or `null` for ordinary rows. See
  "Backend-pivoted results" below.
- `ready` — false until the first context arrives.
- `pointMenu` — host-rendered data-point action menu for a clicked mark; this is
  the current data-point action contract. See "Data-point actions" below.
- `underlyingData` — host-mediated access to the raw rows behind a clicked data point;
  the fallback per-action dialog for hosts that predate `pointMenu`. See
  "Data-point actions" below.
- `drillDown` — host-mediated drill on a clicked data point; the fallback
  per-action dialog for hosts that predate `pointMenu`. See "Data-point
  actions" below.
- `subtotals` — aggregated rows for the levels of a declared hierarchy. Declare
  `hierarchy: { field: "<input name>" }` in the schema, naming a dimension input with
  `multiple: true` ordered from the top level down. `rows` stays at the most detailed
  level. When `subtotals.enabled`, `subtotals.dimensions` lists the bound field ids and
  `await subtotals.get({ level, parentValues })` returns `{ rows }` for one level:
  `level: 0` with `parentValues: []` is the top level, `level: 1` with the raw value of
  one top-level group returns that group's children. Rows come back unsorted, one per
  group and one per series value when the result is pivoted. Subtotals belong to the
  `rows` they were fetched for: refetch when `rows` change, handle a rejected promise,
  and render from `rows` alone when `subtotals.enabled` is false.

Read all of them. Pass the complete `context` to the colour helpers; they preserve model
colours and shared dashboard assignments before falling back to `colorPalette`.

### These app-level APIs do not apply to a viz

The sandbox skill (`/app/skill.md`) is written for data apps, which do run queries. A
reusable visualization does not. Do **not** use `useLightdash()`, `filtersFor(EXPLORE)`,
`addFilter({...})`, or `format(row, fieldName)` — there is no explore to query or filter,
and formatting arrives pre-computed via `getFormatted`. Where this skill and the sandbox
skill differ, this skill wins for a viz. Everything else in the sandbox skill (React,
shadcn, charting, theming, floating surfaces, screenshots) still applies.

## Rendering rules

- **Fill the viewport.** Give the root element `height: 100vh` (or `position: fixed; inset: 0`),
  NOT `height: 100%` — that collapses to a 0-height invisible box unless every ancestor also
  sets a height, leaving auto-sizing charts like recharts `<ResponsiveContainer>` nothing to
  measure. Confirm the chart actually renders and isn't a blank box.
- **Fit the frame.** The viewport is an iframe exactly the size of the chart's tile, and
  the tile never resizes to its content. See "Fitting the frame" below.
- **Placeholder.** Show a clearly visible placeholder (readable, good contrast — never
  near-white on white) while `!ready`, when a required field is unmapped, or when there are
  no rows.
- **Fundamentals.** Clear axes and labels, readable spacing, a tooltip on hover.
  Give every axis a `tickFormatter` (compact numbers, shortened labels) or `hide`
  it — raw ticks overflow and overlap on a chart the viewer can resize.
- **Labels, never ids.** Wherever the chart names a field — axis titles, legend and
  series names, table headers, tooltip rows, default labels — use
  `getFieldLabel(context, fieldId)`. Never show the query id (`orders_total_revenue`)
  or a name derived from it (humanized, or with the table prefix stripped).

### Fitting the frame

A tile can be anything from about 300px wide and 300px tall (a phone, where a dashboard
stacks its tiles in one column) to a full-width desktop tile. The template already makes
the frame the container: it scrolls vertically when the content is taller than the tile,
and its base font size shrinks in a narrow tile. What is left to the chart:

- Size type, padding and gaps in `rem` or Tailwind classes, never `px`, so they shrink
  with the frame.
- A row of items wraps onto more rows as the frame narrows (auto-fit grid columns or
  `flex-wrap`). The minimum item width is what one item needs to keep its values on one
  line, capped just under half the frame so two still fit on a phone:
  `min(<that width in rem>, 45%)`.
- A value stays on one line and is never truncated. A long label wraps between words.
  A table is `width: 100%` with wrapping header cells.
- Add no scroll region of your own. A title or table header row that should stay visible
  while the frame scrolls is `position: sticky; top: 0` with an opaque background.

## Series colours

Use the query SDK's host-resolved colour helpers for every series or group:

- Backend-pivoted series: `resolveSeriesColor(context, column, index)`.
- Client-side groups: `resolveValueColor(context, fieldId, rawValue, index)`.

The helpers first honor model-defined fixed colours and Lightdash's shared dashboard colour
assignment, then fall back to `colorPalette[index % colorPalette.length]`. Keep a small
fallback array only for the case where the helper returns `undefined`, and declare
`colorPalette` in your output so the viewer gets the palette picker.

This is the same rule as the sandbox skill's "chart series colors must come from
`CHART_COLORS`" and `references/d3.md`'s "never hand-pick palettes" — the same Lightdash
palette, delivered differently. An app imports `CHART_COLORS`; a viz reads the resolved
colours off the hook, because the viewer picks the palette per chart. In a viz, use the
hook, not `CHART_COLORS`.

## Backend-pivoted results

A mapped `series` field makes Lightdash pivot the results before they reach the viz. In
that case the mapped metric id is no longer a row key: each series becomes a generated
column described by `pivotDetails.valuesColumns`.

Match `valuesColumns` whose `referenceField` equals the mapped metric id. For each match,
use `pivotValues` to identify and label the mapped series value. Generated
`pivotColumnName` keys contain the same `VizContextCell` objects as ordinary field keys:
read them with `getRaw(row, column.pivotColumnName)` or
`getFormatted(row, column.pivotColumnName)`. Never coerce `row[fieldId]` directly with
`String`, `Number`, or template interpolation; that coerces the cell object rather than
its value. Preserve the ordinary `fieldMapping` row path when `pivotDetails` is `null`, so
the same viz works without a mapped series and on older unpivoted charts.

The rest of `pivotDetails` describes the full layout rather than assuming the viz is a
Cartesian chart:

- `indexColumn` — row-grain fields and their `time`/`category` axis semantics.
- `groupByColumns` — pivot-header fields in backend layout order.
- `originalColumns` — original field ids and semantic types before pivoting.
- `sortBy` — the sort applied to the pivoted result, including anchored pivot values.
- `totalColumnCount` — the untruncated pivot column count; use it when the viz needs to
  explain that the returned columns hit a limit.
- `passthroughDimensions` — hidden dimensions retained on rows for cross-field rendering.

Use these when the visualization's shape benefits from them: a table can build row and
column headers, while a chart can select a time axis from metadata instead of guessing
from values. Treat `fieldMapping` as the declared interface; metadata describes how those
fields were laid out, not permission to render unrelated query fields automatically.

```tsx
const metricId = fieldMapping['value'];
const seriesId = fieldMapping['series'];
const pivotedMetrics =
  pivotDetails?.valuesColumns.filter(
    (column) => column.referenceField === metricId,
  ) ?? [];

const series = pivotedMetrics.map((column, index) => ({
  columnId: column.pivotColumnName,
  label:
    column.pivotValues.find((value) => value.referenceField === seriesId)
      ?.formatted ?? 'Unknown',
  color: resolveSeriesColor(context, column, index) ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length],
}));

const categoryId = fieldMapping['category'];
const data = rows.map((row) => ({
  label: getFormatted(row, categoryId),
  ...Object.fromEntries(
    series.map(({ columnId }) => [
      columnId,
      Number(getRaw(row, columnId) ?? 0),
    ]),
  ),
}));
```

Use the order of `valuesColumns` for the helper's fallback index. A chart with multiple
declared series fields builds its label from each matching `pivotValues` entry in declared
field order. Backend-pivoted rows do not have safe one-source-row provenance, so the host
sets `underlyingData.enabled` to false for them.

## Data-point actions: pointMenu, underlying data, and drill-down

Every chart whose marks satisfy the provenance rule below MUST wire the standard
data-point action. This is part of the component contract, not an optional nicety —
a "keep it minimal" prompt does not waive it. `pointMenu.enabled` (or, on the
fallback path, `underlyingData.enabled` and `drillDown.enabled`) decides at RUNTIME
whether anything shows; you always write the wiring, and it costs nothing visually
when disabled.

### `pointMenu`: the current contract

The current contract is a single call on click: `pointMenu.open({ x, y, row, metric,
fieldId? })`, with `x`/`y` taken from the click event's `clientX`/`clientY`. Lightdash
renders the entire menu — copy value, view underlying data, drill, dashboard
cross-filtering — over the viz and owns all subsequent UI; the component renders no
menu of its own. `pointMenu.open` resolves `{ shown: boolean }`; `shown: false` means
the host had no applicable action for that point, and the component still renders
nothing extra.

```tsx
onClick={(event, datum) => {
  if (pointMenu.enabled) {
    pointMenu
      .open({
        x: event.clientX,
        y: event.clientY,
        row: datum.sourceRow,
        metric: 'value',
      })
      .catch(() => {});
    return;
  }
  // Fallback for hosts that predate the point menu:
  openLocalActionMenu(event, datum);
}}
```

Only when `pointMenu.enabled` is false (a host that predates the capability) does the
component fall back to building its own in-viz action menu from `underlyingData.open`
and `drillDown.open`, as described in the rest of this section.

Provenance is the contract: **every interactive datum keeps a reference to its
untransformed source row.** When mapping `rows` into chart data, carry the row:

```tsx
const data = rows.map((row) => ({
  label: getFormatted(row, catField),
  value: Number(getRaw(row, valField) ?? 0),
  sourceRow: row,               // ← required for the data-point action
}));
```

Only attach the action where one mark maps to exactly ONE source row and ONE metric-slot
field. A mark that aggregates several rows (a binned bucket, a "top N + other" slice)
gets no data-point action.

### Fallback: the in-viz action menu

Everything through "Open the menu at the data point" below applies only when
`pointMenu.enabled` is false. It calls the two per-action APIs directly and builds
its own accessible in-viz menu instead of delegating to a Lightdash-rendered one.

When `underlyingData.enabled` is true, viewers get the fallback action on chart
marks: click a data point → small action menu → "View underlying data" → the
standard Lightdash dialog listing the raw rows behind that point, with its Download
button. When it is false (host too old, viewer lacks permission, embed), render no menu
item — never a disabled one.

On click, send the intent and render nothing else. Lightdash opens the dialog outside
the viz and owns its table, loading, error, sorting, and download controls:

```tsx
underlyingData.open({ row: datum.sourceRow, metric: 'value' }).catch(() => {});
```

`metric` is the declared field NAME from your `fields` (the same key you read from
`fieldMapping`), not a query field id. The custom chart type owns only its data-point
action menu. It never renders an underlying-data dialog, table, loading state, error
state, or download control inside the iframe.

For a multiple-metric input, also pass `fieldId`, the member of that input's binding
whose mark was clicked: `underlyingData.open({ row: datum.sourceRow, metric: 'values',
fieldId })`. Use the same `fieldId` with `drillDown.open`. A mark combining several
metrics cannot identify one underlying metric and gets no data-point action.

### Drill into a data point

When `drillDown.enabled` is true, the same data-point action menu also offers
"Drill into *{formatted metric value}*" (e.g. "Drill into $1,234"). On
selection, fire the intent and render nothing else — Lightdash opens its own
drill dialog outside the viz:

```tsx
drillDown.open({ row: datum.sourceRow, metric: 'value' }).catch(() => {});
```

`metric` is the declared field NAME, exactly as for `underlyingData`. The same
provenance contract applies: the item appears only where one mark maps to
exactly ONE source row and ONE metric-slot field, and only when
`drillDown.enabled` — never a disabled item. The two flags are independent:
show each action on its own flag (a viewer may have one permission but not
the other).

### Open the menu at the data point

Anchor this menu at the activated mark, not at an edge of the iframe or chart.
If neither action is enabled, do not open or render an empty menu and do not
give marks action-button semantics.
Keep the clicked datum's `sourceRow` and declared metric name (and `fieldId` for
a multiple input) in the menu state; the menu items must act on that exact
identity. Pass the helper a library-neutral anchor: the activating DOM mark,
whether activation was by pointer or keyboard, and pointer viewport coordinates
when available. For a pointer activation, use native `clientX` and `clientY`,
never chart-relative coordinates such as `chartX` or `chartY`. For keyboard
activation, anchor at the centre of the activating mark's
`getBoundingClientRect()` instead.

Adapt each chart library before calling the helper. Recharts' React click
handler supplies `event.currentTarget`, `event.clientX`, `event.clientY`, and
`event.detail === 0` for keyboard-originated clicks. For ECharts, extract the
native pointer event from its click parameters before reading `clientX` and
`clientY`; do not assume React fields exist. D3 already passes the native event.

**Make each eligible SVG mark keyboard reachable while either action is enabled.**
When neither is enabled, omit the button role, tab stop, and action handlers.
Otherwise give the mark `tabIndex={0}`,
`role="button"`, and an accessible name from its category and formatted value.
In Recharts, use a custom `shape` to wire `onClick` and `onKeyDown` on the rendered
mark; the chart-wide `accessibilityLayer` alone does not open per-mark menus.
On Enter or Space, prevent the default key action and call the same helper with
`keyboard: true` and the mark as `activator`. Verify Tab → mark → Enter/Space →
menu → Escape returns focus to that mark.
Register rendered marks in a ref map keyed by stable datum key, using a ref callback
on the SVG element: `ref={(node) => registerMark(datum.key, node)}`.
Resolve the current mark after the menu unmounts when restoring
focus: a saved DOM element can become detached during chart rerenders.

Use the existing shadcn/Radix `DropdownMenu`, rendered with
`createPortal(..., document.body)`. Its 1px fixed trigger is positioned at the
stored viewport coordinates; this avoids transformed iframe ancestors changing
the containing block for `position: fixed`. Let `DropdownMenuContent` use its
normal Radix collision handling so a menu near a viewport edge flips or shifts
onscreen. The placement state is small; the existing `underlyingData.open` and
`drillDown.open` calls remain the menu items' host actions:

```tsx
const markElements = useRef(new Map());
const registerMark = (key, node) => {
  if (node) markElements.current.set(key, node);
  else markElements.current.delete(key);
};
const openFallbackMenu = (datum, anchor) => {
  if (!underlyingData.enabled && !drillDown.enabled) return;
  const rect = anchor.activator.getBoundingClientRect();
  setTooltipVisible(false);
  setMenu({
    sourceRow: datum.sourceRow,
    metric: datum.metric,
    fieldId: datum.fieldId,
    pointKey: datum.key,
    keyboard: anchor.keyboard,
    x: anchor.keyboard ? rect.left + rect.width / 2 : anchor.clientX,
    y: anchor.keyboard ? rect.top + rect.height / 2 : anchor.clientY,
  });
  if (!anchor.keyboard) anchor.activator.blur();
};

{menu && (underlyingData.enabled || drillDown.enabled) && createPortal(
  <DropdownMenu open onOpenChange={(open) => !open && setMenu(null)}>
    <DropdownMenuTrigger asChild>
      <span aria-hidden style={{ position: 'fixed', left: menu.x, top: menu.y, width: 1, height: 1 }} />
    </DropdownMenuTrigger>
    <DropdownMenuContent
      onCloseAutoFocus={(event) => {
        event.preventDefault();
        if (menu.keyboard) {
          requestAnimationFrame(() => markElements.current.get(menu.pointKey)?.focus());
        }
      }}
    >
      {/* independently gated action items */}
    </DropdownMenuContent>
  </DropdownMenu>,
  document.body,
)}
```

### Interaction hygiene

These rules apply on both paths — the host-rendered `pointMenu` and the fallback
in-viz menu. One floating surface at a time, and no leftover emphasis — native
Lightdash charts show a menu OR a tooltip, never both, and clicking leaves no mark
highlighted:

- **Opening the action menu closes the tooltip.** Drive tooltip visibility
  from your own state (recharts: gate `<Tooltip>` via controlled props or a
  wrapper's visibility; echarts: `dispatchAction({ type: 'hideTip' })` on
  click). While the menu is open the tooltip stays hidden, and it must not
  reappear until the pointer moves again after the menu closes.
- **No persistent focus or active styling on a clicked mark.** Disable click
  emphasis (recharts: no `activeShape` on click state; echarts: turn off
  lingering `emphasis`/`select`). A keyboard activator keeps its normal focus
  affordance and receives focus again when the menu closes; it must not look
  like a selected data point. Only hover may emphasise, and only while hovering.
- **Subtle hover, no cursor band.** The library-default full-height band
  behind the hovered mark (recharts `<Tooltip cursor>`) is not native
  behaviour — use `cursor={false}` or a faint theme-token fill.
- **Tooltips are themed and deduplicated.** Background, text and border come
  from the theme tokens (never library-default white), and each value appears
  once: one line per series actually under the pointer.

## The declaration

Alongside the component you emit one structured declaration — as **structured output, not a
file**. It has four parts: `fields`, `configOptions`, `colorPalette` and optional
`inputGuidance`. Lightdash builds
the field-mapping UI and the chart config panel from it, so the component is unusable
without it.

The correspondence is exact in both directions: **every key read from `fieldMapping` or `options` is declared, and everything declared is read.** A declared
option nothing reads is a dead control the viewer can move with no effect.

### `fields`

One entry per input the component reads:

- `name` — the key read from `fieldMapping`. Unique, no spaces.
- `label` — human label shown in the mapping UI.
- `type` — `dimension` (a category/grouping column), `metric` (a numeric measure),
  `series` (a dimension used to split or colour the chart), or `column` (any result
  column, whether metric or dimension).
- `required` — `false` only when the chart still renders with this field unmapped.
- `multiple` — optional boolean, default `false`. Set `true` when this input accepts an
  ordered selection of fields of its declared type. Its `fieldMapping` value is a
  `string[]`, including when only one field is selected. A required input needs at least
  one selection; an empty array means the viewer cleared it. Render a placeholder until
  required selections exist.
- `description` — optional reusable mapping help, maximum 160 characters. Use one
  or two short, plain sentences explaining the field's role in the chart. Keep it agnostic
  to any business, explore, or query; do not imply fixed categories or values.

Do not generate field examples. Viewers supply their own values; describe the field's
role and data shape without prescribing categories from a particular business.

### Ordered multiple-field inputs

Use one multiple input for a variable number of measures or grouping columns instead of
declaring a fixed number of numbered slots:

```json
{
  "fields": [
    { "name": "groups", "label": "Grouping fields", "type": "dimension", "required": true, "multiple": true },
    { "name": "values", "label": "Measures", "type": "metric", "required": true, "multiple": true }
  ],
  "configOptions": [],
  "colorPalette": null
}
```

```tsx
const { fieldMapping, rows } = useVizContext();
const groups = Array.isArray(fieldMapping.groups) ? fieldMapping.groups : [];
const values = Array.isArray(fieldMapping.values) ? fieldMapping.values : [];
const data = rows.map((row) => ({
  label: groups.map((id) => getFormatted(row, id)).join(' / '),
  values: values.map((id) => ({ id, value: getRaw(row, id) })),
}));
```

Array order is the viewer's display order: preserve it when building axes, columns,
legends and series. For pivoted metrics, iterate the selected ids first and then match
`pivotDetails.valuesColumns` by `referenceField`. The flag also works for `series` and
`column` inputs. Existing single inputs continue receiving strings in `fieldMapping`;
leave their declarations unchanged when adding a separate multiple input. Keep field
names stable across compatible upgrades so saved selections can be reconciled.

### `inputGuidance`

Optional reusable help shown above the mappings, maximum 200 characters. Use at most two
short, plain sentences. State what each row represents, any ordering the visualization relies
on, and how to change a query that has a different shape.
For a funnel, say that each row contains one stage label and its count in stage order; separate
stage metrics or boolean flags need reshaping into stage/count rows, or a differently authored
chart. This explains how the same viz can be reused with other queries; it does not make the
component infer semantics or transform host data.

### `configOptions`

One entry per setting the viewer can change without regenerating the viz. Every option is a
whole-viz value applying to the entire chart — there is no per-series option. Colour series
and groups with the SDK's resolved-colour helpers.

Every option has:

- `name` — the key read from `options`. Unique, no spaces.
- `label` — human label shown next to the control.
- `group` — optional tab name. Options sharing a `group` are rendered in the same config
  tab; ungrouped options share a default tab.
- `default` — REQUIRED on every option. The value the viz uses until the viewer changes it;
  its shape follows `type`. Use the value you would otherwise have hardcoded.
- `type` — exactly one of these five, no others:

| `type` | control | `default` | extra keys |
|---|---|---|---|
| `boolean` | switch | `true` / `false` | — |
| `select` | dropdown | one of the declared choice `value`s (a string) | `choices`: array of `{ "value": "...", "label": "..." }`, at least one entry |
| `number` | number input | a number | `min`, `max` — both optional numbers |
| `text` | single-line text input | a string | — |
| `color` | single colour | a hex string, e.g. `"#7162FF"` | — |

The `color` control includes swatches from the active Lightdash palette and accepts custom
hex colours. It stores the chosen hex string. Changing the palette or light/dark theme
updates the available swatches; it does not change previously chosen colours.

Series colours are not in this list. They are declared separately, on `colorPalette`.

### `colorPalette`

Whether the viewer gets the standard Lightdash palette picker. Declare
`{ "group": "..." }` when your component colours anything with the resolved-colour helpers
or `colorPalette`, or `null` when it colours nothing. `group` is optional and works like an
option's: the picker joins that tab, and gets a tab of its own when no option shares the
name.

This is not a config option. There is one palette per chart, it has no `name` and no
`default`, and its colours arrive on `colorPalette` — never on `options`.

## Worked example

Component and declaration lining up. Your chart will differ; the correspondence must not.

```tsx
import { useVizContext, getFormatted, getRaw, resolveValueColor } from '@lightdash/query-sdk';
import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';

function Chart() {
  const context = useVizContext();
  const { fieldMapping, rows, options, ready } = context;
  if (!ready) return <div style={{ height: '100vh' }}>Loading…</div>;
  const catField = fieldMapping['category'];            // your field name -> column id
  const valField = fieldMapping['value'];
  const showLabels = options['showLabels'] as boolean;  // your option name -> current value
  const maxBars = options['maxBars'] as number;
  const fallbackColors = ['#7162FF', '#1A1B1E'];
  const data = rows.slice(0, maxBars).map((row) => ({
    label: getFormatted(row, catField),                 // display text, e.g. "Completed"
    category: getRaw(row, catField),                    // raw value for host-resolved colour
    value: Number(getRaw(row, valField) ?? 0),          // raw number
  }));
  return (
    // The root fills the viewport, so ResponsiveContainer has a height to measure.
    <div style={{ height: '100vh' }}>
      <ResponsiveContainer width="100%" height="100%">
        <BarChart data={data}>
          <XAxis
            dataKey="label"
            tickFormatter={(v) => (v.length > 12 ? `${v.slice(0, 11)}…` : v)}
          />
          <YAxis tickFormatter={(v) => v.toLocaleString()} />
          <Tooltip />
          <Bar dataKey="value">
            {data.map((d, i) => (
              <Cell
                key={d.label}
                fill={resolveValueColor(context, catField, d.category, i) ?? fallbackColors[i % fallbackColors.length]}
              />
            ))}
            {showLabels && <LabelList dataKey="value" position="top" />}
          </Bar>
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}
```

The declaration that component emits is exactly:

```
fields: [{ "name": "category", "label": "Category", "type": "dimension", "required": true, "description": "The label for each chart row." },
         { "name": "value", "label": "Value", "type": "metric", "required": true, "description": "The numeric value for that row." }]
configOptions: [{ "name": "showLabels", "label": "Show value labels", "group": "Labels", "type": "boolean", "default": true },
                { "name": "maxBars", "label": "Max bars", "type": "number", "default": 10, "min": 1, "max": 50 }]
colorPalette: {}
inputGuidance: "Use one row per category in display order. Reshape queries with separate category columns into category/value rows before mapping."
```

## Final pass, before you finish

Re-read the component you just wrote and list every literal in it: each colour, each
true/false you chose, each number, each string you typed into the chart. Take the list one
entry at a time and ask "would a viewer plausibly want this different?". This is the
minimum, not a menu:

- every element you chose to show or hide (value labels, legend, gridlines, an axis) → `boolean`
- every variant you picked between (vertical/horizontal, grouped/stacked, curve style) → `select`
- every number you chose (bar width, max rows, decimal places, a threshold) → `number`
- every string you wrote into the chart (title, axis label, empty-state text) → `text`
- every accent colour that is not a series colour (a target line, a highlight) → `color`
- the series colours, whenever the chart draws more than one series → resolved-colour
  helper plus `colorPalette`

Where the answer is yes, make that literal the option's `default`, declare the option, and
read the option in its place. Leave it hardcoded only where changing it would break the
chart.

Then check both directions: every key you read from `options` is declared, and every option
you declared is read somewhere. `colorPalette` is declared when you use either resolved-
colour helper or colour from `colorPalette` — it is never read from `options`.

Then resize the frame mentally, to a 320px by 320px phone tile and to a wide desktop
tile: nothing is wider than the frame, rows of items wrap, and a title or table header
stays in view if the frame scrolls.

Before returning the declaration, add a `description` to each slot whose role
could be ambiguous to a viewer, and add `inputGuidance` whenever row shape or ordering
matters. Keep that help reusable across queries: describe the chart contract rather than a
specific dataset.

Finally, if any mark maps to exactly one source row, the data-point action
is wired: each interactive datum carries `sourceRow`, and the click handler calls
`pointMenu.open({ x, y, row, metric })` when `pointMenu.enabled`, falling back to
an in-viz menu — gated on `underlyingData.enabled` and `drillDown.enabled` — only
when it is not. Omitting the action on a chart whose marks satisfy
provenance is a defect, not a simplification. Then click a mark mentally:
the tooltip closes, nothing stays highlighted, and only the menu remains —
one floating surface at a time.

Related skills