---
id: "packages/active-grid/columns-api"
title: "Columns API"
description: "The column definition, column types and every column meta field."
url: "/packages/active-grid/columns-api"
locale: "en"
revision: "30d821cbf366374a"
---

# Columns API

> [!NOTE]
> **The package's own reference**
>
> The text below is the package's own API reference, included unchanged. The
> complete list of exports, generated from the source, is on [All
> exports](/packages/active-grid/api).

`GridColumnDef`, `GridColumnMeta`, `GridColumnTypes`, `GridColumnDefaults`,
`createColumnHelper`, `ColumnHelper`, and the `GridDefaults` boundary.
For tutorial-style how-to see `../guides/columns.md`.

## createColumnHelper

```typescript
import {
  createColumnHelper,
  type ColumnHelper,
  type CellContext,
  type GridColumnDef,
} from "@workspace/active-grid";

const ch = createColumnHelper<MyRowType>();

const columns: GridColumnDef<MyRowType>[] = [
  ch.accessor("id", { header: "ID", type: "text" }),
  ch.accessor((row) => row.user?.name ?? "", {
    id: "userName",
    header: "User",
    cell: (info: CellContext<MyRowType, string>) => <strong>{info.getValue()}</strong>,
  }),
];
```

Type-safe column builder, and the only construction path that infers a
column's value type. `accessor(key, def)` resolves the value from the field the
key names, so `info.getValue()`, `meta.valueFormatter` and every other value
callback are typed without an annotation and without `(info: any)`.

The key must name a field of the row, or a dotted path to one. A key naming no
field is a compile error, in this path and in the object-literal path alike.

Returns a `ColumnHelper<TData>`. Its three builders produce the same column
kinds an object literal produces, over the same row type: `accessor()` a
value-reading column, `display()` a column with no value, `group()` a header
group whose children are column definitions at every depth.

Canonical usage rules live here and in
`../guides/columns.md`. The helper implements the
neutral public column contract; it does not expose an engine feature registry.

### Where inference works, and where an annotation is still needed

| Pattern | Inference |
| --- | --- |
| `ch.accessor('name', {…})` | ✓ the key gives the value type |
| `ch.accessor('nested.field', {…})` | ✓ a dotted path runs through the row |
| `ch.accessor('name', {cell: (info) => …})` | ✓ `info.getValue()` is the column's value type |
| `ch.accessor((row) => …, {id: 'x'})` | ✓ the value type is what the function returns |
| `{accessorKey: 'name', …}` in an array annotation | ✗ the value type is the default; annotate when in doubt |

The last row is the only one that remains, and the reason is under
[The value type](#the-value-type): one array annotation covers columns with
different value types, so the default of `TValue` is broad. For one strict
column, name the type: `GridColumnDef<Row, number>`. For a whole strict grid,
use the column helper.

## GridColumnDef

```typescript
import {type GridColumnDef} from '@workspace/active-grid';
```

A column definition. It is a **union of three kinds**, and that is what makes an
invalid definition invalid:

| Kind | Recognised by | Has a cell value |
| --- | --- | --- |
| accessor-key column | `accessorKey`, naming a row field | yes |
| accessor-function | `accessorFn`, plus an `id`/`header` | yes |
| group or display | `id` or a string `header` | no |

A group column is the third kind with `columns`; a display column is the third
kind without it. They are one kind on purpose: with `columns` optional, two
separate kinds would let a definition whose children are invalid pass as a
display column carrying a spare property.

### Reading a definition back

Because it is a union, `accessorKey` is **not reachable** on
`GridColumnDef<Row>`: two of the three kinds do not have it. A reader narrows
first, and the package publishes the two predicates for it:

```typescript
import {hasAccessorKey, hasAccessorFn} from '@workspace/active-grid';

const status = columns.find(
  (c) => hasAccessorKey(c) && c.accessorKey === 'status',
);
```

A local `column as {accessorKey?: string}` is not the convention. It is an
assertion in a position where a test is available, and it keeps compiling after
the field it names has moved.

### The value type

`GridColumnDef<Row>` leaves `TValue` at its default, and the default is
deliberately broad. One array annotation holds columns whose values are a
string, a number and nothing at all, and every value position in a definition
(`cell`, `header`, `footer`, `meta.valueFormatter`, …) is a callback PARAMETER,
which `strictFunctionTypes` checks contravariantly. A narrow default would
therefore refuse every column that knows what it holds — including everything
`createColumnHelper` builds.

What the broad default does **not** relax, and each of these is a compile error:

- an unknown `meta` key, and a `meta` value of the wrong type;
- an `accessorKey` naming no field of the row;
- a nested entry of `columns` that is not a column definition;
- a primitive where a definition belongs.

A nested `columns` value that is not a list, or a nested entry that is not an
object, is also refused at runtime with an error naming its position, for a
caller the compiler does not see.

Name the type for a strict single column: `GridColumnDef<Row, string>` refuses a
renderer shaped for a number. Use `createColumnHelper` for a strict whole grid —
it infers each column's value type, so nothing has to be named.

| Property | Type | Description |
| --- | --- | --- |
| `accessorKey` | `DeepKeys<TData>` | The row field this column reads, or a dotted path to one. A name the row does not carry is a compile error. |
| `accessorFn` | `(row: TData) => TValue` | Custom accessor function. |
| `id` | `string` | Column identifier. Auto-derived from `accessorKey` or a string `header` if not set; a column with no derivable identifier is refused with an error. |
| `header` | `string \| ((context) => ReactNode)` | Header content — string or render function. |
| `cell` | `(context) => ReactNode` | Custom cell renderer. |
| `footer` | `string \| ((context) => ReactNode)` | Footer content. |
| `columns` | `Array<GridColumnDef<TData>>` | Nested columns for a column group. Recursively this same definition, so a group child carries `type`, `server` and `meta` exactly as its parent does, at any depth. Until TD-31B closed it was the engine's own shape and a child carried none of them. |
| `size` | `number` | Initial column width in pixels. |
| `minSize` | `number` | Minimum width (default: 100). A column that renders a filter control automatically gets at least 80px, so the clear and filter-menu buttons stay inside the cell -- see `filters.md` § The layout contract of the filter row. That floor only raises; a larger value here still applies. |
| `maxSize` | `number` | Maximum width (default: 800). |
| `enableSorting` | `boolean` | Whether this column can be sorted (default: true). |
| `enableResizing` | `boolean` | Whether this column can be resized (default: true). |
| `enableHiding` | `boolean` | Whether this column can be hidden (default: true). |
| `sortFn` | function or name | A comparator `(rowA, rowB, columnId) => number` over rows that may be generated (narrow with `getIsGrouped()` before reading `original`), or a built-in name: `basic`, `datetime`, `text`, `textCaseSensitive`, `alphanumeric`, `alphanumericCaseSensitive`, `localeText` (orders text in the language of the grid's `locale`); `auto` or no declaration infers one from the values. An unknown name is refused. |
| `filterFn` | function or name | A filter function `(row, columnId, filterValue, addMeta?) => boolean` over a `SourceRow`, or a built-in name: `includesString`, `includesStringSensitive`, `equalsString`, `equals`, `weakEquals`, `arrIncludes`, `arrIncludesAll`, `arrIncludesSome`, `arrHas`, `inNumberRange`, `between`, `betweenInclusive`; `auto` or no declaration infers one. An unknown name is refused. |
| `type` | `string \| string[]` | Column type preset name(s) from `columnTypes`. |
| `enableGrouping` | `boolean` | Whether this column can be grouped (default: true when grid-level `enableGrouping` is on). |
| `aggregationFn` | `AggregationFnOption` | The aggregate a group row shows for this column: a function `(columnId, leafRows, childRows) => unknown` or a built-in name: `sum`, `min`, `max`, `extent`, `mean`, `median`, `unique`, `uniqueCount`, `count`, `auto` (sums numbers, nothing otherwise). **No declaration, no aggregate**: a numeric column is not summed because it is numeric (DC-3); the group-row value is then absent, not `null`. |
| `aggregatedCell` | `ColumnDefTemplate` | Custom render template for aggregated cells in group rows; receives a `GeneratedCellContext`. |
| `getGroupingValue` | `(row: TData) => unknown` | Custom grouping value getter. Defaults to `accessorKey`/`accessorFn`. |
| `meta` | `GridColumnMeta<TData, TValue>` | Column metadata. See below. |

Sorting keeps source order for equal values, in both directions. Ascending, an
explicit empty value sorts first and an absent value last; descending reverses
that, unless `sortUndefined` declares a policy. The first direction of a toggle
is inferred from the kind of value the column holds and can be overridden with
`sortDescFirst`.

What the built-in names do beyond the obvious:

- **Unknown names.** A `sortFn` or `filterFn` naming no built-in, an inherited
  object name such as `toString` included, is refused with an error that names
  the column and lists the valid names. An unknown `aggregationFn` name
  produces no aggregate.
- **`undefined` is no declaration.** A `sortFn`, `filterFn` or `aggregationFn`
  set to `undefined` behaves exactly as an omitted one.
- **Mixed values.** `basic` and `datetime` order by kind first: numbers,
  booleans and dates on one numeric scale, then not-a-number and invalid dates,
  then words, then everything else. A mixed column therefore sorts to one order
  whatever order its rows arrived in.
- **`inNumberRange`** treats only present finite numbers as numbers: `null`,
  empty or numeric words, booleans and infinities fall outside every range,
  and `0` is kept. A blank or non-numeric end leaves that side open; reversed
  ends are swapped.
- **`between` / `betweenInclusive`** compare a value with an end only when both
  are numbers, booleans or dates, or both are words; any other value is
  outside the range. An absent or blank end leaves that side open and reversed
  ends are swapped. A filter value that is not an array of ends (`[low, high]`)
  is refused with an error naming the column.
- **`arrHas`** with a wanted value that is not an array matches nothing.

Widths are held between the column's `minSize` and `maxSize` when the grid
accepts them, so persisted sizing state carries the width the grid uses. A
column whose own `minSize` exceeds its own `maxSize` is refused with an error
naming the column; when only one of two conflicting bounds is the column's own
and the other is inherited, the column's own bound wins.

## GridColumnMeta

```typescript
import {type GridColumnMeta} from '@workspace/active-grid';
```

Rich metadata for column behavior and appearance.

### The key space is gated AND type-checked (TD-31B, 2026-09-20)

The compiler is the first enforcer. `GridColumnDef<TData>` is a real union, so an
unknown `meta` key, a `meta` value of the wrong type, an `accessorKey` naming no
field and a nested entry that is not a column are each a compile error — in the
object-literal path and in the `createColumnHelper` path alike. Until TD-31B the
whole column type resolved to the top type and none of that was checked.

The gate stays, and not out of caution: `lint-column-meta-contract.mjs` catches
what a type cannot. `_hasCustomCell` is a DECLARED key of this interface, because
`enhanceColumns` has to write it; a consumer setting it by hand writes valid
TypeScript and always will. That is the gate's own subject, and the two
mechanisms enforce one contract rather than two.

The gate holds every column `meta` block against this interface. It reads the
allowed set out of `src/types/columns.ts` on every run, so **adding a property
here is all that is needed to make it legal** — there is no second list. It
rejects four things:

| Rejected | Why |
| --- | --- |
| an unknown key | nothing reads it; it is silently dropped |
| a retired key (`badgeVariant`) | removed by a decision; the message names it |
| an `@internal` key | the grid writes it — see below |
| `filterOptions` `value: true` | the contract says `value: string`; a boolean breaks the filter (TD-29) |

There is **no skip marker**. A key the gate rejects is a key the contract does not
have; the fix is to change the contract.

**`@internal` marks framework ownership.** A property whose docblock opens with
`@internal` is written by active-grid itself and must never appear in a consumer
declaration — today `_hasCustomCell` (set by `enhanceColumns`) and
`_serverFilterCapability` (stamped by `applyCapabilityContract`). That tag is
machine-read: an underscore-prefixed property without it makes the gate abort
rather than guess at ownership.

**What the gate does not check**: function signatures, row types, and the closed
unions (`semanticType`, `statusTones`, `filterType`). Those need a type checker
rather than a text scanner, and the type checker now does them.

### Display

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | `undefined` | Display label (used in menus, exports, and the accessible names of the column's filter, menu and editor controls; falls back to a string `header`, then the id). Declare it on a column whose `header` is a render function. |
| `headerAlign` | `'left' \| 'center' \| 'right'` | `'left'` | Header text alignment. |
| `cellAlign` | `'left' \| 'center' \| 'right'` | `'left'` | Cell text alignment. |
| `headerClassName` | `string` | `undefined` | Additional CSS class on header cell. |
| `cellClassName` | `string \| ((value: TValue, row: TData) => string \| undefined)` | `undefined` | CSS class on body cells — static string or callback. |
| `headerClass` | `(context) => string` | `undefined` | Dynamic header class function. |
| `textDirection` | `'auto' \| 'ltr' \| 'rtl'` | `undefined` | `GridTextDirection`: the base direction of this column's text in the default renderers (ADR-052). Unset, free text is `auto` (its own first strong character decides) and a formatted value (`semanticType`) inherits the grid's direction. Declare `ltr` for identifiers and codes (`ABC-42`, an IBAN, a policy number) that must keep their order inside right-to-left text. Every run renders isolated (`unicode-bidi: isolate`). Changes neither the column's position nor any exported or copied value. |
| `truncate` | `boolean` | `undefined` | Truncation of the default text renderer: one line, ellipsis, and the full text as `title`. Unset, it follows `wrapText`: a wrapping column does not truncate, any other column does. An explicit `true` or `false` always wins, also over `wrapText`. Custom `cell` renderers and group-row labels are not affected. |
| `wrapText` | `boolean` | `undefined` | Allow text wrapping (multi-line cells). With `truncate` unset, the default text renderer then renders untruncated text, so the text breaks over lines. A group row's label stays on one line: its cell carries no `data-wrap`, and a label too wide for its column is truncated with its full text as `title`. |
| `autoHeight` | `boolean` | `undefined` | Auto-adjust row height to fit content. Combine with `wrapText` to let long text grow the row; on its own it adds no wrapping. |
| `valueFormatter` | `(value: TValue, data: TData) => ReactNode` | `undefined` | Format the raw value of a row that stands for a record. A group row has no record and never calls it; its aggregate uses `semanticType` or `aggregatedCell` (DC-1). Presentation only — DATA export never consults it (WS-04). |
| `meta.exportValue` | `(value: TValue, data: TData) => unknown` | `undefined` | Explicit machine value for DATA export when the raw value is not the machine value. Returns data, never localized text. See `guides/export.md`. |
| `emptyValueKey` | `string` | `undefined` | i18n-key for an **absent** value: `null`, `undefined`, `''` or non-finite numbers (TD-28 / F3b-9). Defaults to `common.placeholder.notAvailable`. Resolved via `t()` at render time; the absence branch runs first, so `valueFormatter` is never invoked for those absent values. |
| `tooltipField` | `keyof TData` | `undefined` | Data field to use as tooltip content. |
| `tooltipValueGetter` | `(value: TValue, row: TData) => string` | `undefined` | Custom tooltip-content function. |

### Conditional Styling

| Property | Type | Description |
| --- | --- | --- |
| `cellClassName` | `string \| ((value: TValue, row: TData) => string \| undefined)` | Static CSS class string, or callback that returns classes based on value/row. |
| `cellClassRules` | `Record<string, (value: TValue, row: TData) => boolean>` | Map of CSS class → condition function. All classes whose condition returns true are applied. |
| `cellStyle` | `(value: TValue, row: TData) => CSSProperties \| undefined` | Dynamic inline styles based on value/row. |

When both `cellClassName` and `cellClassRules` are defined, their results are
combined.

```tsx
// cellClassName callback — positive/negative coloring with shadcn tokens
{
  accessorKey: 'unrealizedGain',
  meta: {
    filterType: 'number',
    cellClassName: (value: number) =>
      value > 0 ? 'text-success' : value < 0 ? 'text-destructive' : undefined,
  },
}

// cellClassRules — multiple independent conditions
{
  accessorKey: 'stock',
  meta: {
    filterType: 'number',
    cellClassRules: {
      'text-destructive font-semibold': (value: number) => value === 0,
      'text-orange-500': (value: number) => value > 0 && value < 10,
    },
  },
}
```

### Filtering

**`filterType` selects a widget; it does not grant permission to filter.**
A filter control exists only when both answers are yes:

| Question | Answered by |
| --- | --- |
| May this column be filtered? | the resolved capability, `column.getCanFilter()` |
| Which widget should it render? | `meta.filterType` |

In **client mode** every accessor column may be filtered unless the consumer
sets `enableColumnFilter: false`, so this reduces to the familiar rule: a
`filterType` gives you a filter cell.

In **server mode** the capability comes from the server contract.
`applyServerContractCapabilities` forces `enableColumnFilter: false` on any
column without a `server.filter` mapping (or without an entry in the
`serverCapabilities` filter map), so a `meta.filterType` — including one
injected by a column type preset — renders **nothing**. That is the point:
before this rule, such a column rendered a fully interactive input that the
server would never honour, on a grid that does not filter locally either
(`manualFiltering: true`). Presentation may narrow the server contract, never
widen it.

Consequences worth knowing:

- A server-mode grid with no filter contract at all shows **no filter row**.
  You do not need `settings={{showFilterRow: false}}` to suppress it.
- `settings.showFilterRow: true` still forces the row to render, but it remains
  a **visibility** override: each cell resolves its own capability, so the row
  comes up empty rather than manufacturing filters the server does not allow.
- Hiding the row never changes `ServerFetchParams.filters`, and never turns
  into filtering of the loaded block only.
- If a server-mode column *should* be filterable, declare it — `fieldFilter()`
  on `column.server`, or the field in the resource's capability map. Failing
  closed on the client is the same rule that already applies to sorting.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `filterType` | `'text' \| 'number' \| 'date' \| 'select' \| 'boolean'` | `undefined` | Which filter widget to render. Requires the column to be filterable — see above. |
| `filterOptions` | `{label, value}[]` | `undefined` | Options for select filters. Doubles as labelMap for `type: 'badge'` columns. |
| `enableFilterOperators` | `boolean` | `undefined` | Show operator selector in filter popover. |
| `allowedOperators` | `FilterOperator[]` | `undefined` | Restrict available operators. |
| `operatorPreset` | `BuiltInOperatorPreset \| (string & {})` | `undefined` | Named operator preset: a built-in, or one declared in `GridConfig.filtering.operatorPresets`. Narrows only. |
| `defaultFilterOperator` | `FilterOperator` | `undefined` | Initial operator selection. |
| `filterSearchable` | `boolean` | `undefined` | Enable search within select-filter options. |
| `filterShowSelectAll` | `boolean` | `undefined` | Show "Select all" option in select filter. |
| `booleanFilterLabels` | `{all?, true?, false?}` | `undefined` | Custom labels for boolean-filter states. |
| `booleanFilterVariant` | `'text' \| 'icon'` | `'text'` | Boolean-filter display style. |
| `dateFilterPresets` | `DateFilterPreset[] \| false` | `undefined` | Custom date presets (array), or `false` to disable. Default: 8 built-in presets. |
| `dateFilterMode` | `'single' \| 'range' \| 'both'` | `'single'` | Calendar mode. `'range'` enables date-range selection; `'both'` shows a toggle. |
| `dateFilterMinDate` | `string \| Date` | `undefined` | Minimum selectable date in the Calendar. |
| `dateFilterMaxDate` | `string \| Date` | `undefined` | Maximum selectable date in the Calendar. |

For the full operator inventory and presets see
`filters.md`. For tutorial coverage of all filter recipes see
`../guides/filters.md`.

### Status columns

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `statusTones` | `Partial<Record<string, GridStatusTone>>` | `undefined` | Declares status semantics: maps each raw cell value to a generic UI-tone. |

A column carries status semantics only when it says so. `statusTones` is that
statement, and it is the **only** one: no filter type, enum name, colour or
label turns a column into a status column, and a column called `status` without
this map stays a categorical badge.

```typescript
{
  accessorKey: 'contractStatus',
  header: t('contract.status'),
  type: 'badge',
  meta: {
    filterOptions: [
      {label: t('contract.status.ACTIVE'), value: 'ACTIVE'},
      {label: t('contract.status.LAPSED'), value: 'LAPSED'},
    ],
    statusTones: {ACTIVE: 'success', LAPSED: 'danger'},
  },
}
```

The key is the raw cell value as a string (`'ACTIVE'`, and `'true'` / `'false'`
on a boolean column). The value is one of the five `GridStatusTone`s:
`neutral`, `info`, `success`, `warning`, `danger`.
There is no `pending` — waiting without required attention is `neutral`,
waiting that needs someone to act is `warning`. A value that is not in the map
falls back to `neutral`.

`statusTones` and `filterOptions` are complementary and each has one home:
`filterOptions` maps value → label, `statusTones` maps value → tone. The label
of a status cell still comes from `filterOptions` (or from `valueFormatter`, or
the raw value when neither is set).

**The mapping belongs to the caller.** active-grid renders tones; it does not
know that `LAPSED` is bad. That is the same division as the label mapping and
it is deliberate — a grid that knew domain statuses would carry the meaning of
every app in the repo.

Use `<StatusCell tone={...}>` in a custom `cell` renderer instead when the cell
value is not a stable key — a derived or already-translated value. A map keyed
on translated text breaks in every other locale.

Booleans get **no** implicit status: `true` and `false` render the same
categorical badge, with the yes/no text as the information. A consumer who wants
`true` to read as success declares it: `statusTones: {true: 'success', false: 'neutral'}`.

### Boolean columns (TD-29, 2026-08-25)

A boolean column is one of two things, and the answer decides the declaration:

| The column is | Declare | Renders |
| --- | --- | --- |
| a **state** carrying a judgement | `type: 'badge'` + `filterOptions` + `statusTones` | badge with a tone |
| a **property** whose two values are peers | `type: 'badge'` + `filterOptions` | badge, tone-less |
| a binary answer with no domain vocabulary | `type: 'boolean'` | the built-in yes/no badge |

```typescript
// State — a tone says something
{accessorKey: 'isActive', type: 'badge', meta: {
  filterOptions: [{label: t('common.status.active'), value: 'true'},
                  {label: t('common.status.inactive'), value: 'false'}],
  statusTones: {true: 'success', false: 'neutral'},
}}

// Property — no tone
{accessorKey: 'isSystem', type: 'badge', meta: {
  filterOptions: [{label: t('common.origin.system'), value: 'true'},
                  {label: t('common.origin.own'), value: 'false'}],
}}
```

Three rules come with it.

**The key space is the string `'true'` / `'false'`.** That is what `String(value)`
produces, so it is what `filterOptions`, `statusTones` and the select filter all
look up. Writing raw booleans as filter values still resolves the *cell* label —
an object key is coerced to text — but the select filter compares `true` against
`'true'` and returns an empty list for every choice.

**Every state carries a word.** A colour or an icon may reinforce it and may
never replace it; that is the house rule (`PAGE_STANDARDS.md` §3 Information, copy and visual hierarchy, WCAG 1.4.1), and it rules out encoding one state as a bare glyph or
as nothing at all.

**No `valueFormatter` on a boolean badge column.** The formatter branch runs
before the badge branch, so a formatter turns `type: 'badge'` into a dead word
and the cell renders plain text. It was a workaround for `BadgeCell` discarding
`false`, and that reason disappeared with TD-27.

Whether a given field is a state or a property is a domain decision and stays
with the consumer; active-grid renders tones and does not know what `isActive`
means. The families this repository has aligned are guarded by
`lint-boolean-column-convergence.mjs` in `repo-tools`.

### What an accessor may return (TD-30, 2026-08-25)

**An accessor delivers a value, not a rendering.** It may derive, compose and
choose between fields. It may not inject the presentation *of absence*, and it
may not apply formatting that depends on the viewer's language or presentation
settings.

```typescript
// ✗ presentation in the raw value
accessorFn: (row) => row.ipAddress || t('common.placeholder.notAvailable')
accessorFn: (row) => row.category?.name ?? '-'
accessorFn: (row) => formatDateOnly(row.startDate, locale)

// ✓ a value; the column decides how absence reads
accessorFn: (row) => row.ipAddress ?? null
accessorFn: (row) => row.category?.name ?? null,  meta: {emptyValueKey: 'common.placeholder.empty'}
accessorKey: 'startDate', type: 'date'
```

Three tests for an accessor's result:

1. **Would it belong in a machine export?** A dash and `Not available` would not;
   a composed name would.
2. **Does it stay identical when the viewer switches language?** If not, it is
   presentation.
3. **Can the column still choose its own word for empty?** If not, the accessor
   took that choice away.

Why it matters is measurable, not stylistic. With `'-'` or a translated word in
the raw value: the DATA export carries UI text (and different bytes per
language), `isEmpty` matches nothing, and sorting places absent rows among the
real values by their translation.

**Deriving is still allowed** — a composed `` `${name} <${email}>` `` is a value
you genuinely filter, sort and export on. A *derived* value should be a stable
key rather than translated text: return `'ACTIVE'` / `'EXPIRED'` and let
`filterOptions` supply the labels, so the export stays language-independent.

Guarded by `lint-accessor-presentation.mjs` in `repo-tools`, which checks the
literal forms only (`?? '-'`, `|| t('common.placeholder.…')`, and formatter
calls). A case whose repair needs its own design decision carries
`// accessor-presentation-skip: <reason>` with a booked item.

### Empty and falsy cell values (TD-27 / TD-28, 2026-08-25)

**`false` and `0` are data.** A cell renders them like any other value. Nullish values, the empty string and non-finite numbers count as absent, and that set is stated once — in the internal
`utils/cell-value.ts` — for **both** the cell orchestration and the primitives:

| Value | Cell orchestration | Primitive called directly |
| --- | --- | --- |
| `null` | placeholder | nothing |
| `undefined` | placeholder | nothing |
| `''` | **placeholder** | nothing — except `TextCell`: `''` |
| `NaN` | **placeholder** | nothing — except `TextCell`: `"NaN"` |
| `false` / `true` | **value** | **value** |
| `0` / `-0` | **value** (epoch → 1970-01-01) | **value** |
| any other finite number | **value** | **value** |

`''` is absent because that is what it means everywhere it occurs in this
repository: it is a normalisation of a nullish source value, written by an
accessor as `(row) => row.field || ''`. Until TD-28 the orchestration tested
nullish only, so that accessor — and not the column — silently decided whether a
missing value read as `Not available` or as nothing. Four defects hung on the
same test, each with the screen on the wrong side of a line the export already
drew correctly: `Number('') === 0` produced a fabricated zero on a number,
currency or percent column; `formatDateOnly('')` returns a translation *key* and
leaked it into the cell; `'' ? yes : no` produced a fabricated "no"; and
`formatNumber(NaN)` returns `''`, so a calculation error looked like an empty
cell. See ADR-024.

**`TextCell` is deliberately not part of that change.** As a primitive it still
renders `''` as a valid empty string: a direct caller who passes an empty string
asked for an empty span, and only the orchestration knows enough about a column
to call that absence.

**Deliberate blankness has two carriers, both explicit.** A `valueFormatter`
that returns `''` runs *after* the absence branch, so its empty result is an
authoring act and stays a genuinely blank cell — the orchestration never
re-tests a formatter's output. And `emptyValueKey: 'common.placeholder.empty'`
renders the dash where absence is the ordinary state of a column rather than a
gap.

**Absence gives the same answer on every layer** (REVIEW-AG-001, 2026-08-26).
The values above, and every non-finite number, are absent for the cell, for the two no-value filter
operators, for both export modes and for the numeric aggregates — one
predicate, no per-layer variation:

| Layer | `null` / `undefined` / `''` | `NaN`, `±Infinity` | `0` and other finite numbers |
| --- | --- | --- | --- |
| Cell | placeholder | placeholder | value |
| `isEmpty` / `isNotEmpty` | `true` / `false` | `true` / `false` | `false` / `true` |
| DATA export | empty | empty | the number |
| PRESENTATION export | `''` | `''` | formatted |
| `sum` / `mean` / `min` / `max` | skipped | skipped | counted |
| `median` | skipped | skipped | counted |

**Group-row aggregation is declarative** (DC-3, ratified 2026-09-22). A column
shows an aggregate in a group row only when it declares `aggregationFn`. A
number is not a quantity: an identifier, a year, a rate, a percentage, a rank
or a numeric code is numeric and has no meaningful sum, so the grid does not
invent one. A column that should total declares it, for example
`aggregationFn: 'sum'`; `auto` remains available as an explicit choice and sums
the column when its values are numbers. Without a declaration the group-row
cell is empty. See `DECISIONS.md` ADR-038.

`median` skips an absent value like the other numeric aggregates: it is the
median of the values that are present, and without a present value there is
no result. Until the kernel cutover it returned nothing as soon as the column
carried a single absent value; that difference was ratified as delta DC-2
(2026-09-22, see `DECISIONS.md` ADR-038).

**`Infinity` and `-Infinity` are absent, like `NaN`** (active-i18n D13,
F3b-9). No product means an infinite number, and the human formatters return
`''` for a non-finite value, so a present cell would render blank. Until F3b-9
the cell and the presentation export showed `∞` and the aggregates counted
the value.

**A numeric formatter decides on presence, never on truthiness** (TD-32,
2026-08-25). Because the absence branch runs first, a `valueFormatter` on a
`number`, `currency`, `percent` or `count` column only ever receives a value
that is present — so it should simply format its input:

```ts
// WRONG — a measured 0 disappears behind the placeholder branch
const wrong = {
  accessorKey: 'amount',
  type: 'currency',
  meta: {
    currencyCode: 'EUR',
    valueFormatter: (value: number | null) => (value ? currency(value) : '-'),
  },
};

// RIGHT — no formatter at all; the declaration already says "money"
const right = {
  accessorKey: 'amount',
  type: 'currency',
  meta: {currencyCode: 'EUR'},
};

// RIGHT — a formatter that carries a unit the declaration cannot supply
const rightWithUnit = {
  accessorKey: 'costBps',
  type: 'number',
  meta: {valueFormatter: (value: string) => `${value} bps`},
};
```

`0` is data (TD-27), not absence (TD-28). A truthiness test hides a measured
zero in a source cell and its presentation export: an amount of zero becomes
a dash. Generated rows have no record and do not call this formatter (DC-1);
their aggregates use declared semantics or `aggregatedCell`.
The DATA export is the exception and always was: by contract it consults no
formatter (WS-04), so a zero has always left it as a real `0`. Deliberate
blankness
is still available, but it has to say so: `value === 0 ? '' : format(value)` is
a declaration, `value ? … : ''` is an accident. Wanting to test for absence is
the same story: write `value == null`. `lint-numeric-zero-presence.mjs` enforces
this and has no skip marker.

**The label is looked up on the string key.** `BadgeCell` derives
`String(value)` and reads `labelMap` (auto-built from `filterOptions`) on that
key, so a boolean column keys on `'true'` / `'false'` — the same shape
`filterOptions` and `statusTones` already use. The key derivation is
deliberately identical to the one the select filter uses, so filter and cell can
never disagree about the same column.

**The cell orchestration owns the placeholder; the primitives do not.** For an absent value
a primitive renders nothing at all, and the dash or `Not available` text comes
from the orchestration, which is the only layer that knows `meta.emptyValueKey`
and has a `t()` function.

That has one consequence worth knowing: **a primitive used directly in a custom
`cell` renderer gets no placeholder**, because that renderer bypassed the
orchestration. This is intended — a second, column-blind placeholder inside the
primitives would compete with `emptyValueKey` rather than complement it. A
custom renderer that wants a placeholder renders its own.

**`emptyValueKey` selects the label, never the emptiness.** Whether a value is
absent is decided by `isAbsentCellValue` alone; `emptyValueKey` only chooses
which word the placeholder shows. There is no metadata that turns `''` back into
a value, and there is deliberately none: the two carriers above already express
intentional blankness, and a third would make "blank" ambiguous again.

**Filtering, sorting, grouping and both export modes read the raw value** and
are untouched by any of this. `isEmpty` already counted `''` as empty before
TD-28, the select filter already keyed `null` and `''` alike on `''`, and the
DATA export already resolved `''` to an empty cell. A machine value never
carries a translated placeholder — see
`guides/export.md` and ADR-022.

### Currency columns (WS-01, 2026-08-14)

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `semanticType` | `'currency'` | `undefined` | Declares money semantics; set by the `currency` column-type preset. |
| `currencyKey` | `string` | `undefined` | Row field carrying the ISO 4217 code — per-row currency identity. |
| `currencyCode` | `string` | `undefined` | Fixed ISO 4217 code when the whole column is one currency by domain contract. |
| `currencyDecimals` | `number` | `undefined` | Deliberate per-column decimals override (e.g. whole dashboard amounts). Applies to body, footer, group aggregates and presentation export alike; omitted, the currency's natural minor units decide. Ignored by DATA export. |

A money column carries its currency identity as **data**, never as a
derivation from the language (PP-005/PP-006). The `currency` preset declares
only `semanticType: 'currency'`, and the cell orchestration resolves the
identity:

```typescript
// Per row: the record carries its own currency (orders in different currencies).
{accessorKey: 'currentValue', type: 'currency', meta: {currencyKey: 'currency'}}

// Fixed column contract: the base currency, supplied by the host layer.
{accessorKey: 'salary', type: 'currency', meta: {currencyCode: baseCurrency}}
```

Without a source the cell renders a bare locale number **without a symbol**
(with a dev warning): no identity means no symbol. Footer and group
aggregations of a `currencyKey` column carry a symbol only when all aggregated
rows share one currency; a sum over different currencies is meaningless and
renders as a bare number (PP-010). Server aggregations format only with a fixed
`currencyCode` contract. The grid stays tenant-blind: it reads codes from rows
or props, never from settings. For these columns the DATA export delivers the
amount as a bare number plus the ISO 4217 identity in a companion column
`<id>_currency` — never a symbol; see
`guides/export.md`.

### Number and percent columns (WS-02, 2026-08-15)

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `semanticType` | `'number' \| 'percent'` | `undefined` | Set by the `number`/`threshold`/`count` resp. `percent` column-type presets. |
| `numberFormat` | `NumberFormat` (active-i18n) | `'decimal'` | Semantic precision preset for a number column (`count` declares `'integer'`). |
| `percentSource` | `'fraction' \| 'percentage-points'` | see preset | The **stored** unit: `'fraction'` = 0-1 data (`0.125` means 12.5%), `'percentage-points'` = 0-100 data. The `percent` preset declares `'fraction'`. |
| `percentPreset` | `PercentPreset` (active-i18n) | `'integer'` | Precision preset for a percent column. |

The body cell and the footer and group aggregations format through the same
central source (the internal helpers in `utils/number-utils.ts`), so a plain
`number` column formats its cells in the locale just as its footer does. A
percent column without a declared `percentSource` renders a bare number
**without a percent sign** (with a dev warning): guessing the unit is the error
the explicit percent API rules out — the same rule as a currency without an
identity source. A column `valueFormatter` still wins over all these
declarations in a source cell; a group or footer aggregate does not call it,
because a generated row has no record (DC-1, ADR-038), and shows the aggregate
through the declared semantics. The grid stays tenant-blind.

```typescript
// 0-1 data: the preset already declares the fraction; only choose precision.
{accessorKey: 'deviationThreshold', type: 'percent', meta: {percentPreset: 'decimal-1'}}

// 0-100 data: the column declares the source unit instead of dividing by 100.
{accessorKey: 'completionRatio', type: 'percent',
 meta: {percentSource: 'percentage-points', percentPreset: 'decimal-1'}}
```

### Date semantics as declaration (WS-04, 2026-08-15)

The `date` and `dateTime` presets also declare their semantics as metadata:
`semanticType: 'date'` (calendar day) and `'dateTime'` (instant)
respectively. The cell orchestration formats through the presentation the grid
receives. The DATA export uses the same semantic declaration for the machine
form: `yyyy-MM-dd` for a calendar day, ISO 8601 UTC for an instant. No preset
formatter closure is needed. A custom column can set the declaration directly.

#### Who chooses the semantic date shape (PP-023)

**The consumer chooses the semantic presentation; the presentation context
only resolves the final form.** A `dateFormat` from an administration profile
(`DMY_NUMERIC`, `MDY_NUMERIC`, `ISO_NUMERIC`) determines what a **numeric**
date looks like; it does not determine whether a column should show a numeric,
short textual or long textual date. The grid follows that by construction: it
passes its `locale` value unchanged to the central formatters and never reads a
field of it itself.

A measurable consequence for the bundled column types: an `MDY_NUMERIC` profile
does not touch a **textual** date column, so such a column renders
byte-identical to the same column under a bare language code. That is pinned in
`config/__tests__/column-type-presets-presentation.test.ts`. The clock half of
`dateTime` does follow the profile (`timeFormat`), because that is a structural
convention and not a word.

**The `date` column type carries no fixed shape.** An ordinary date column
**inherits** the normal human date shape from the presentation context the
consumer passes through `locale` (`PP-053`, `PP-054`):

```text
1  meta.datePreset                  explicit semantics of the column
2  dateShape from the presentation  the presentation owner's choice
3  SHORT_TEXTUAL                    package fallback of active-i18n
```

`active-i18n` resolves levels 2 and 3: the grid simply passes **no** preset when
the column declares none. This package therefore has no date default that could
drift out of step. **The grid stays owner-blind**: it does not read `dateShape`
and does not know the field exists; it knows no administration, system or
tenant (`PP-023`, `PP-058`).

`meta.datePreset` is the declarative exception route, meant for a column with a
**semantic** ground and not for bypassing the setting (`PP-056`). A repo-wide
guard (`lint-date-shape-inheritance`) measures the ratio between inheriting and
declaring places and requires a registered ground for every declaration.

A consumer that wants a different semantic shape declares it per column:

```typescript
// The declarative route, next to meta.numberFormat and meta.percentPreset. Meant
// to be rare: a column without a semantic ground should inherit (PP-056).
{accessorKey: 'birthDate', type: 'date', meta: {datePreset: 'long'}}

// The `columnTypes` prop extends or overrides the built-in types per name;
// the other built-in types stay in force.
const columnTypes: GridColumnTypes<Person> = {
  date: {minSize: 150, meta: {filterType: 'date', semanticType: 'date',
    datePreset: 'long'}},
};
```

**The built-in column types take no presentation.** All five value semantics
are declarations, and the presentation enters exactly once — through the grid's
effective presentation (its `locale` prop, else the nearest `<GridDefaults>`),
which the cell orchestration, the footer, the group aggregates and the
presentation export all four read. There is no second presentation supply that
the caller would have to keep equal by hand.

`meta.datePreset` accepts only day-shaped presets on a calendar-day column
(OP-39 (source repository: `docs/platform/OPEN-PUNTEN.md`)); a time-bearing preset
produces a development warning and falls back to the **inherited** shape,
because a calendar day carries no instant.

### Editing

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `editable` | `boolean \| (row: TData) => boolean` | `undefined` | Whether this column is editable. |
| `editType` | `'text' \| 'number' \| 'date' \| 'select' \| 'boolean'` | `filterType \| 'text'` | Editor input type. |
| `validate` | `(value, row) => string \| undefined \| Promise<string \| undefined>` | `undefined` | Runs at commit, before `onDataCommit`. Return a message to block the commit, `undefined` to allow it. Throwing also blocks. |
| `editOptions` | `{label, value}[]` | `filterOptions` | Options for select editors. |
| `valueParser` | `(value: string) => unknown` | `undefined` | Parse raw editor input before saving (e.g. currency string `"1.000,50"` → number `1000.50`). Returning `undefined` rejects the input and blocks the commit. Runs per keystroke, so use `validate` for range rules. |

### Aggregation

| Property | Type | Description |
| --- | --- | --- |
| `aggregationFn` | `'sum' \| 'avg' \| 'count' \| 'min' \| 'max'` | Aggregation function for the footer. |
| `aggregationLabel` | `string` | Label prefix in the footer cell (e.g., `"Total: "`). |

### Layout

| Property | Type | Description |
| --- | --- | --- |
| `flex` | `number` | Flex grow factor for proportional sizing. |
| `enableResizing` | `boolean` | Override column resizing for this column. |
| `enablePinning` | `boolean` | Override column pinning for this column. |
| `suppressSizeToFit` | `boolean` | Exclude from auto-size calculations. |
| `autoSizeMaxBodyWidth` | `number` | Max width (px) body cells contribute to auto-size. Header width always wins. Overrides grid-level `settings.autoSizeMaxBodyWidth`. |
| `locked` | `boolean` | Lock column width (prevent flex resizing). |
| `headerMenu` | `GridHeaderMenuConfig` | Per-column header-menu override. |
| `suppressFilterIcon` | `boolean` | Hide the filter-active icon for this column. |
| `suppressHeaderMenuButton` | `boolean` | Hide the menu button for this column. |
| `isSystemColumn` | `boolean` | Mark as system column — excluded from edge-column detection (e.g. custom row-action columns). |

## GridColumnTypes

```typescript
import {type GridColumnTypes} from '@workspace/active-grid';
```

Named column-type presets. Each key maps to a partial `GridColumnDef`.

**The built-in types are the package default.** Every grid applies them
(`text`, `id`, `number`, `threshold`, `boolean`, `date`, `dateTime`, `badge`,
`count`, `currency`, `percent`) without a prop. The `columnTypes` prop extends
that map or overrides an entry **by type name**; it never replaces the map as a
whole, so overriding `text` keeps the built-in `number`:

```typescript
const columnTypes: GridColumnTypes<Employee> = {
  currency: {
    size: 120,
    meta: {
      cellAlign: 'right',
      filterType: 'number',
      // Money semantics: the identity comes per column from `currencyKey` or
      // `currencyCode` (see "Currency columns"), never from the language.
      semanticType: 'currency',
    },
  },
  status: {
    size: 100,
    meta: {filterType: 'select'},
  },
};
```

Usage in column definitions:

```typescript
const columns: GridColumnDef<Employee>[] = [
  {accessorKey: 'salary', header: 'Salary', type: 'currency'},
  {accessorKey: 'status', header: 'Status', type: 'status'},
];
```

Multiple types can be applied as an array — later types override earlier
ones: `type: ['currency', 'rightAligned']`.

## GridColumnDefaults

```typescript
import {type GridColumnDefaults} from '@workspace/active-grid';
```

Default values applied to all columns unless overridden:

| Property | Type | Description |
| --- | --- | --- |
| `size` | `number` | Default column width. |
| `minSize` | `number` | Default minimum width. |
| `maxSize` | `number` | Default maximum width. |
| `enableSorting` | `boolean` | Default sorting enabled state. |
| `enableResizing` | `boolean` | Default resizing enabled state. |
| `enablePinning` | `boolean` | Default pinning enabled state. |
| `enableHiding` | `boolean` | Default hiding enabled state. |
| `meta` | `Partial<GridColumnMeta>` | Default metadata. |

Priority order (highest wins): column-definition → column-type → column-defaults → global config.

## GridDefaults

```typescript
import {
  GridDefaults,
  type GridDefaultsProps,
  type GridConfig,
  type GridDiagnostic,
  type GridDiagnosticLevel,
  type GridDiagnosticSink,
  type PartialGridConfig,
} from '@workspace/active-grid';
```

The one defaults boundary of the grid. It carries the presentation (`locale`),
the translator (`t`), the diagnostic sink (`diagnostics`) and the sections of `PartialGridConfig` (the partial form
of `GridConfig`) for every grid rendered below it. Every section is optional, and every field within a section
is optional too. A host mounts it once per application or surface, next to the
chart package's own defaults boundary:

```tsx
<GridDefaults
  locale={presentation}
  t={t}
  layout={{rowHeight: 36}}
  pagination={{pageSize: 25}}
  storage={{keyPrefix: 'app_grid_'}}
>
  <App />
</GridDefaults>
```

### Diagnostics

`diagnostics` receives every diagnostic of the grids below it as a
`GridDiagnostic`: `{level: 'debug' | 'warn' | 'error', source, message,
data?}`, with a caught error under `data.error` and `source` naming the part
(`ActiveGrid`, `ActiveGrid.ColumnVirtualizer`). Like `t`, it is atomic: a
nearer boundary replaces it whole. Without one, `warn` and `error` go to the
console and `debug` is dropped. An inline arrow is safe; the grid forwards to
the sink of the latest render without re-creating its callbacks. The grid
imports no logging package; a host routes the sink to its own logging, for
instance by passing its logger's structured-diagnostic function as the sink.

A misconfiguration (rows without an `id` and no `getRowId`, an invalid
`serverOptions` field) throws a `GridConfigurationError` at render, with a
stable `code` (a `GridConfigurationErrorCode`: **GRID\_ROW\_ID\_MISSING**,
**GRID\_SERVER\_OPTIONS\_INVALID** or **GRID\_DIRECTION\_INVALID**), the `field`, the `constraint` and the refused
`value`. Its constructor takes one `GridConfigurationErrorOptions` object.

```typescript
import {
  GridConfigurationError,
  type GridConfigurationErrorCode,
  type GridConfigurationErrorOptions,
} from '@workspace/active-grid';
```

### Precedence: prop → nearest boundary → package default

```text
presentation:  locale prop  →  nearest GridDefaults locale  →  configuration error
translator:    t prop       →  nearest GridDefaults t       →  configuration error
behaviour:     per-grid prop / option  →  nearest GridDefaults  →  package default
```

Presentation and translator have **no package default**. A grid with neither
the prop nor an inherited value throws at render, naming the component and the
two ways to fix it. `englishGridTranslator` is the package's English: a value a
standalone host, fixture or example passes on purpose, never a fallback.

Every behaviour setting is a **baseline**, never an override. For **stateful**
values the user can change and the grid can remember, two stronger sources sit
above all three — an explicit URL state and an explicit persisted state,
ordered against each other by `statePriority`. That applies to page size:

```text
URL / persisted  →  initialPageSize  →  GridConfig.pagination.pageSize  →  10
```

See `grid-props.md` § Initial page size for
the full pagination chain, including how an invalid value is refused.

### Nested and sibling boundaries

A nested `<GridDefaults>` refines the **enclosing** one; it does not start again
from the package defaults. Presentation and translator are **atomic**: a nearer
value replaces a farther one whole and a presentation object is never merged
per field. The behaviour sections merge as [Merge depth](#merge-depth)
describes. Sibling boundaries share nothing:

```tsx
<GridDefaults locale={presentation} t={t} pagination={{pageSize: 25}}>
  <GridDefaults export={{defaultFilename: 'reports'}}>
    {/* pageSize is still 25, presentation and translator are inherited;
        the filename is 'reports' */}
  </GridDefaults>
</GridDefaults>
```

`undefined` means "inherit". There is no value that removes an inherited
presentation or translator; a nearer boundary can only replace it.

### Merge depth

The merge is **one level deep per section**: an override that sets
`layout.rowHeight` keeps the other three heights from the baseline. Anything
below that is **replaced**, not merged — `columns.autoSizeConfig` and
`ui.headerMenu` are coherent measurement settings whose half-filled variant has
no meaning, and the type requires them complete. Arrays are replaced, never
appended.

**`filtering.operatorPresets` is the one exception, and it is deliberate.** That
map is a *namespace*, not a coherent setting: every key stands alone. Replacing
it would mean that a boundary adding one preset silently drops all built-in
ones, and with them the narrowing of every column that names
`operatorPreset: 'identity'`. Its keys are therefore merged, with the override
winning per name. Overriding a built-in preset is one key with the same name;
adding one is one key with a new name. There is no way to *remove* a built-in
preset, which is intended: removing one would break the columns that name it.

### Config Sections

#### `layout` — `GridLayoutConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `rowHeight` | `number` | `42` | Default row height in pixels. |
| `headerHeight` | `number` | `48` | Header row height. |
| `groupHeaderHeight` | `number` | `32` | Group header row height. |
| `filterBarHeight` | `number` | `36` | Filter row height. |

#### `pagination` — `GridPaginationDefaults`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `pageSize` | `number` | `10` | Page size a grid opens at. Loses to `initialPageSize` and to URL/persisted state; wins over the package default of `10`. |
| `pageSizeOptions` | `number[]` | `[10, 20, 50, 100]` | Available page-size options. Loses to `pagination.pageSizeOptions` on the grid. |

Both are whole numbers greater than zero. A value that is present but invalid
is **refused**, not clamped: the chain continues to the next source and a
`console.warn` in development names the grid.

#### `behavior` — `GridBehaviorConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `debounceMs` | `number` | `300` | Default debounce for global filter. |
| `virtualizationThreshold` | `number` | `1000` | Row count above which virtualization is enabled. |
| `overscan` | `number` | `10` | Extra rows rendered above/below viewport. |
| `maxPinnedRows` | `number` | `10` | Maximum pinned rows. |
| `enableFuzzyFilter` | `boolean` | `true` | Enable fuzzy matching in global search. |

#### `columns` — `GridColumnConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `minWidth` | `number` | `120` | Minimum column width. |
| `maxWidth` | `number` | `800` | Maximum column width. |
| `autoSizeConfig` | `GridAutoSizeConfig` | *(see defaults)* | Column auto-sizing measurement settings. |

`GridAutoSizeConfig` is handed to the measurement layer as a whole. Three of its
fields — `iconWidth`, `gap` and `lockIconWidth` — have had no effect since
`LAYOUT-FIX-05`: the measurement no longer reserves room for elements that are
not rendered, and the lock indicator is absolutely positioned and never affects
content flow. They are kept for shape compatibility, not as knobs.

#### `filtering` — `GridFilteringConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `operatorPresets` | `Record<string, FilterOperator[]>` | 6 built-in presets | Named operator presets. |

Merged per key with the built-ins (see [Merge depth](#merge-depth)). A column
selects one by name with `meta.operatorPreset`; see
`guides/filters.md` § Operator presets
for what a preset can and cannot do.

#### `storage` — `GridStorageConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `keyPrefix` | `string` | `'active_grid_state_'` | localStorage key prefix. |
| `version` | `number` | `3` | State version. Mismatch triggers automatic clear. 3 since logical pinning (ADR-053). |

#### `export` — `GridExportConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultFilename` | `string` | `'active-grid-export'` | Default export filename. |
| `csvSeparator` | `CsvDelimiter` | `','` | Field separator of a CSV export. |
| `fonts` | `GridExportFonts` | none | The PDF export fonts of every grid below (row menu). Below a grid's own `export.fonts`. PDF only (ADR-054). |

`fonts` is an export resource, never a live one: it changes no rendering and
reaches neither CSV, JSON nor XLSX. Without it, the PDF draws in Helvetica and
refuses text outside WinAnsi. See
`guides/export.md`.

`CsvDelimiter` is exactly one of `,` `;` `\t` `|` — a **positive** list, closed
by the type, and re-checked at runtime because a boundary configuration can come from
plain JavaScript. An invalid value is refused (never truncated or trimmed) and
the export falls back to a comma with a `console.warn` in development.

Quoting does not depend on the separator: every CSV field is quoted
unconditionally, so a value containing the separator, a quote or a newline is
safe whichever separator is chosen. The formula-injection guard is unchanged and
sits at the same boundary.

**This setting does not reach the clipboard.** A clipboard copy is TSV and joins
with a tab: that is the format Excel expects on paste, while `csvSeparator` is
about a *file*. The two paths deliberately share no configuration.

#### `ui` — `ActiveGridUIConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `contextMenu` | `ActiveGridMenuConfig` | `{widthClass: 'grid-menu-popup--context', itemClass: 'grid-menu-item--context'}` | Row context-menu presentation. |
| `headerMenu` | `ActiveGridMenuConfig` | `{widthClass: 'grid-menu-popup--header', itemClass: 'grid-menu-item--header'}` | Column header-menu presentation. |
| `linkComponent` | `GridLinkComponent` | none (a native `<a href>`) | The host's link for internal links (`LinkCell`); see ADR-050. |

A column's `meta.rowMenu.className` / `itemClassName` wins over `contextMenu`.
The default classes are the package's own (18rem / 16rem wide, item padding
0.5rem / 0.375rem, `styles/menus.css`); a host passes its own class to change
them. `linkComponent` receives `{href, className, children}` and nothing else,
so a router link such as `next/link` fits it.

#### `features` — `GridFeaturesConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enableColumnReordering` | `boolean` | `false` | Global default for column reordering. |
